The message list renders a window of the newest messages, so in a long fixture chat every new row pushes the oldest out of the DOM and counting .row.out never moves. SWIPE-QUOTES-ATTACHMENT-BY-NAME read that as a dropped attachment while the upload, the stanza and the row were all fine. It now waits for a data-message-key that was not there before. SWIPE-VERTICAL-SCROLLS had the matching fault: it measured a fixture that had scrolled out of view, so the pan started outside the list. Both suites are green against the live site at generation 566: reactions 14/14 under Chromium and WebKit, swipe 12/12. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
373 lines
19 KiB
JavaScript
373 lines
19 KiB
JavaScript
// 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('<message')) wire.messages.push(s);
|
|
server.send(message);
|
|
});
|
|
server.onMessage(m => 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(/<reply[^>]+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; });
|