160 lines
16 KiB
JavaScript
160 lines
16 KiB
JavaScript
// 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));
|
|
});
|