// 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(' 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 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 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 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 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; });