monal-rebuild/ux-reactions-runner.cjs
pijukka 38fd403bf4 fix(chat): count a double tap once, however the browser reports it
A browser synthesises a mouse dblclick out of a double tap, so the touch
path and the dblclick path both fired for one gesture and the second
toggle undid the first — the reaction appeared not to happen. Both routes
now go through gestureReact, which treats a repeat on the same message
within 900 ms as the same gesture.

The check found it only after it asserted how many stanzas a gesture
sends, not just what the last one said: with two toggles the last stanza
is a withdrawal, which looks plausible by itself.

Underneath it were two faults in the runner, not the client: a tap point
is absolute, and adding a chip makes the bubble taller so a
bottom-anchored list shifts every row above it — a box measured before the
previous gesture then points at another message. doubleTap now takes a
locator and re-measures. And the dedupe window is product behaviour, so
reactDefault waits past it the way a person would.

One wrong turn is recorded in UX-REACTIONS-RESULTS.md rather than hidden:
session.react was briefly changed to take the full set from the UI on the
strength of that shifted-tap evidence. The reasoning was wrong and the
change traded a fresh database read for the UI's debounced view. Reverted.

Checks: 95 tests, zero svelte-check errors, build, ux-reactions-runner.cjs
14/14 under both engines and ux-swipe-runner.cjs 12/12, zero page errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-09 08:47:22 +02:00

645 lines
34 KiB
JavaScript

