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>
645 lines
34 KiB
JavaScript
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;
|
|
});
|