// Candidate checks for swipe-to-reply: drag a bubble right to quote it, as // in Telegram. Runs against a local `npm run preview` of the production // build by default (UX_BASE_URL), because the gesture is client-only; the // websocket is proxied to the real server, so the reply still goes out as a // real stanza. // // Both directions are exercised: an incoming bubble replies when dragged // right, your own when dragged left, each in the direction that moves it // towards the middle of the screen. The incoming fixture is sent over XMPP // by the second audit account, so the check does not depend on history. // // Touch is driven through CDP's Input.dispatchTouchEvent, not Playwright's // mouse: the handler ignores pointerType "mouse" on purpose (a mouse drag // over a bubble is a text selection), and only real touch gives the // pointer id that setPointerCapture needs. So: Chromium only. What this // cannot prove is the feel on a phone — travel, snap-back and the iOS // back-swipe at the left screen edge are device checks. const fs = require('node:fs'); const path = require('node:path'); const assert = require('node:assert/strict'); const pw = require(process.env.UX_PLAYWRIGHT_MODULE || '/tmp/chat-ux-runner/node_modules/playwright'); const {client: xmppClient, xml} = require('@xmpp/client'); const accounts = JSON.parse(fs.readFileSync(process.env.UX_ACCOUNTS_FILE || '/tmp/chat-ux-runner/accounts.json', 'utf8')); const option = name => process.argv.find(arg => arg.startsWith('--' + name + '='))?.slice(name.length + 3); const checks = process.env.UX_CHECKS || option('checks'); const origin = process.env.UX_BASE_URL || option('origin') || 'http://localhost:4173'; const profile = process.env.UX_PROFILE_DIR || '/tmp/chat-ux-runner/swipe-profiles/a'; const out = process.env.UX_RESULTS_DIR || `/tmp/chat-ux-runner/results/swipe-${Date.now()}`; fs.mkdirSync(out, {recursive: true, mode: 0o700}); const CHROMIUM = process.env.UX_CHROMIUM_PATH || '/nix/store/33pxss8h71cl7vmfpy21bidsw0lj1g8q-chromium-152.0.7977.82/bin/chromium'; const report = {engine: 'chromium', origin, checks: [], errors: [], simulation: 'Synthetic touch on a Linux Chromium phone viewport against the production build. Not phone feel, not iOS edge gestures, not VoiceOver.'}; const wire = {messages: []}; const delay = ms => new Promise(r => setTimeout(r, ms)); async function until(fn, timeout = 20000) { const end = Date.now() + timeout; do { if (await fn()) return; await delay(100); } while (Date.now() < end); throw Error('Condition timed out'); } function save() { fs.writeFileSync(path.join(out, 'report.json'), JSON.stringify(report, null, 2), {mode: 0o600}); } const redact = s => String(s).replaceAll(accounts[0].password, '[redacted]').replaceAll(accounts[1].password, '[redacted]'); let ctx, page, cdp; async function launch() { ctx = await pw.chromium.launchPersistentContext(profile, { headless: true, viewport: {width: 402, height: 874}, isMobile: true, hasTouch: true, executablePath: CHROMIUM, }); await ctx.routeWebSocket('**/xmpp-websocket', ws => { const server = ws.connectToServer(); ws.onMessage(message => { const s = String(message); if (s.startsWith(' ws.send(m)); }); page = ctx.pages()[0] || await ctx.newPage(); page.setDefaultTimeout(15000); page.on('pageerror', e => { report.errors.push(redact(e.message)); (report.errorDetails ??= []).push({source: 'pageerror', stack: redact(e.stack)}); }); page.on('console', m => { if (m.type() === 'warning' || m.type() === 'error') (report.consoleLines ??= []).push(redact(m.type() + ': ' + m.text()).slice(0, 300)); }); page.on('requestfailed', r => { (report.networkFailures ??= []).push({url: r.url(), type: r.resourceType(), error: r.failure()?.errorText}); }); cdp = await ctx.newCDPSession(page); } async function login(account = accounts[0]) { await page.goto(origin); let submitted = false; await until(async () => { if (!submitted && await page.locator('[name=username]').isVisible()) { submitted = true; await page.locator('[name=username]').fill(account.jid); await page.locator('[name=password]').fill(account.password); await page.getByRole('checkbox', {name: 'Stay logged in'}).check(); await page.getByRole('button', {name: 'Log in', exact: true}).click(); } return await page.locator('.app').count() > 0 && !await page.locator('.connection').count(); }, 30000); } async function chat(jid = accounts[1].jid) { await page.evaluate(j => location.hash = '#/chat/' + encodeURIComponent(j), jid); await page.locator('.composer textarea:enabled').waitFor(); } /** A real touch drag: CDP, so the page sees genuine pointer events. */ async function drag(from, dx, dy, {steps = 14, release = true} = {}) { const point = (x, y) => ({x, y, id: 1, radiusX: 12, radiusY: 12, force: 1}); await cdp.send('Input.dispatchTouchEvent', {type: 'touchStart', touchPoints: [point(from.x, from.y)]}); for (let i = 1; i <= steps; i++) { await cdp.send('Input.dispatchTouchEvent', {type: 'touchMove', touchPoints: [point(from.x + (dx * i) / steps, from.y + (dy * i) / steps)]}); await delay(8); } if (release) await cdp.send('Input.dispatchTouchEvent', {type: 'touchEnd', touchPoints: []}); } const centre = box => ({x: box.x + box.width / 2, y: box.y + box.height / 2}); /** An incoming bubble, sent by the other audit account over XMPP. */ async function receive(text) { const account = accounts[1]; const peer = xmppClient({ service: 'wss://chat.0ff.space/xmpp-websocket', domain: '0ff.space', username: account.jid.split('@')[0], password: account.password, resource: 'swipe-fixture', }); peer.on('error', () => {}); await peer.start(); await peer.send(xml('message', {to: accounts[0].jid, type: 'chat', id: text.replace(/\s/g, '-')}, xml('body', null, text))); await peer.stop().catch(() => {}); await page.locator('.row.in .bubble', {hasText: text}).last().waitFor(); } async function send(text) { await page.locator('.composer textarea').fill(text); await page.getByRole('button', {name: 'Send', exact: true}).click(); await page.locator('.bubble', {hasText: text}).last().waitFor(); } /** The row holding this text, and a point inside its bubble. */ async function rowFor(text, side = '') { const row = page.locator('.row' + side).filter({has: page.locator('.bubble', {hasText: text})}).last(); await row.waitFor(); return {row, box: await row.locator('.bubble').boundingBox()}; } const previewOpen = () => page.locator('.reply-preview').count().then(n => n > 0); async function clearPreview() { if (await previewOpen()) await page.getByRole('button', {name: 'Cancel reply'}).click(); await until(async () => !(await previewOpen())); } async function check(id, fn) { if (checks && !checks.split(',').includes(id)) return; try { report.checks.push({id, status: 'passed', ...await fn()}); } catch (e) { report.checks.push({id, status: 'failed', detail: redact(e.message)}); await page.screenshot({path: path.join(out, id + '.png')}).catch(() => {}); } save(); console.log(JSON.stringify(report.checks.at(-1))); } async function main() { await launch(); await login(); await chat(); const mine = `swipe fixture ${Date.now()}`; await send(mine); const theirs = `swipe incoming ${Date.now()}`; await receive(theirs); await check('SWIPE-IN-COMMITS-RIGHT', async () => { await clearPreview(); const {box} = await rowFor(theirs, '.in'); await drag(centre(box), 64, 0); await page.locator('.reply-preview').waitFor(); const quoted = await page.locator('.reply-preview span').first().innerText(); assert.equal(quoted.trim(), theirs, 'the preview quotes a different message'); return {detail: 'an incoming bubble dragged 64 px right opens the reply preview with its text'}; }); await check('SWIPE-IN-IGNORES-LEFT', async () => { await clearPreview(); const {box} = await rowFor(theirs, '.in'); await drag(centre(box), -70, 0); await delay(400); assert.equal(await previewOpen(), false, 'an incoming bubble replied when dragged left'); return {detail: 'an incoming bubble does not reply when dragged towards its own side'}; }); await check('SWIPE-OUT-COMMITS-LEFT', async () => { await clearPreview(); const {box} = await rowFor(mine, '.out'); await drag(centre(box), -64, 0); await page.locator('.reply-preview').waitFor(); const quoted = await page.locator('.reply-preview span').first().innerText(); assert.equal(quoted.trim(), mine, 'the preview quotes a different message'); return {detail: 'your own bubble dragged 64 px left opens the reply preview with its text'}; }); await check('SWIPE-OUT-IGNORES-RIGHT', async () => { await clearPreview(); const {box} = await rowFor(mine, '.out'); await drag(centre(box), 70, 0); await delay(400); assert.equal(await previewOpen(), false, 'your own bubble replied when dragged right'); return {detail: 'your own bubble does not reply when dragged towards its own side'}; }); await check('SWIPE-OFFSET-FOLLOWS-SIDE', async () => { await clearPreview(); // Held, not released: the sign of the offset is what is under test. const incoming = await rowFor(theirs, '.in'); await drag(centre(incoming.box), 40, 0, {release: false}); const inOffset = await incoming.row.evaluate(el => el.style.getPropertyValue('--swipe')); await cdp.send('Input.dispatchTouchEvent', {type: 'touchEnd', touchPoints: []}); await delay(300); const outgoing = await rowFor(mine, '.out'); await drag(centre(outgoing.box), -40, 0, {release: false}); const outOffset = await outgoing.row.evaluate(el => el.style.getPropertyValue('--swipe')); await cdp.send('Input.dispatchTouchEvent', {type: 'touchEnd', touchPoints: []}); await delay(300); assert.ok(parseFloat(inOffset) > 0, 'an incoming bubble did not move right: ' + inOffset); assert.ok(parseFloat(outOffset) < 0, 'your own bubble did not move left: ' + outOffset); return {detail: 'each bubble moves towards the middle while the finger holds it', inOffset, outOffset}; }); await check('SWIPE-SHORT-DOES-NOTHING', async () => { await clearPreview(); const {row, box} = await rowFor(theirs, '.in'); await drag(centre(box), 30, 0); await delay(400); assert.equal(await previewOpen(), false, 'a 30 px drag opened the reply preview'); assert.equal(await row.evaluate(el => el.style.getPropertyValue('--swipe')), '0px', 'the bubble stayed offset'); return {detail: 'a 30 px drag neither replies nor leaves the bubble offset'}; }); await check('SWIPE-VERTICAL-SCROLLS', async () => { await clearPreview(); const list = page.locator('.messages'); // Pinned at the bottom there is nothing left to scroll towards, so the // finger has to pull downwards, into the history. await list.evaluate(el => el.scrollTop = el.scrollHeight); const room = await list.evaluate(el => el.scrollHeight - el.clientHeight); assert.ok(room > 240, 'the fixture chat is too short to scroll; the check cannot run'); const before = await list.evaluate(el => el.scrollTop); // Measured after the scroll, and on a row that is still on screen: the // named fixture can be far up the history once the chat is long, and a // drag starting outside the list is not a pan at all. const box = await page.locator('.row.in').last().boundingBox(); const area = await list.boundingBox(); assert.ok(box && box.y >= area.y && box.y + box.height <= area.y + area.height, 'no incoming bubble is fully on screen, so the pan cannot start on one'); await drag(centre(box), 8, 220, {steps: 18}); await delay(400); assert.equal(await previewOpen(), false, 'a vertical drag opened the reply preview'); const after = await list.evaluate(el => el.scrollTop); assert.ok(after < before, 'the list did not scroll, so the gesture ate the pan'); return {detail: 'a mostly vertical drag scrolls the list and does not reply', scrollTop: {before, after}}; }); await check('SWIPE-EDGES-IGNORED', async () => { await clearPreview(); await page.locator('.messages').evaluate(el => el.scrollTop = el.scrollHeight); const width = await page.evaluate(() => window.innerWidth); // Where iOS puts its own back gesture. const incoming = await rowFor(theirs, '.in'); await drag({x: 8, y: incoming.box.y + incoming.box.height / 2}, 90, 0); await delay(400); assert.equal(await previewOpen(), false, 'a drag from the left screen edge replied'); // And its forward gesture. const outgoing = await rowFor(mine, '.out'); await drag({x: width - 8, y: outgoing.box.y + outgoing.box.height / 2}, -90, 0); await delay(400); assert.equal(await previewOpen(), false, 'a drag from the right screen edge replied'); return {detail: 'gestures starting inside either iOS edge are left alone'}; }); await check('SWIPE-KEEPS-LINK-UNOPENED', async () => { await clearPreview(); const linked = `swipe link fixture https://example.invalid/${Date.now()}`; await send(linked); const {box} = await rowFor(linked, '.out'); const link = await page.locator('.row.out .bubble', {hasText: linked}).last().locator('a').boundingBox(); const pages = ctx.pages().length; await drag(centre(link ?? box), -70, 0); await page.locator('.reply-preview').waitFor(); await delay(500); assert.equal(ctx.pages().length, pages, 'the swipe also opened the link'); return {detail: 'swiping across a link quotes the message and does not open the link'}; }); await check('SWIPE-REPLY-GOES-OUT', async () => { await clearPreview(); const {box} = await rowFor(theirs, '.in'); await drag(centre(box), 64, 0); await page.locator('.reply-preview').waitFor(); const answer = `swipe answer ${Date.now()}`; const since = wire.messages.length; await send(answer); await until(() => wire.messages.slice(since).some(s => s.includes('urn:xmpp:reply:0')), 15000); const stanza = wire.messages.slice(since).find(s => s.includes('urn:xmpp:reply:0')); assert.ok(/]+id=/.test(stanza), 'the reply element carries no id'); assert.equal(await previewOpen(), false, 'the preview stayed open after sending'); return {detail: 'the swipe-made reply goes out as a urn:xmpp:reply:0 stanza and the preview clears'}; }); await check('SWIPE-QUOTES-ATTACHMENT-BY-NAME', async () => { // Quoting an attachment must name it, not quote its URL. For an // encrypted one that URL's fragment is the file key, so copying it into // the quote would put the key into a second message's body — and it // reads as a wall of hex where a human expects "Photo". await clearPreview(); // A 1x1 PNG, enough to be an attachment; this chat is OMEMO-capable, so // it goes out encrypted and its body becomes an aesgcm:// URL. const png = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64'); const name = `swipe-quote-${Date.now()}.png`; // The composer only accepts an attachment once media capability is // settled; the button is that state. Setting the file before it is ready // is dropped silently, which is how this check used to fail in a long run. await page.getByRole('button', {name: 'Attach a file', exact: true}).waitFor(); await until(async () => await page.getByRole('button', {name: 'Attach a file', exact: true}).isEnabled(), 60000) .catch(async () => { throw Error('the attach button never became enabled: ' + JSON.stringify(await page.locator('.media-reason, .send-error').allInnerTexts().catch(() => []))); }); // Counting rows would be wrong: the list renders a window of the newest // messages, so once the fixture chat is longer than a page every new row // pushes the oldest out of the DOM and the count never moves. Identity is // the signal — a key that was not there before. const keys = async () => new Set(await page.locator('.row.out').evaluateAll( n => n.map(x => x.getAttribute('data-message-key')))); const before = await keys(); await page.locator('input[type=file]').setInputFiles({name, mimeType: 'image/png', buffer: png}); const added = async () => [...await keys()].filter(k => !before.has(k)); await until(async () => (await added()).length > 0, 60000) .catch(async () => { const lines = []; for (const sel of ['.send-error', '.media-reason', '.upload']) { lines.push(sel + '=' + JSON.stringify(await page.locator(sel).allInnerTexts().catch(() => []))); } throw Error('the attachment never became a message. Page said: ' + lines.join(' | ')); }); const row = page.locator(`.row.out[data-message-key="${(await added())[0]}"]`); await row.locator('.bubble img, .bubble .media, .bubble .file').first().waitFor({timeout: 60000}); const box = await row.locator('.bubble').boundingBox(); await drag(centre(box), -64, 0); await page.locator('.reply-preview').waitFor(); const preview = (await page.locator('.reply-preview span').first().innerText()).trim(); assert.equal(preview, '📷 Photo', 'the reply preview did not name the attachment: ' + preview); assert.equal(/aesgcm:/i.test(preview), false, 'the reply preview shows the aesgcm URL'); const answer = `quote answer ${Date.now()}`; await send(answer); const quoted = page.locator('.row.out').filter({has: page.locator('.bubble', {hasText: answer})}) .last().locator('.quoted span'); await quoted.waitFor(); const shown = (await quoted.innerText()).trim(); assert.ok(shown.includes('🎤') || shown.includes('📷') || shown.includes('📎'), 'the sent quote does not name the attachment: ' + shown); assert.equal(/aesgcm:/i.test(shown), false, 'the sent quote carries the aesgcm URL, and with it the file key'); // And the body that went out carries no key either, envelope or not. const stanza = wire.messages.filter(m => m.includes(answer.split(' ')[0])).at(-1) || ''; assert.equal(/aesgcm:/i.test(stanza), false, 'the outgoing stanza carries an aesgcm URL in cleartext'); return {detail: 'swiping an attachment quotes its name, and neither the quote nor the wire carries the aesgcm URL', preview, quoted: shown}; }); await check('SWIPE-IS-THE-ONLY-QUOTE-ROUTE', async () => { // The operator had both bubble buttons removed on 2026-10-08: the swipe // quotes and a double-tap reacts. Recorded so the loss of the keyboard // route stays visible rather than being discovered later. await clearPreview(); const {row} = await rowFor(theirs, '.in'); assert.equal(await row.getByRole('button', {name: 'Reply to message'}).count(), 0, 'the Reply button is back'); assert.equal(await row.getByRole('button', {name: 'React to message'}).count(), 0, 'the React button is back'); return {detail: 'no bubble buttons: the swipe is the only quote route, and it is touch-only', limitation: 'a mouse or keyboard cannot quote a message'}; }); report.pageErrors = report.errors.length; save(); await ctx.close(); const failed = report.checks.filter(c => c.status === 'failed'); console.log(`\n${report.checks.length - failed.length}/${report.checks.length} checks passed, ${report.errors.length} page errors`); console.log(`report: ${path.join(out, 'report.json')}`); if (failed.length || report.errors.length) process.exitCode = 1; } main().catch(async e => { console.error(e); save(); await ctx?.close().catch(() => {}); process.exitCode = 1; });