// Candidate checks for emoji reactions (XEP-0444), against a local
// `npm run preview` of the production build by default (UX_BASE_URL). The
// websocket is proxied to the real server, so every reaction is a real
// stanza and the archive checks read the real archive.
//
// Three XMPP identities are in play:
// A the browser under test (accounts[0]),
// B a raw client as accounts[1] — it supplies incoming fixtures and,
// as the recipient, lets the outgoing stanzas be read off the wire
// directly, hostile payloads included,
// A2 a second raw resource of accounts[0], used only to query A's own
// MAM archive. It publishes no OMEMO bundle, so it adds no device to
// the account's device list.
//
// What this cannot prove: how the chips and the picker feel on a phone,
// VoiceOver, and the no-push result on a real pushing device — the audit
// accounts have no push registration (REACTIONS-PLAN.md §5, §7).
const fs = require('node:fs');
const path = require('node:path');
const assert = require('node:assert/strict');
const engine = process.argv.find(a => a === 'webkit') ? 'webkit' : 'chromium';
if (engine === 'webkit') {
// The runtime paths the audit report documents for Linux WebKit.
process.env.UX_WEBKIT_PATH ??= '/nix/store/kvbchbp55jd09wh2h1bv3xx9cz26k6vg-playwright-webkit/pw_run.sh';
process.env.LIBGL_ALWAYS_SOFTWARE ??= '1';
process.env.EGL_PLATFORM ??= 'surfaceless';
process.env.LIBGL_DRIVERS_PATH ??= '/nix/store/ppphyhp48gzdji3q9q945gmamy4rrj3f-mesa-26.2.2/lib/dri';
process.env.__EGL_VENDOR_LIBRARY_FILENAMES ??= '/nix/store/ppphyhp48gzdji3q9q945gmamy4rrj3f-mesa-26.2.2/share/glvnd/egl_vendor.d/50_mesa.json';
}
const pw = require(process.env.UX_PLAYWRIGHT_MODULE ||
(engine === 'webkit' ? '/tmp/chat-ux-webkit/node_modules/playwright' : '/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/reaction-profiles/${engine}`;
const out = process.env.UX_RESULTS_DIR || `/tmp/chat-ux-runner/results/reactions-${engine}-${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 SERVICE = process.env.UX_XMPP_SERVICE || 'wss://chat.0ff.space/xmpp-websocket';
const DOMAIN = process.env.UX_XMPP_DOMAIN || '0ff.space';
const MUC = process.env.UX_MUC_DOMAIN || 'muc.0ff.space';
const report = {
engine, origin, checks: [], errors: [],
simulation: 'Synthetic desktop/phone viewport on Linux against the production build. Not phone feel, not VoiceOver, not a real pushing device.',
};
const MAX_PER_SENDER = 12; // must match src/xmpp/reactions.ts
const THUMB = '\u{1F44D}', HEART = '❤️', CRY = '\u{1F62E}';
// The one reaction a double-click gives; must match DEFAULT_REACTION in
// src/ui/Chat.svelte. Any other emoji is reached only by joining a chip
// somebody else already put there.
const DEFAULT = HEART;
// Used only by the orphan check, so a chip bearing it in a reusable profile
// would be this run's own failure and never an earlier run's leftover.
const ORPHAN = '\u{1F991}';
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 => accounts.reduce((text, a) => text.replaceAll(a.password, '[redacted]'), String(s));
let ctx, page, cdp, b, a2, room;
let noticeSeen = false;
const watchers = [];
let errorsAtCheckStart = 0;
const newErrors = () => report.errors.length - errorsAtCheckStart;
const inbox = {b: [], a2: []};
async function launch() {
const options = {
headless: true, viewport: {width: 402, height: 874}, isMobile: engine === 'chromium', hasTouch: true,
};
options.executablePath = engine === 'chromium' ? CHROMIUM : process.env.UX_WEBKIT_PATH;
ctx = await pw[engine].launchPersistentContext(profile, options);
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);
// Real touch, for the gesture as a thumb makes it. CDP is Chromium only;
// under WebKit the double-click path stands in for it, and the phone is
// the operator's check either way.
if (engine === 'chromium') cdp = await ctx.newCDPSession(page);
// The cleartext notice shows once per chat and is then dismissed, so it
// is recorded as it appears rather than looked for afterwards.
page.on('console', () => {});
const watchNotice = setInterval(async () => {
try {
if (await page.locator('.reply-notice').filter({hasText: 'unencrypted'}).count()) noticeSeen = true;
} catch { /* the page is navigating or closed */ }
}, 200);
watchers.push(watchNotice);
page.on('pageerror', e => {
// WebKit reports a skipped ResizeObserver delivery round as a window
// error. The list's anchor observer scrolls to keep the reading position
// when content height changes, which is what it is for, and a row of
// chips appearing at once is the largest such jump. Recorded, not a
// failure: the position itself is asserted by the checks around it.
if (/ResizeObserver loop/.test(e.message)) { (report.warnings ??= []).push(redact(e.message)); return; }
report.errors.push(redact(e.message));
(report.errorDetails ??= []).push({source: 'pageerror', stack: redact(e.stack)});
});
}
/** A raw client; `label` selects which inbox collects its stanzas. */
async function raw(account, resource, label) {
const peer = xmppClient({
service: SERVICE, domain: DOMAIN,
username: account.jid.split('@')[0], password: account.password, resource,
});
peer.on('error', () => {});
peer.on('stanza', stanza => inbox[label].push(stanza));
await peer.start();
// Without presence the server has no available resource to route a
// bare-JID message to, so A's reactions would never reach B.
await peer.send(xml('presence'));
return peer;
}
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();
}
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();
}
/** An incoming bubble from B, so a check never depends on stored history. */
async function receive(text, to = accounts[0].jid) {
const id = 'fixture-' + Math.random().toString(36).slice(2);
await b.send(xml('message', {to, type: 'chat', id}, xml('body', null, text), xml('origin-id', {xmlns: 'urn:xmpp:sid:0', id})));
await page.locator('.row.in .bubble', {hasText: text}).last().waitFor();
return id;
}
const rowFor = (text, side = '') =>
page.locator('.row' + side).filter({has: page.locator('.bubble', {hasText: text})}).last();
/** The chips under one message: emoji, count and whether we are in it. */
async function chips(text, side = '') {
const row = rowFor(text, side);
return row.locator('.reaction').evaluateAll(nodes => nodes.map(n => ({
emoji: n.querySelector('span[aria-hidden]')?.textContent ?? '',
count: Number(n.querySelector('.count')?.textContent ?? '0'),
mine: n.getAttribute('aria-pressed') === 'true',
})));
}
/** Double-click a bubble: the only route to the default reaction. */
async function reactDefault(text, side = '') {
// The client treats a repeat on the same message inside its dedupe window
// as one gesture, because a browser reports a double tap twice. Two
// separate reactions are two separate gestures, as a person makes them.
await delay(1000);
const row = rowFor(text, side);
await row.locator('.bubble').first().dblclick();
return row;
}
/** Two real touch taps in one place, as a thumb makes them. */
async function doubleTap(target) {
// Always measured now, never earlier: adding a chip changes the bubble
// height, and a bottom-anchored list then shifts every row above it, so a
// box taken before the previous gesture points at a different message.
if (typeof target.scrollIntoViewIfNeeded === "function") {
await target.scrollIntoViewIfNeeded();
await delay(300);
}
const box = typeof target.boundingBox === "function" ? await target.boundingBox() : target;
if (!box) throw Error("the bubble to tap is not on screen");
const point = (x, y) => ({x, y, id: 1, radiusX: 12, radiusY: 12, force: 1});
const centre = {x: box.x + box.width / 2, y: box.y + box.height / 2};
for (let i = 0; i < 2; i++) {
await cdp.send('Input.dispatchTouchEvent', {type: 'touchStart', touchPoints: [point(centre.x, centre.y)]});
await delay(40);
await cdp.send('Input.dispatchTouchEvent', {type: 'touchEnd', touchPoints: []});
if (i === 0) await delay(90);
}
}
/** Join or leave a reaction somebody already gave, by tapping its chip. */
async function tapChip(text, emoji, side = '') {
await rowFor(text, side).locator('.reaction').filter({hasText: emoji}).first().click();
}
/** B gives one reaction to the message with this origin id. */
async function peerReacts(targetId, emoji, to = accounts[0].jid, type = 'chat') {
await b.send(xml('message', {to, type, id: 'b-react-' + Math.random().toString(36).slice(2)},
xml('reactions', {xmlns: 'urn:xmpp:reactions:0', id: targetId},
...[].concat(emoji).map(e => xml('reaction', null, e))),
xml('store', {xmlns: 'urn:xmpp:hints'})));
}
/** B's view of A's reaction stanzas, newest last. */
function reactionsSeenByB() {
return inbox.b
.filter(s => s.is('message') && s.getChild('reactions', 'urn:xmpp:reactions:0'))
.map(s => {
const node = s.getChild('reactions', 'urn:xmpp:reactions:0');
return {
id: node.attrs.id,
emoji: node.getChildren('reaction').map(r => r.text()),
store: !!s.getChild('store', 'urn:xmpp:hints'),
type: s.attrs.type,
body: s.getChildText('body'),
raw: s.toString(),
};
});
}
/**
* A MAM archive, read through a second resource of our own account: the
* account's own archive filtered by `with`, or — with `to` — a room's,
* which `mod_muc_mam` governs by its own rules (REACTIONS-PLAN.md §3).
*/
async function archive(withJid, to) {
const queryId = 'mam-' + Math.random().toString(36).slice(2);
inbox.a2.length = 0;
await a2.iqCaller.request(xml('iq', {type: 'set', id: queryId, ...(to ? {to} : {})},
xml('query', {xmlns: 'urn:xmpp:mam:2', queryid: queryId},
xml('x', {xmlns: 'jabber:x:data', type: 'submit'},
xml('field', {var: 'FORM_TYPE', type: 'hidden'}, xml('value', null, 'urn:xmpp:mam:2')),
...(withJid ? [xml('field', {var: 'with'}, xml('value', null, withJid))] : [])),
xml('set', {xmlns: 'http://jabber.org/protocol/rsm'}, xml('max', null, '60'), xml('before')))), 20000);
return inbox.a2
.filter(s => s.is('message') && s.getChild('result', 'urn:xmpp:mam:2'))
.map(s => s.getChild('result', 'urn:xmpp:mam:2').getChild('forwarded', 'urn:xmpp:forward:0')?.getChild('message'))
.filter(Boolean);
}
async function check(id, fn) {
if (checks && !checks.split(',').includes(id)) return;
// Errors are counted per check: one check's page error must not fail the
// next one through a shared total.
errorsAtCheckStart = report.errors.length;
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'), fullPage: true}).catch(() => {});
}
save();
console.log(JSON.stringify(report.checks.at(-1)));
}
async function main() {
await launch();
b = await raw(accounts[1], 'reaction-peer', 'b');
a2 = await raw(accounts[0], 'reaction-archive', 'a2');
await login();
await chat();
const mine = `reaction fixture ${Date.now()}`;
await send(mine);
const theirs = `reaction incoming ${Date.now()}`;
const theirsId = await receive(theirs);
await check('REACT-GOES-OUT', async () => {
const before = reactionsSeenByB().length;
await reactDefault(theirs, '.in');
await until(() => reactionsSeenByB().length > before);
const stanza = reactionsSeenByB().at(-1);
// Without the hint mod_mam archives no bodiless stanza at all, so the
// reaction would silently not survive a reload (REACTIONS-PLAN.md §3).
assert.equal(stanza.store, true, 'the reaction carries no <store/> hint');
assert.equal(stanza.id, theirsId, `the reaction points at ${stanza.id}, not the message's origin id`);
assert.deepEqual(stanza.emoji, [DEFAULT]);
assert.equal(stanza.body, null, 'the reaction carries a body, so it would show as a message elsewhere');
await until(async () => (await chips(theirs, '.in')).some(c => c.emoji === DEFAULT && c.mine));
return {detail: 'a double-click gives the default reaction, with the target origin id, the archive hint and no body', stanza: stanza.raw};
});
await check('REACT-SINGLE-CLICK-DOES-NOTHING', async () => {
// A double-click is the gesture; one click must not be half of it.
const row = rowFor(mine, '.out');
const before = reactionsSeenByB().length;
await row.locator('.bubble').first().click();
await delay(900);
assert.equal(reactionsSeenByB().length, before, 'a single click sent a reaction');
assert.equal((await chips(mine, '.out')).length, 0, 'a single click produced a chip');
return {detail: 'one click neither sends nor renders a reaction'};
});
await check('REACT-DOUBLE-TAP-TOUCH', async () => {
// The mouse path is REACT-GOES-OUT; this is the gesture the operator
// actually makes. One tap must not react, two must.
if (!cdp) return {detail: 'skipped: real touch needs CDP, which is Chromium only', skipped: true};
const row = rowFor(mine, '.out');
// A touch point is absolute: the row has to be on screen and settled, or
// the taps land on whatever else is at those coordinates.
await row.scrollIntoViewIfNeeded();
await delay(400);
const bubble = row.locator('.bubble').first();
const box = await bubble.boundingBox();
const point = (x, y) => ({x, y, id: 1, radiusX: 12, radiusY: 12, force: 1});
const before = reactionsSeenByB().length;
await cdp.send('Input.dispatchTouchEvent', {type: 'touchStart', touchPoints: [point(box.x + box.width / 2, box.y + box.height / 2)]});
await cdp.send('Input.dispatchTouchEvent', {type: 'touchEnd', touchPoints: []});
await delay(900);
assert.equal(reactionsSeenByB().length, before, 'a single tap sent a reaction');
await doubleTap(bubble);
await until(() => reactionsSeenByB().length > before, 10000)
.catch(async () => {
throw Error('the double tap sent nothing. chips=' + JSON.stringify(await chips(mine, '.out'))
+ ' sendError=' + JSON.stringify(await page.locator('.send-error').allInnerTexts().catch(() => []))
+ ' box=' + JSON.stringify(box)
+ ' viewport=' + JSON.stringify(page.viewportSize()));
});
// Exactly one. A browser synthesises a mouse dblclick out of a double
// tap, and when both paths fire the second toggle undoes the first, so
// counting the stanzas is the property that matters, not the last one.
await delay(900);
assert.equal(reactionsSeenByB().length, before + 1,
'the double tap sent ' + (reactionsSeenByB().length - before) + ' reactions, not one');
assert.deepEqual(reactionsSeenByB().at(-1).emoji, [DEFAULT], 'the double tap gave something other than the default reaction');
await until(async () => (await chips(mine, '.out')).some(c => c.emoji === DEFAULT && c.mine))
.catch(async () => { throw Error('the chip never appeared after the double tap: ' + JSON.stringify(await chips(mine, '.out'))); });
// And again withdraws it, so the fixture is left clean.
const given = reactionsSeenByB().length;
await doubleTap(bubble);
await until(() => reactionsSeenByB().length > given, 10000)
.catch(async () => { throw Error('the withdrawing double tap sent nothing. chips=' + JSON.stringify(await chips(mine, '.out'))); });
await delay(900);
assert.equal(reactionsSeenByB().length, given + 1, 'the withdrawing double tap sent more than one reaction');
await until(async () => (await chips(mine, '.out')).length === 0)
.catch(async () => { throw Error('the chip did not go away after withdrawing: ' + JSON.stringify(await chips(mine, '.out'))
+ ' | stanzas sent: ' + JSON.stringify(reactionsSeenByB().slice(-4).map(r => r.emoji))
+ ' | sendError=' + JSON.stringify(await page.locator('.send-error').allInnerTexts().catch(() => []))); });
return {detail: 'one real touch tap does nothing; two give the default reaction, and two more withdraw it'};
});
await check('REACT-REFUSAL-IS-VISIBLE', async () => {
// A message with no id of any kind can never be reacted to: replyId has
// nothing to return. The same silent-refusal path a room message takes
// for a few hundred milliseconds after sending, but deterministic.
const idless = `no id fixture ${Date.now()}`;
await b.send(xml('message', {to: accounts[0].jid, type: 'chat'}, xml('body', null, idless)));
await page.locator('.row.in .bubble', {hasText: idless}).last().waitFor();
const before = reactionsSeenByB().length;
await reactDefault(idless, '.in');
await page.locator('.send-error').filter({hasText: 'no confirmed id yet'}).waitFor({timeout: 5000});
await delay(600);
assert.equal(reactionsSeenByB().length, before, 'a reaction went out for a message with no id');
assert.equal((await chips(idless, '.in')).length, 0, 'a chip appeared for a message that cannot be reacted to');
return {detail: 'a double-click on a message with no stable id says so instead of doing nothing silently'};
});
await check('REACT-NO-BUBBLE-BUTTONS', async () => {
// Both were removed on purpose; the swipe quotes and a double-click
// reacts. Recorded as a check so their return is noticed.
assert.equal(await page.getByRole('button', {name: 'React to message'}).count(), 0, 'the React button is back');
assert.equal(await page.getByRole('button', {name: 'Reply to message'}).count(), 0, 'the Reply button is back');
assert.equal(await page.locator('.reaction-picker').count(), 0, 'the reaction picker is back');
return {detail: 'no Reply or React button and no picker in any bubble'};
});
await check('REACT-FULL-STATE', async () => {
// A second emoji is reached by joining one somebody else gave; that, and
// withdrawing, must each send the complete remaining set.
await peerReacts(theirsId, THUMB);
await until(async () => (await chips(theirs, '.in')).some(c => c.emoji === THUMB));
const joined = reactionsSeenByB().length;
await tapChip(theirs, THUMB, '.in');
await until(() => reactionsSeenByB().length > joined);
await until(async () => (await chips(theirs, '.in')).some(c => c.emoji === THUMB && c.mine && c.count === 2));
const added = reactionsSeenByB().at(-1);
assert.deepEqual(added.emoji.slice().sort(), [THUMB, DEFAULT].sort(), 'joining a chip was sent as a delta, not full state');
// A second double-click withdraws the default one again. The chip goes
// on the local echo, so wait for the stanza B actually receives.
const sent = reactionsSeenByB().length;
await reactDefault(theirs, '.in');
await until(() => reactionsSeenByB().length > sent);
await until(async () => !(await chips(theirs, '.in')).some(c => c.emoji === DEFAULT && c.mine));
const removed = reactionsSeenByB().at(-1);
assert.deepEqual(removed.emoji, [THUMB], 'withdrawing did not resend the remaining set');
const ours = (await chips(theirs, '.in')).filter(c => c.mine);
assert.equal(ours.length, 1);
assert.equal(ours[0].emoji, THUMB);
return {detail: 'joining a chip and double-clicking again each send the whole set; two then one leaves exactly one, ours', sets: [added.emoji, removed.emoji]};
});
await check('REACT-RECEIVE-AND-WITHDRAW', async () => {
// We share THUMB with B at this point. Its accessible name has to say so.
const shared = (await chips(theirs, '.in')).find(c => c.emoji === THUMB);
assert.equal(shared.count, 2, 'the shared chip does not count both of us');
assert.equal(shared.mine, true, 'a chip we are part of is no longer marked as ours');
const label = await rowFor(theirs, '.in').locator('.reaction').filter({hasText: THUMB}).first().getAttribute('aria-label');
assert.ok(label.includes('including you'), 'the chip does not say we are in it: ' + label);
// B withdraws its whole set; ours survives, at a count of one.
await peerReacts(theirsId, []);
await until(async () => (await chips(theirs, '.in')).some(c => c.emoji === THUMB && c.count === 1));
assert.equal((await chips(theirs, '.in')).find(c => c.emoji === THUMB).mine, true);
return {detail: 'a shared reaction counts both and says so; a peer\'s empty set withdraws only theirs'};
});
await check('REACT-IN-ARCHIVE', async () => {
// The bar is the server's archive, not this device's store.
const stanzas = await archive(accounts[1].jid);
const reactions = stanzas
// Only our own: B's withdrawal sits in the same archive under the same
// target id, and is not evidence about what we sent.
.filter(s => (s.attrs.from || '').split('/')[0].toLowerCase() === accounts[0].jid.toLowerCase())
.map(s => s.getChild('reactions', 'urn:xmpp:reactions:0'))
.filter(Boolean)
.map(n => ({id: n.attrs.id, emoji: n.getChildren('reaction').map(r => r.text())}));
assert.ok(reactions.length, 'the archive holds no reaction at all, so the <store/> hint is not working');
const ours = reactions.filter(r => r.id === theirsId);
assert.ok(ours.length, 'the archive has reactions, but none for this message');
assert.deepEqual(ours.at(-1).emoji, [THUMB], 'the archive\'s newest set is not the current one');
return {detail: 'the reaction is in the account\'s own MAM archive, with the current set as its newest copy', archived: ours.length, newest: ours.at(-1).emoji};
});
await check('REACT-SURVIVES-RELOAD', async () => {
await page.reload();
await page.locator('.app').waitFor();
await chat();
await until(async () => (await chips(theirs, '.in')).some(c => c.emoji === THUMB && c.mine), 30000);
return {detail: 'the chip and its own-reaction marking come back after a reload'};
});
await check('REACT-CLAMPED', async () => {
// Twenty emoji and a text payload where an emoji belongs.
const flood = Array.from({length: 20}, (_, i) => xml('reaction', null, String.fromCodePoint(0x1f600 + i)));
await b.send(xml('message', {to: accounts[0].jid, type: 'chat', id: 'b-react-flood'},
xml('reactions', {xmlns: 'urn:xmpp:reactions:0', id: theirsId}, ...flood, xml('reaction', null, 'drop database')),
xml('store', {xmlns: 'urn:xmpp:hints'})));
await until(async () => (await chips(theirs, '.in')).length > 1);
await delay(600);
const shown = await chips(theirs, '.in');
// Our own THUMB plus at most MAX_PER_SENDER from B.
assert.ok(shown.length <= MAX_PER_SENDER + 1, `${shown.length} chips rendered, over the cap`);
// Exactly the cap has to arrive, or a quiet under-render would pass here:
// none of the flood's emoji are ones we gave, so all of them are B's.
assert.equal(shown.filter(c => !c.mine).length, MAX_PER_SENDER,
`${shown.filter(c => !c.mine).length} of the peer's reactions rendered, not the cap of ${MAX_PER_SENDER}`);
assert.equal(shown.some(c => c.emoji.includes('drop database')), false, 'arbitrary text was rendered as a reaction');
assert.equal(newErrors(), 0, 'the flood produced a page error');
return {detail: 'a twenty-emoji set is clamped and a text payload is dropped, without a page error', chips: shown.length};
});
await check('REACT-NO-GHOST', async () => {
const rows = await page.locator('.row').count();
await b.send(xml('message', {to: accounts[0].jid, type: 'chat', id: 'b-react-ghost'},
xml('reactions', {xmlns: 'urn:xmpp:reactions:0', id: 'no-such-message-' + Date.now()}, xml('reaction', null, ORPHAN)),
xml('store', {xmlns: 'urn:xmpp:hints'})));
await delay(1500);
assert.equal(await page.locator('.row').count(), rows, 'a reaction to an unknown message created a row');
const orphan = await page.locator('.messages .reaction').evaluateAll((nodes, emoji) =>
nodes.filter(n => (n.textContent || '').includes(emoji)).length, ORPHAN);
assert.equal(orphan, 0, 'the orphan reaction was rendered as a chip');
assert.equal(newErrors(), 0, 'the orphan reaction produced a page error');
return {detail: 'a reaction whose message is not stored creates no ghost row and no error'};
});
await check('REACT-CLEARTEXT-NOTICE', async () => {
// §4: the one named exception, and it has to be said rather than buried.
// It is shown once per chat, on the first reaction given there, so this
// reads the record the watcher left rather than reacting again.
const encrypted = await page.getByRole('button', {name: 'End-to-end encrypted'}).count() > 0;
assert.equal(noticeSeen, encrypted, encrypted
? 'an encrypted chat never said that reactions go out unencrypted'
: 'an unencrypted chat claimed reactions leak something extra');
if (!encrypted) return {detail: 'the chat is not encrypted, so no extra notice is shown', chatEncrypted: false};
const notice = () => page.locator('.reply-notice').filter({hasText: 'unencrypted'});
const dismiss = async () => {
if (!(await notice().count())) return;
await page.getByRole('button', {name: 'Dismiss', exact: true}).first().click();
await until(async () => (await notice().count()) === 0);
};
// The notice is once per chat per app start, and this run reloaded in
// REACT-SURVIVES-RELOAD, so the first reaction after that is entitled to
// show it again. Consume that one, then prove the next stays quiet.
await dismiss();
const first = reactionsSeenByB().length;
await reactDefault(mine, '.out');
await until(() => reactionsSeenByB().length > first);
await delay(600);
const shownAfterReload = await notice().count();
await dismiss();
// The second reaction in the same chat, same page: silent, and it
// withdraws the first so nothing is left behind.
const second = reactionsSeenByB().length;
await reactDefault(mine, '.out');
await until(() => reactionsSeenByB().length > second);
await delay(600);
assert.equal(await notice().count(), 0, 'the notice came back on a later reaction in the same chat');
await until(async () => (await chips(mine, '.out')).length === 0);
return {detail: 'the encrypted chat says once per app start, not on every reaction, that reactions are sent unencrypted',
chatEncrypted: true, shownAgainAfterReload: !!shownAfterReload};
});
await check('REACT-ROOM-STANZA-ID', async () => {
room = `ux-react-${engine}-${Date.now()}@${MUC}`;
report.groupFixture = room;
await chat(room);
const join = page.getByRole('button', {name: 'Join', exact: true});
if (await join.count()) await join.click();
// The room only exists once the join completed, which the header says.
await until(async () => /participant/.test(await page.locator('.sub').first().innerText().catch(() => '')), 40000)
.catch(async () => {
throw Error('A never joined the room, header said: ' + await page.locator('.sub').first().innerText().catch(() => '?'));
});
// Rooms here default to members-only, so the owner has to admit B first.
await a2.iqCaller.request(xml('iq', {type: 'set', to: room},
xml('query', {xmlns: 'http://jabber.org/protocol/muc#admin'},
xml('item', {affiliation: 'member', jid: accounts[1].jid}))), 20000);
await b.send(xml('presence', {to: `${room}/peer`}, xml('x', {xmlns: 'http://jabber.org/protocol/muc'})));
const fromRoom = () => inbox.b.filter(s => s.is('presence') && (s.attrs.from || '').startsWith(room + '/'));
await until(() => fromRoom().length, 20000)
.catch(() => { throw Error('B got no presence back from the room at all'); });
// An error presence also comes "from the room": a refused join must not
// read as a successful one.
const refused = fromRoom().find(s => s.attrs.type === 'error');
if (refused) throw Error('the room refused B: ' + refused.getChild('error')?.children.map(c => c.name).join(',') + ' :: ' + refused.toString().slice(0, 400));
const text = `room reaction ${Date.now()}`;
inbox.b.length = 0;
await send(text);
// The room stamps its own XEP-0359 id; that, and not the sender's id, is
// the only thing a groupchat reaction may point at.
await until(() => inbox.b.some(s => s.is('message') && s.attrs.type === 'groupchat' && s.getChildText('body') === text))
.catch(() => {
const seen = inbox.b.filter(s => s.is('message')).map(s => s.attrs.type + ':' + (s.getChildText('body') || '—')).slice(-6);
throw Error('B never saw the room message. Messages B did see: ' + JSON.stringify(seen));
});
const reflected = inbox.b.find(s => s.is('message') && s.getChildText('body') === text);
const roomId = reflected.getChildren('stanza-id', 'urn:xmpp:sid:0').find(n => (n.attrs.by || '').toLowerCase() === room)?.attrs.id;
assert.ok(roomId, 'the room issued no stanza id, so the check cannot run');
assert.notEqual(roomId, reflected.attrs.id, 'the room reused the sender id, so this proves nothing');
const before = reactionsSeenByB().length;
// A room stamps our own copy only when it reflects it back, so for a few
// hundred milliseconds after sending there is nothing to react to. That
// window is real; what must not happen is a double-tap doing nothing and
// saying nothing.
let tries = 0, refusalSeen = false;
await until(async () => {
tries++;
await reactDefault(text, '.out');
await delay(700);
if (await page.locator('.send-error').filter({hasText: 'no confirmed id yet'}).count()) refusalSeen = true;
return reactionsSeenByB().length > before;
}, 20000).catch(() => { throw Error('the room reaction never reached B after ' + tries + ' double-clicks'); });
assert.ok(tries === 1 || refusalSeen,
'a double-click before the room confirmed the message did nothing and said nothing');
const stanza = reactionsSeenByB().at(-1);
assert.equal(stanza.type, 'groupchat', 'the room reaction went out as a 1:1 message');
assert.equal(stanza.id, roomId, `the room reaction points at ${stanza.id}, not the room-issued ${roomId}`);
assert.equal(stanza.store, true, 'the room reaction carries no <store/> hint');
// And B's reaction, with the same room id, lands on our message.
await peerReacts(roomId, CRY, room, 'groupchat');
await until(async () => (await chips(text, '.out')).some(c => c.emoji === CRY));
const shown = await chips(text, '.out');
assert.equal(shown.find(c => c.emoji === DEFAULT).mine, true);
assert.equal(shown.find(c => c.emoji === CRY).mine, false, 'an occupant\'s reaction was counted as ours');
return {detail: 'a room reaction carries the room-issued stanza id both ways, ours stays distinguishable, and a tap before the room confirms the message says so',
roomId, doubleClicks: tries, refusalShown: refusalSeen};
});
await check('REACT-ROOM-ARCHIVE', async () => {
// §3 left this open: mod_muc_mam has its own storage rules, so whether a
// room keeps reactions had to be read out of the room's own archive.
if (!room) throw Error('no room fixture: the groupchat check did not get that far');
await a2.send(xml('presence', {to: `${room}/archive-reader`}, xml('x', {xmlns: 'http://jabber.org/protocol/muc'})));
await until(() => inbox.a2.some(s => s.is('presence') && (s.attrs.from || '').startsWith(room + '/')), 20000);
const stanzas = await archive(undefined, room);
const reactions = stanzas
.map(s => ({from: s.attrs.from, node: s.getChild('reactions', 'urn:xmpp:reactions:0')}))
.filter(r => r.node)
.map(r => ({from: r.from, emoji: r.node.getChildren('reaction').map(x => x.text()), id: r.node.attrs.id}));
assert.ok(reactions.length, 'the room archive holds no reaction, so they do not survive a rejoin there');
assert.ok(reactions.some(r => r.emoji.includes(DEFAULT)), 'ours is missing from the room archive');
assert.ok(reactions.some(r => r.emoji.includes(CRY)), 'the occupant\'s is missing from the room archive');
return {detail: 'mod_muc_mam does keep room reactions, with the <store/> hint', archived: reactions.length};
});
report.pageErrors = report.errors.length;
report.pageWarnings = (report.warnings || []).length;
save();
}
async function cleanup() {
for (const w of watchers) clearInterval(w);
// Leave no owned audit room behind: the earlier packages' handoffs check
// for exactly this.
if (room) {
await a2?.iqCaller.request(xml('iq', {type: 'set', to: room},
xml('query', {xmlns: 'http://jabber.org/protocol/muc#owner'}, xml('destroy')))).catch(() => {});
if (page && !page.isClosed()) {
await chat(room).catch(() => {});
await page.getByRole('button', {name: 'More actions', exact: true}).click().catch(() => {});
page.once('dialog', d => d.accept());
await page.getByRole('menuitem', {name: 'Leave room', exact: true}).click().catch(() => {});
await delay(800);
}
}
await b?.stop().catch(() => {});
await a2?.stop().catch(() => {});
await ctx?.close().catch(() => {});
}
main()
.catch(e => { console.error(e); report.errors.push(redact(e.message)); save(); process.exitCode = 1; })
.finally(async () => {
await cleanup();
save();
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, ${(report.warnings || []).length} recorded warnings`);
console.log(`report: ${path.join(out, 'report.json')}`);
if (failed.length || report.errors.length) process.exitCode = 1;
});