// Exploratory checks against dedicated audit accounts, never a personal account. const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); const crypto = require('node:crypto'); const engine = process.argv[2] || 'chromium'; if (!['chromium', 'webkit'].includes(engine)) throw Error('Use chromium or webkit.'); const modulePath = process.env.UX_PLAYWRIGHT_MODULE || '/tmp/chat-ux-runner/node_modules/playwright'; const pw = require(modulePath); const accounts = JSON.parse(fs.readFileSync(process.env.UX_ACCOUNTS_FILE || '/tmp/chat-ux-runner/accounts.json', 'utf8')); const runId = `${engine}-${Date.now()}`; const out = process.env.UX_RESULTS_DIR || `/tmp/chat-ux-runner/results/${runId}`; fs.mkdirSync(out, { recursive: true, mode: 0o700 }); const origin = process.env.UX_BASE_URL || 'https://chat.0ff.space'; const room = 'ux-audit-20261007@muc.0ff.space'; const report = { started: new Date().toISOString(), engine, viewport: { width: 402, height: 874 }, mode: 'mobile browser emulation, not installed iPhone PWA', checks: [], resources: [], errors: [] }; report.origin = origin; report.gitRevision = require('node:child_process').execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); report.dirty = !!require('node:child_process').execFileSync('git', ['status', '--porcelain'], { encoding: 'utf8' }).trim(); let browser, timer, a, b; const delay = ms => new Promise(r => setTimeout(r, ms)); function memory() { const text = fs.readFileSync('/proc/meminfo', 'utf8'); const value = name => Math.round(Number(text.match(new RegExp(`^${name}:\\s+(\\d+)`, 'm'))?.[1]) / 1024); return { availableMiB: value('MemAvailable'), swapFreeMiB: value('SwapFree'), load: os.loadavg() }; } async function until(fn, timeout = 12000) { const deadline = Date.now() + timeout; do { if (await fn()) return; await delay(150); } while (Date.now() < deadline); throw Error('Condition not reached within ' + timeout + 'ms'); } function sanitize(error) { let s = String(error); for (const account of accounts) s = s.replaceAll(account.password, '[redacted]'); return s; } async function check(id, fn) { try { report.checks.push({ id, ...await fn() }); } catch (error) { report.checks.push({ id, status: 'execution-error', detail: sanitize(error.message) }); if (a?.page && !a.page.isClosed()) await capture(a.page, 'error-' + id).catch(() => {}); } fs.writeFileSync(path.join(out, 'report.json'), JSON.stringify(report, null, 2), { mode: 0o600 }); } async function capture(page, name) { await page.screenshot({ path: path.join(out, name + '.png'), fullPage: true }); } async function login(account) { const context = await browser.newContext({ viewport: report.viewport, isMobile: true, hasTouch: true, deviceScaleFactor: 2, ...(engine === 'webkit' ? { userAgent: pw.devices['iPhone 13'].userAgent } : {}) }); let pageTimer; const page = await Promise.race([ context.newPage(), new Promise((_, reject) => { pageTimer = setTimeout(() => reject(Error('Browser failed to create a page within 30 seconds')), 30000); }), ]).finally(() => clearTimeout(pageTimer)); page.setDefaultTimeout(10000); const transport = { enabled: true, sockets: [] }; await page.routeWebSocket('**/xmpp-websocket', ws => { if (!transport.enabled) { void ws.close({ code: 1011, reason: 'Synthetic UX interruption' }); return; } ws.connectToServer(); transport.sockets.push(ws); }); page.on('pageerror', error => report.errors.push(sanitize(error.message))); await page.goto(origin); await page.locator('[name=username]').fill(account.jid); await page.locator('[name=password]').fill(account.password); await page.getByRole('button', { name: 'Log in', exact: true }).click(); await page.getByRole('button', { name: 'New chat', exact: true }).waitFor({ timeout: 30000 }); return { context, page, transport }; } async function openChat(page, jid) { await page.evaluate(() => { location.hash = ''; }); await page.getByRole('button', { name: 'New chat', exact: true }).click(); await page.locator('form.new input').fill(jid); await page.getByRole('button', { name: 'Chat', exact: true }).click(); await page.locator('textarea[placeholder="Message"]').waitFor({ timeout: 30000 }); } async function back(page) { await page.getByRole('button', { name: 'Back to chats', exact: true }).click(); await page.getByRole('button', { name: 'New chat', exact: true }).waitFor(); } async function reopen(page) { await page.locator('.list button.row').filter({ hasText: 'ux-audit-20261007' }).first().click(); await page.locator('textarea').waitFor(); } async function send(page, body) { await page.locator('.composer textarea').fill(body); await page.getByRole('button', { name: 'Send', exact: true }).click(); await until(async () => await page.locator('.composer textarea').inputValue() === ''); } async function dimensions(page) { return page.evaluate(() => ({ viewport: { width: innerWidth, height: innerHeight }, documentWidth: document.documentElement.scrollWidth, controls: [...document.querySelectorAll('button,input,textarea')].filter(e => e.getBoundingClientRect().width).map(e => { const r = e.getBoundingClientRect(); return { name: e.getAttribute('aria-label') || e.getAttribute('placeholder') || e.textContent.trim().slice(0, 60), width: r.width, height: r.height, right: r.right }; }) })); } async function main() { const options = { headless: true, timeout: 30000 }; if (engine === 'chromium') options.executablePath = process.env.UX_CHROMIUM_PATH || '/nix/store/33pxss8h71cl7vmfpy21bidsw0lj1g8q-chromium-152.0.7977.82/bin/chromium'; if (engine === 'webkit' && process.env.UX_WEBKIT_PATH) options.executablePath = process.env.UX_WEBKIT_PATH; browser = await pw[engine].launch(options); report.browserVersion = browser.version(); report.resourceBefore = memory(); timer = setInterval(() => report.resources.push({ time: new Date().toISOString(), ...memory() }), 2000); a = await login(accounts[0]); b = await login(accounts[1]); await check('LOGIN', async () => ({ status: 'passed', detail: 'Both accounts authenticated.' })); await check('BUILD', async () => { const html = await (await a.context.request.get(origin)).text(); const asset = html.match(/src="\.\/(assets\/[^\"]+\.js)"/)?.[1]; const bytes = await (await a.context.request.get(origin + '/' + asset)).body(); report.bundle = { asset, sha256: crypto.createHash('sha256').update(bytes).digest('hex') }; return { status: 'measured', ...report.bundle }; }); if (process.argv.includes('--reconnect-only')) { await openChat(a.page, room); await reconnect(); return; } await openChat(a.page, room); await openChat(b.page, room); await until(async () => /2 participants/.test(await a.page.locator('.chat header .sub').innerText())); await check('G01', async () => { const before = await a.page.locator('body').innerText(); await a.page.locator('.chat header .title').tap(); await capture(a.page, 'group-header'); return { status: before === await a.page.locator('body').innerText() ? 'confirmed-gap' : 'needs-review', detail: 'Tap group title/count.' }; }); await check('G02', async () => { const before = await a.page.locator('.chat header .sub').innerText(); await b.page.getByRole('button', { name: 'More actions' }).click(); b.page.once('dialog', dialog => dialog.accept()); await b.page.getByRole('menuitem', { name: 'Leave room', exact: true }).click(); await b.page.getByRole('button', { name: 'New chat', exact: true }).waitFor(); await delay(1500); const afterLeave = await a.page.locator('.chat header .sub').innerText(); await capture(a.page, 'group-offline-member'); await openChat(b.page, room); await until(async () => /2 participants/.test(await a.page.locator('.chat header .sub').innerText())); return { status: 'observed', before, afterLeave, afterRejoin: await a.page.locator('.chat header .sub').innerText(), detail: 'Current occupant count; hibernated earlier test sessions may keep the nickname present. Full-member semantics require protocol verification.' }; }); const body = `Synthetic audit ${runId}`; await send(b.page, body); await a.page.getByText(body, { exact: true }).waitFor(); await check('GROUP-SEND', async () => ({ status: 'passed' })); await check('M02', async () => { const bubble = a.page.locator('.bubble').filter({ hasText: body }); await bubble.tap(); await bubble.click({ button: 'right' }); await capture(a.page, 'message-actions'); return { status: 'observed', detail: 'Tap/context-menu gesture; native iOS long press untested.', visibleButtons: await a.page.locator('.chat button').allTextContents() }; }); await check('G03', async () => { const before = await a.page.locator('body').innerText(); await a.page.locator('.bubble').filter({ hasText: body }).locator('.nick').tap(); return { status: before === await a.page.locator('body').innerText() ? 'confirmed-gap' : 'needs-review', detail: 'Sender nickname exposes no person/contact action.' }; }); await check('N02', async () => { const draft = 'Synthetic navigation draft'; await a.page.locator('textarea').fill(draft); await back(a.page); await reopen(a.page); return { status: await a.page.locator('textarea').inputValue() === draft ? 'passed' : 'confirmed-gap', detail: 'Draft after returning to list and reopening.' }; }); await reconnect(); await a.context.setOffline(false); await check('N01', async () => { for (let i = 0; i < 22; i++) await send(b.page, `History ${runId} ${i}: synthetic multiline\nsecond line\nthird line`); await a.page.getByText(`History ${runId} 21: synthetic multiline\nsecond line\nthird line`, { exact: true }).waitFor(); await a.page.locator('.messages').evaluate(e => { e.scrollTop = 200; }); const before = await a.page.locator('.messages').evaluate(e => ({ top: e.scrollTop, remaining: e.scrollHeight - e.clientHeight - e.scrollTop })); await back(a.page); await reopen(a.page); await until(async () => await a.page.locator('.messages .bubble').count() > 0); await delay(250); const after = await a.page.locator('.messages').evaluate(e => ({ top: e.scrollTop, remaining: e.scrollHeight - e.clientHeight - e.scrollTop })); return { status: before.remaining > 300 && after.remaining < 120 ? 'confirmed-gap' : 'needs-review', before, after }; }); await check('A01-LAYOUT', async () => { const layouts = []; for (const v of [{ width: 320, height: 700 }, report.viewport, { width: 874, height: 402 }, { width: 1280, height: 800 }]) { await a.page.setViewportSize(v); layouts.push(await dimensions(a.page)); await capture(a.page, `group-${v.width}x${v.height}`); } await a.page.setViewportSize(report.viewport); return { status: 'measured', layouts }; }); await check('M03-UPLOAD', async () => { const name = `audit-${runId}.txt`; await a.page.locator('input[type=file]').setInputFiles({ name, mimeType: 'text/plain', buffer: Buffer.from('Synthetic UX attachment. No private data.\n') }); await b.page.getByRole('link', { name: new RegExp(name) }).waitFor({ timeout: 25000 }); await capture(b.page, 'received-attachment'); return { status: 'passed', detail: 'Synthetic text file uploaded and rendered on B.' }; }); await check('G04-LEAVE', async () => { await b.page.getByRole('button', { name: 'More actions' }).click(); b.page.once('dialog', d => d.accept()); await b.page.getByRole('menuitem', { name: 'Leave room', exact: true }).click(); await b.page.getByRole('button', { name: 'New chat', exact: true }).waitFor(); await openChat(b.page, room); return { status: 'passed', detail: 'Left dedicated room and rejoined.' }; }); await check('A02-MENU', async () => { await a.page.getByRole('button', { name: 'More actions' }).click(); const focus = await a.page.evaluate(() => document.activeElement?.getAttribute('aria-label')); await a.page.keyboard.press('Escape'); const remains = await a.page.getByRole('menu').isVisible(); if (remains) await a.page.getByRole('button', { name: 'More actions' }).click(); return { status: remains ? 'confirmed-gap' : 'passed', focusOnOpen: focus, escapeClosed: !remains }; }); await back(a.page); await a.page.getByRole('button', { name: 'Settings', exact: true }).click(); await check('SETTINGS-LAYOUT', async () => { await a.page.setViewportSize({ width: 320, height: 700 }); const narrow = await dimensions(a.page); await capture(a.page, 'settings-narrow'); await a.page.setViewportSize(report.viewport); await a.page.getByRole('radio', { name: 'Dark', exact: true }).click(); await capture(a.page, 'settings-dark'); const dark = await a.page.evaluate(() => ({ theme: document.documentElement.dataset.theme, background: getComputedStyle(document.body).backgroundColor })); await a.page.getByRole('radio', { name: 'System', exact: true }).click(); return { status: 'measured', narrow, dark }; }); await back(a.page); await openChat(a.page, accounts[1].jid); await openChat(b.page, accounts[0].jid); await check('M01-DIRECT', async () => { const text = `Direct ${runId}`; await send(a.page, text); await b.page.getByText(text, { exact: true }).waitFor({ timeout: 25000 }); await capture(a.page, 'direct-message'); return { status: 'passed', lockVisible: await a.page.getByRole('button', { name: 'End-to-end encrypted', exact: true }).isVisible() }; }); await check('A02-DIALOG', async () => { const lock = a.page.getByRole('button', { name: 'End-to-end encrypted', exact: true }); if (!await lock.isVisible()) return { status: 'not-tested', detail: 'Fingerprint entry unavailable.' }; await lock.click(); await a.page.getByRole('dialog').waitFor(); const inside = await a.page.getByRole('dialog').evaluate(e => e.contains(document.activeElement)); await a.page.keyboard.press('Escape'); const closed = !await a.page.getByRole('dialog').isVisible(); if (!closed) { await a.page.getByRole('button', { name: 'Close', exact: true }).focus(); for (let i = 0; i < 8; i++) await a.page.keyboard.press('Tab'); } const escaped = !closed && await a.page.evaluate(() => !document.querySelector('[role=dialog]')?.contains(document.activeElement)); await capture(a.page, 'fingerprint-dialog'); if (!closed) await a.page.getByRole('button', { name: 'Close', exact: true }).click(); return { status: !inside || !closed || escaped ? 'confirmed-gap' : 'passed', focusInsideOnOpen: inside, escapeClosed: closed, focusEscapedByTab: escaped }; }); } async function reconnect() { await check('R01', async () => { const draft = 'Synthetic offline draft'; await a.page.locator('textarea').fill(draft); try { a.transport.enabled = false; await a.context.setOffline(true); for (const ws of a.transport.sockets) await ws.close({ code: 1011, reason: 'Synthetic UX interruption' }); await until(async () => /Offline|reconnect/i.test(await a.page.locator('textarea').getAttribute('placeholder') || ''), 12000); const offlineDraft = await a.page.locator('textarea').inputValue(); const disabled = await a.page.getByRole('button', { name: 'Send', exact: true }).isDisabled(); await capture(a.page, 'offline-draft'); a.transport.enabled = true; await a.context.setOffline(false); await a.page.locator('textarea[placeholder="Message"]').waitFor({ timeout: 30000 }); const restored = await a.page.locator('textarea').inputValue(); await a.page.locator('textarea').fill(''); return { status: offlineDraft === draft && restored === draft && disabled ? 'passed' : 'needs-review', offlineDraftPreserved: offlineDraft === draft, reconnectDraftPreserved: restored === draft, sendDisabled: disabled, detail: 'Injected WebSocket close plus HTTP offline emulation; real radio/background recovery still requires phone test.' }; } finally { a.transport.enabled = true; await a.context.setOffline(false); } }); } main().catch(error => { report.fatal = sanitize(error.message); process.exitCode = 1; }).finally(async () => { clearInterval(timer); if (browser) await browser.close(); report.finished = new Date().toISOString(); report.resourceAfter = memory(); fs.writeFileSync(path.join(out, 'report.json'), JSON.stringify(report, null, 2), { mode: 0o600 }); console.log(JSON.stringify({ engine, checks: report.checks.map(({ id, status, detail }) => ({ id, status, detail })), fatal: report.fatal, errors: report.errors, minAvailableMiB: Math.min(report.resourceBefore?.availableMiB || Infinity, ...report.resources.map(r => r.availableMiB)), report: path.join(out, 'report.json') }, null, 2)); });