453 lines
37 KiB
JavaScript
453 lines
37 KiB
JavaScript
// Live exploratory checks, confined to the existing synthetic audit accounts.
|
|
// Standalone and notification clicks are injected states, not iOS certification.
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const os = require('node:os');
|
|
const crypto = require('node:crypto');
|
|
const pw = require(process.env.UX_PLAYWRIGHT_MODULE || '/tmp/chat-ux-runner/node_modules/playwright');
|
|
const accounts = JSON.parse(fs.readFileSync(process.env.UX_ACCOUNTS_FILE || '/tmp/chat-ux-runner/accounts.json', 'utf8'));
|
|
const engine = process.argv[2] || 'chromium';
|
|
if (!['chromium', 'webkit'].includes(engine)) throw Error('Use chromium or webkit');
|
|
const out = process.env.UX_RESULTS_DIR || `/tmp/chat-ux-runner/results/direct-pwa-${engine}-${Date.now()}`;
|
|
fs.mkdirSync(out, { recursive: true, mode: 0o700 });
|
|
const candidate = process.argv.includes('--candidate');
|
|
const profileRoot = process.env.UX_PROFILE_DIR || '/tmp/chat-ux-runner/candidate-profiles/' + engine;
|
|
const profile = candidate ? path.join(profileRoot, 'a') : path.join(out, 'private-profile');
|
|
const origin = process.env.UX_BASE_URL || 'https://chat.0ff.space';
|
|
const stamp = path.basename(out);
|
|
const viewport = { width: 402, height: 874 };
|
|
const report = { engine, started: new Date().toISOString(), simulation: 'Injected standalone media/navigator state; real browser storage, network, worker and UI. Not an installed iOS app.', checks: [], errors: [], resources: [] };
|
|
report.origin = origin;
|
|
report.viewport = viewport;
|
|
report.testSetup = { persistentProfiles: candidate, deliberatelyAddedBDevice: process.argv.includes('--new-device'), dedicatedAccounts: accounts.map(a => a.jid), workers: 1, profileRoot: candidate ? profileRoot : profile };
|
|
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 context, browser, page, otherContext, other, timer;
|
|
let faultDevice;
|
|
const wire = { slots: 0, puts: 0, displayed: [] };
|
|
let faultId;
|
|
let holdSlot = false, heldSlot;
|
|
let addedDeviceId;
|
|
const delay = ms => new Promise(r => setTimeout(r, ms));
|
|
function save() { fs.writeFileSync(path.join(out, 'report.json'), JSON.stringify(report, null, 2), { mode: 0o600 }); }
|
|
function clean(error) { let s = String(error); for (const a of accounts) s = s.replaceAll(a.password, '[redacted]'); return s; }
|
|
async function until(fn, timeout = 15000) { const end = Date.now() + timeout; do { if (await fn()) return; await delay(150); } while (Date.now() < end); throw Error(`Condition timed out (${timeout}ms)`); }
|
|
async function shot(name) { await page.screenshot({ path: path.join(out, `${name}.png`), fullPage: true }); }
|
|
async function check(id, fn) {
|
|
if (process.env.UX_CHECKS && !process.env.UX_CHECKS.split(',').includes(id)) return;
|
|
if (candidate && id === 'D07-READING-POSITION' && !process.env.UX_CHECKS) return;
|
|
if (candidate && !['BUILD', 'D00-NEW-DEVICE-CACHE', 'D02-DELIVERY-READ', 'D03-TYPING', 'D04-MULTILINE-LINK', 'D06-IMAGE-FILE', 'D07-READING-POSITION', 'D08-VOICE', 'D09-FAILED-DISPLAY', 'D10-GROUP-MEDIA', 'D11-WORKER', 'P04-PUSH-CLICK'].includes(id)) return;
|
|
try {
|
|
if (candidate && id !== 'BUILD' && id !== 'D10-GROUP-MEDIA') { await direct(page, accounts[1].jid); await direct(other, accounts[0].jid); }
|
|
const result = await fn();
|
|
if (candidate && id === 'D00-NEW-DEVICE-CACHE') { result.expected = 'New device reads new message without reload or cache deletion'; result.status = result.readable ? 'passed' : 'failed'; }
|
|
report.checks.push({ id, ...result });
|
|
}
|
|
catch (e) { report.checks.push({ id, status: 'execution-error', detail: clean(e.message) }); await shot(`error-${id}`).catch(() => {}); if (other && !other.isClosed()) await other.screenshot({ path: path.join(out, `error-${id}-recipient.png`), fullPage: true }).catch(() => {}); }
|
|
save(); console.log(JSON.stringify(report.checks.at(-1)));
|
|
}
|
|
async function configure(ctx) {
|
|
if (candidate) {
|
|
const role = ctx === context ? 'a' : 'b';
|
|
ctx.on('request', request => { if (request.method() === 'PUT') wire.puts++; });
|
|
await ctx.routeWebSocket('**/xmpp-websocket', ws => {
|
|
const server = ws.connectToServer();
|
|
ws.onMessage(message => {
|
|
let text = String(message);
|
|
if (/<request[^>]+urn:xmpp:http:upload:0/.test(text)) wire.slots++;
|
|
if (/<displayed\b/.test(text)) wire.displayed.push({ role, id: text.match(/<displayed[^>]*id="([^"]+)"/)?.[1] });
|
|
if (role === 'a' && faultDevice && text.includes('<encrypted')) {
|
|
faultId = text.match(/<message[^>]*id="([^"]+)"/)?.[1];
|
|
text = text.replaceAll('rid="' + faultDevice + '"', 'rid="2147483647"');
|
|
faultDevice = undefined;
|
|
}
|
|
server.send(text);
|
|
});
|
|
server.onMessage(message => {
|
|
if (role === 'a' && holdSlot && String(message).includes('<slot')) { heldSlot = { ws, message }; return; }
|
|
ws.send(message);
|
|
});
|
|
});
|
|
await ctx.addInitScript(() => {
|
|
window.__uxMicRequests = 0;
|
|
if (navigator.mediaDevices?.getUserMedia) {
|
|
const get = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices);
|
|
navigator.mediaDevices.getUserMedia = (...args) => { window.__uxMicRequests++; return get(...args); };
|
|
}
|
|
});
|
|
}
|
|
await ctx.addInitScript(() => {
|
|
const original = window.matchMedia.bind(window);
|
|
window.matchMedia = query => {
|
|
const result = original(query);
|
|
if (query === '(display-mode: standalone)') Object.defineProperty(result, 'matches', { value: true });
|
|
return result;
|
|
};
|
|
Object.defineProperty(navigator, 'standalone', { value: true, configurable: true });
|
|
});
|
|
ctx.on('page', p => { p.setDefaultTimeout(12000); p.on('pageerror', e => report.errors.push(clean(e.message))); });
|
|
}
|
|
async function launch() {
|
|
const options = { headless: true, timeout: 30000, viewport, isMobile: true, hasTouch: true, deviceScaleFactor: 2 };
|
|
if (engine === 'chromium') { options.executablePath = process.env.UX_CHROMIUM_PATH || '/nix/store/33pxss8h71cl7vmfpy21bidsw0lj1g8q-chromium-152.0.7977.82/bin/chromium'; options.args = ['--use-fake-device-for-media-stream']; }
|
|
else { options.executablePath = process.env.UX_WEBKIT_PATH; options.userAgent = pw.devices['iPhone 13'].userAgent; }
|
|
context = await pw[engine].launchPersistentContext(profile, options);
|
|
browser = context.browser(); report.browserVersion = browser.version();
|
|
await configure(context);
|
|
page = context.pages()[0] || await context.newPage(); page.setDefaultTimeout(12000);
|
|
page.on('pageerror', e => report.errors.push(clean(e.message)));
|
|
}
|
|
async function login(p, account, remember) {
|
|
await p.goto(origin);
|
|
if (candidate) {
|
|
await Promise.race([p.locator('[name=username]').waitFor(), p.getByRole('button', { name: 'New chat', exact: true }).waitFor({ timeout: 30000 })]);
|
|
if (!await p.locator('[name=username]').isVisible()) return;
|
|
}
|
|
await p.locator('[name=username]').fill(account.jid);
|
|
await p.locator('[name=password]').fill(account.password);
|
|
if (remember) await p.getByRole('checkbox', { name: 'Stay logged in' }).check();
|
|
await p.getByRole('button', { name: 'Log in', exact: true }).click();
|
|
await p.getByRole('button', { name: 'New chat', exact: true }).waitFor({ timeout: 30000 });
|
|
}
|
|
async function direct(p, jid) { await p.evaluate(hash => { location.hash = hash; }, '#/chat/' + encodeURIComponent(jid)); await p.locator('textarea[placeholder="Message"]').waitFor({ timeout: 30000 }); }
|
|
async function list(p) { await p.evaluate(() => { location.hash = ''; }); await p.getByRole('button', { name: 'New chat', exact: true }).waitFor(); }
|
|
async function send(p, text) { await p.locator('.composer textarea').fill(text); await p.getByRole('button', { name: 'Send', exact: true }).click(); await until(async () => await p.locator('.composer textarea').inputValue() === ''); }
|
|
function bubble(p, text) { return p.locator('.bubble').filter({ has: p.getByText(text, { exact: true }) }); }
|
|
async function restart() { await otherContext?.close(); otherContext = undefined; await context.close(); await launch(); }
|
|
async function deviceInfo(p, me, peer) {
|
|
return p.evaluate(async ({ me, peer }) => {
|
|
const request = r => new Promise((resolve, reject) => { r.onsuccess = () => resolve(r.result); r.onerror = () => reject(r.error); });
|
|
const db = await request(indexedDB.open('omemo:' + me));
|
|
try {
|
|
const tx = db.transaction(['identity', 'devices']);
|
|
const [identity, cached] = await Promise.all([request(tx.objectStore('identity').get('identity')), request(tx.objectStore('devices').get(peer))]);
|
|
return { ownDeviceId: identity?.deviceId, published: !!identity?.bundlePublished, peerDevices: cached?.deviceIds || [] };
|
|
} finally { db.close(); }
|
|
}, { me, peer });
|
|
}
|
|
async function main() {
|
|
timer = setInterval(() => {
|
|
const mem = fs.readFileSync('/proc/meminfo', 'utf8');
|
|
report.resources.push({ availableMiB: Math.round(Number(mem.match(/^MemAvailable:\s+(\d+)/m)[1]) / 1024), load: os.loadavg()[0] });
|
|
}, 2000);
|
|
await launch(); await login(page, accounts[0], true);
|
|
await check('BUILD', async () => {
|
|
const response = await context.request.get(origin);
|
|
const html = await response.text();
|
|
const asset = html.match(/src="\.\/(assets\/[^\"]+\.js)"/)?.[1];
|
|
const bytes = await (await context.request.get(origin + '/' + asset)).body();
|
|
const loadedAsset = await page.evaluate(() => Array.from(document.scripts).map(s => s.src).find(src => src.includes('/assets/')));
|
|
return { status: candidate ? loadedAsset?.endsWith('/' + asset) ? 'passed' : 'failed' : 'measured', asset, loadedAsset, sha256: crypto.createHash('sha256').update(bytes).digest('hex'), csp: response.headers()['content-security-policy'], expected: 'Page executes the asset served by this origin' };
|
|
});
|
|
// Cache the existing contact's devices, then add a new B browser device.
|
|
await direct(page, accounts[1].jid);
|
|
await until(async () => (await deviceInfo(page, accounts[0].jid, accounts[1].jid)).peerDevices.length > 0);
|
|
otherContext = candidate ? await pw[engine].launchPersistentContext(path.join(profileRoot, process.argv.includes('--new-device') ? 'b-added-' + Date.now() : 'b'), { headless: true, executablePath: engine === 'chromium' ? (process.env.UX_CHROMIUM_PATH || '/nix/store/33pxss8h71cl7vmfpy21bidsw0lj1g8q-chromium-152.0.7977.82/bin/chromium') : process.env.UX_WEBKIT_PATH, viewport, isMobile: true, hasTouch: true }) : await browser.newContext({ viewport, isMobile: true, hasTouch: true, ...(engine === 'webkit' ? { userAgent: pw.devices['iPhone 13'].userAgent } : {}) });
|
|
await configure(otherContext); other = await otherContext.newPage(); await login(other, accounts[1], candidate);
|
|
await until(async () => (await deviceInfo(other, accounts[1].jid, accounts[0].jid)).published);
|
|
if (candidate && process.argv.includes('--new-device')) addedDeviceId = (await deviceInfo(other, accounts[1].jid, accounts[0].jid)).ownDeviceId;
|
|
await direct(page, accounts[1].jid); await direct(other, accounts[0].jid);
|
|
await check('D00-NEW-DEVICE-CACHE', async () => {
|
|
const aDevices = await deviceInfo(page, accounts[0].jid, accounts[1].jid);
|
|
const bDevices = await deviceInfo(other, accounts[1].jid, accounts[0].jid);
|
|
const text = `New device probe ${stamp}`; await send(page, text); await delay(1600);
|
|
const cachedNewDevice = aDevices.peerDevices.includes(bDevices.ownDeviceId);
|
|
const readable = await other.getByText(text, { exact: true }).isVisible();
|
|
await other.screenshot({ path: path.join(out, 'new-device-recipient.png'), fullPage: true });
|
|
return { status: !cachedNewDevice && !readable ? 'confirmed-gap' : 'needs-review', cachedNewDevice, readable, aDevices, bDevices, recipientPlaceholders: (await other.locator('.bubble.undecryptable').allTextContents()).slice(-3), senderTick: await bubble(page, text).locator('.ticks').getAttribute('title') };
|
|
});
|
|
await check('TEST-CACHE-REFRESH', async () => {
|
|
// Isolate later checks from the reproduced stale-device-list gap. Only
|
|
// this private test profile is changed; this is not an application fix.
|
|
await page.evaluate(async ({ me, peer }) => {
|
|
const db = await new Promise((resolve, reject) => { const r = indexedDB.open('omemo:' + me); r.onsuccess = () => resolve(r.result); r.onerror = () => reject(r.error); });
|
|
try { await new Promise((resolve, reject) => { const tx = db.transaction('devices', 'readwrite'); tx.objectStore('devices').delete(peer); tx.oncomplete = resolve; tx.onerror = () => reject(tx.error); }); }
|
|
finally { db.close(); }
|
|
}, { me: accounts[0].jid, peer: accounts[1].jid });
|
|
await page.reload(); await page.locator('textarea[placeholder="Message"]').waitFor({ timeout: 30000 });
|
|
const current = await deviceInfo(other, accounts[1].jid, accounts[0].jid);
|
|
await until(async () => (await deviceInfo(page, accounts[0].jid, accounts[1].jid)).peerDevices.includes(current.ownDeviceId));
|
|
return { status: 'test-intervention', detail: 'Removed only the peer device-list cache in the private A profile and reloaded. Subsequent healthy-path checks depend on this manual refresh.' };
|
|
});
|
|
await check('D01-CONTACT', async () => {
|
|
const already = !await page.getByRole('button', { name: 'Add contact', exact: true }).isVisible();
|
|
if (!already) {
|
|
await page.getByRole('button', { name: 'Add contact', exact: true }).click();
|
|
await list(other);
|
|
await other.locator('.request').filter({ hasText: accounts[0].jid }).getByRole('button', { name: 'Accept', exact: true }).click();
|
|
await direct(other, accounts[0].jid);
|
|
}
|
|
await until(async () => await page.getByRole('button', { name: 'Call', exact: true }).isVisible());
|
|
await shot('direct-contact');
|
|
return { status: 'passed', detail: already ? 'Existing test contact restored from server roster.' : 'Contact request accepted by B; call controls appeared.', callEnabled: await page.getByRole('button', { name: 'Call', exact: true }).isEnabled() };
|
|
});
|
|
await check('D02-DELIVERY-READ', async () => {
|
|
await list(other); const text = `Receipt ${stamp}`; await send(page, text);
|
|
await until(async () => /delivered|read|Delivered|Displayed/.test(await bubble(page, text).locator('.ticks').getAttribute('title')));
|
|
const before = await bubble(page, text).locator('.ticks').getAttribute('title');
|
|
const unread = await other.locator('.list button.row').filter({ has: other.getByText(accounts[0].id, { exact: true }) }).innerText();
|
|
await delay(400); const unreadTitle = await other.title();
|
|
await direct(other, accounts[0].jid); await other.getByText(text, { exact: true }).waitFor();
|
|
await until(async () => await bubble(page, text).locator('.ticks').getAttribute('title') === (candidate ? 'Displayed' : 'read'));
|
|
await delay(400);
|
|
return { status: 'passed', beforeOpening: before, afterOpening: candidate ? 'Displayed' : 'read', unreadRow: unread, unreadTitle, titleAfterRead: await other.title(), lockVisible: await page.getByRole('button', { name: 'End-to-end encrypted', exact: true }).isVisible() };
|
|
});
|
|
await check('D03-TYPING', async () => {
|
|
await other.locator('textarea').fill('Synthetic typing');
|
|
await until(async () => /typing/.test(await page.locator('.chat .sub').innerText()));
|
|
const seen = await page.locator('.chat .sub').innerText();
|
|
await send(other, `Typing end ${stamp}`);
|
|
await until(async () => !/typing/.test(await page.locator('.chat .sub').innerText()));
|
|
return { status: 'passed', seen, after: await page.locator('.chat .sub').innerText() };
|
|
});
|
|
await check('D04-MULTILINE-LINK', async () => {
|
|
const text = `Unicode äöü 👨👩👧👦 ${stamp}\nSecond line https://example.org/a?b=1&c=2`;
|
|
await page.locator('textarea').fill(text); await page.locator('textarea').press('Enter');
|
|
const touchEnterNewline = (await page.locator('textarea').inputValue()).endsWith('\n');
|
|
await send(page, text); await other.getByText(text, { exact: true }).waitFor();
|
|
const link = bubble(other, text).getByRole('link');
|
|
return { status: 'passed', touchEnterNewline, link: { href: await link.getAttribute('href'), rel: await link.getAttribute('rel'), target: await link.getAttribute('target') } };
|
|
});
|
|
await check('D05-DRAFT-NAVIGATION', async () => {
|
|
const draft = 'Direct draft\nSecond line'; const preserved = {};
|
|
for (const destination of ['list', 'settings']) {
|
|
await page.locator('textarea').fill(draft);
|
|
await page.evaluate(hash => { location.hash = hash; }, destination === 'settings' ? '#/settings' : ''); await delay(250);
|
|
await direct(page, accounts[1].jid); preserved[destination] = await page.locator('textarea').inputValue() === draft;
|
|
}
|
|
return { status: Object.values(preserved).every(Boolean) ? 'passed' : 'confirmed-gap', preserved };
|
|
});
|
|
await check('D06-IMAGE-FILE', async () => {
|
|
if (candidate) {
|
|
await page.locator('.media-reason').filter({hasText:'Encrypted files'}).waitFor();
|
|
const before = {slots:wire.slots, puts:wire.puts};
|
|
const attachDisabled = await page.getByRole('button',{name:'Attach a file',exact:true}).isDisabled();
|
|
await page.locator('input[type=file]').setInputFiles([{name:'blocked.txt',mimeType:'text/plain',buffer:Buffer.from('private test')}]);
|
|
await page.getByRole('alert').filter({hasText:'Encrypted files'}).waitFor();
|
|
for (const type of ['paste','drop']) {
|
|
await page.evaluate(type => {
|
|
const data = new DataTransfer(); data.items.add(new File(['private test'], 'blocked-'+type+'.txt', {type:'text/plain'}));
|
|
const event = type === 'paste' ? new ClipboardEvent('paste',{clipboardData:data,bubbles:true,cancelable:true}) : new DragEvent('drop',{dataTransfer:data,bubbles:true,cancelable:true});
|
|
document.querySelector(type === 'paste' ? '.composer textarea' : '.chat').dispatchEvent(event);
|
|
},type);
|
|
await delay(400);
|
|
}
|
|
return {status:attachDisabled && wire.slots === before.slots && wire.puts === before.puts ? 'passed':'failed', expected:'Selection, paste and drop fail before any slot/PUT', attachDisabled, slots:wire.slots-before.slots, puts:wire.puts-before.puts};
|
|
}
|
|
const name = `pixel-${stamp}.png`;
|
|
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aX1sAAAAASUVORK5CYII=', 'base64');
|
|
await page.locator('input[type=file]').setInputFiles([{ name, mimeType: 'image/png', buffer: png }, { name: `${stamp}.txt`, mimeType: 'text/plain', buffer: Buffer.from('Synthetic direct attachment') }]);
|
|
await until(async () => await page.locator('.upload.failed').count() > 0 || await other.getByRole('img', { name, exact: true }).isVisible(), 30000);
|
|
const failures = await page.locator('.upload.failed').allTextContents();
|
|
if (failures.length) {
|
|
await shot('direct-attachments-blocked');
|
|
const dismiss = page.getByRole('button', { name: 'Dismiss', exact: true }); while (await dismiss.count()) await dismiss.first().click();
|
|
return { status: 'confirmed-gap', failures, detail: 'Attachment selection/upload is offered but sending to the encrypted contact is blocked. Upload may already have taken place.' };
|
|
}
|
|
const image = other.getByRole('img', { name, exact: true }); await image.waitFor({ timeout: 30000 });
|
|
await until(async () => await image.evaluate(e => e.complete && e.naturalWidth === 1));
|
|
await other.getByRole('link', { name: new RegExp(`${stamp}\\.txt`) }).waitFor({ timeout: 30000 });
|
|
await shot('direct-attachments'); return { status: 'passed', imageDecoded: true, textFileReceived: true };
|
|
});
|
|
await check('D07-READING-POSITION', async () => {
|
|
for (let i = 0; i < 12; i++) await send(other, `Direct history ${stamp} ${i}\nSynthetic second line\nThird line`);
|
|
await page.getByText(`Direct history ${stamp} 11\nSynthetic second line\nThird line`, { exact: true }).waitFor();
|
|
await until(async () => await page.locator('.messages').evaluate(e => e.scrollHeight - e.clientHeight > 600));
|
|
await delay(500);
|
|
await page.locator('.messages').evaluate(e => { e.scrollTop = Math.max(100, e.scrollHeight - e.clientHeight - 500); });
|
|
await until(async () => await page.locator('.messages').evaluate(e => e.scrollHeight - e.clientHeight - e.scrollTop > 300));
|
|
const remaining = () => page.locator('.messages').evaluate(e => e.scrollHeight - e.clientHeight - e.scrollTop);
|
|
const before = await remaining(); await list(page); await direct(page, accounts[1].jid); await delay(350); const after = await remaining();
|
|
return { status: candidate ? 'measured' : before > 300 && after < 100 ? 'confirmed-gap' : 'needs-review', remainingBefore: before, remainingAfter: after, detail: 'Scroll setup must exceed 300 px before navigation; position preservation is package 4.' };
|
|
});
|
|
if (candidate || engine === 'chromium') await check('D08-VOICE', async () => {
|
|
if (candidate) {
|
|
await page.locator('textarea').fill('');
|
|
const record = page.getByRole('button',{name:'Record a voice message',exact:true});
|
|
if (!await record.count()) return {status:'not-tested',detail:'Recording API unavailable in this engine'};
|
|
const disabled = await record.isDisabled();
|
|
await record.dispatchEvent('click'); await delay(300);
|
|
const microphoneRequests = await page.evaluate(() => window.__uxMicRequests);
|
|
return {status:disabled && microphoneRequests === 0 ? 'passed':'failed', expected:'Unsupported recording is unavailable without microphone permission', disabled,microphoneRequests};
|
|
}
|
|
await page.locator('textarea').fill('');
|
|
await context.grantPermissions(['microphone']);
|
|
const record = page.getByRole('button', { name: 'Record a voice message', exact: true });
|
|
if (!await record.isVisible()) return { status: 'not-tested', detail: 'Recording control unavailable in this engine.' };
|
|
await record.click(); await page.getByRole('button', { name: 'Discard recording' }).waitFor();
|
|
await page.getByRole('button', { name: 'Discard recording' }).click();
|
|
await record.waitFor();
|
|
await record.click(); await delay(1400); await page.getByRole('button', { name: 'Send voice message' }).click();
|
|
await page.locator('.upload.failed').waitFor({ timeout: 20000 });
|
|
const error = await page.locator('.upload.failed').innerText();
|
|
await shot('voice-blocked'); await page.getByRole('button', { name: 'Dismiss', exact: true }).click();
|
|
return { status: 'confirmed-gap', recordingAndDiscardWorked: true, sendingError: error, simulation: 'Chromium synthetic microphone and browser permission grant; no real microphone capture.' };
|
|
});
|
|
await check('D09-FAILED-DISPLAY', async () => {
|
|
await direct(other, accounts[0].jid);
|
|
await other.bringToFront();
|
|
faultDevice = (await deviceInfo(other, accounts[1].jid, accounts[0].jid)).ownDeviceId;
|
|
const text = 'Intentionally unreadable ' + stamp;
|
|
const start = wire.displayed.length;
|
|
await send(page, text);
|
|
await until(async () => await other.locator('.row[data-message-key$="|' + faultId + '"] .bubble.undecryptable').count() > 0);
|
|
await delay(700);
|
|
const markedFailure = wire.displayed.slice(start).some(m => m.role === 'b' && m.id === faultId);
|
|
const before = await bubble(page, text).locator('.ticks').getAttribute('title');
|
|
const unread = await other.evaluate(async jid => {
|
|
const db = await new Promise(r => { const req = indexedDB.open('chat:' + jid); req.onsuccess = () => r(req.result); });
|
|
try { return await new Promise(r => { const req = db.transaction('conversations').objectStore('conversations').get(location.hash.slice(7) ? decodeURIComponent(location.hash.slice(7)) : ''); req.onsuccess = () => r(req.result?.unread); }); } finally { db.close(); }
|
|
}, accounts[1].jid);
|
|
const later = 'Readable after gap ' + stamp;
|
|
await send(page, later); await other.getByText(later,{exact:true}).waitFor();
|
|
await until(async () => await bubble(page,later).locator('.ticks').getAttribute('title') === 'Displayed');
|
|
const retained = await other.locator('.bubble.undecryptable').filter({hasText:'Could not decrypt this message.'}).count() > 0;
|
|
return {status: !markedFailure && before !== 'Displayed' && retained && unread === 0 ? 'passed' : 'failed', expected:'No failure marker, local badge cleared, later readable cumulative display retains the error', markedFailure, before, unread, retained, later: 'Displayed'};
|
|
});
|
|
await check('D10-GROUP-MEDIA', async () => {
|
|
const room = 'ux-audit-20261007@muc.0ff.space';
|
|
for (const p of [page,other]) {
|
|
await direct(p,room);
|
|
const join = p.getByRole('button',{name:'Join',exact:true});
|
|
if (await join.isVisible()) await join.click();
|
|
await until(async () => await p.getByRole('button',{name:'Attach a file',exact:true}).isEnabled(),30000);
|
|
}
|
|
const before = {slots:wire.slots,puts:wire.puts};
|
|
const name = 'group-'+stamp+'.txt';
|
|
await page.locator('input[type=file]').setInputFiles({name,mimeType:'text/plain',buffer:Buffer.from('supported group file')});
|
|
await other.getByRole('link',{name:new RegExp(name)}).waitFor({timeout:30000});
|
|
const pngName = 'group-'+stamp+'.png';
|
|
await page.locator('input[type=file]').setInputFiles({name:pngName,mimeType:'image/png',buffer:Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aX1sAAAAASUVORK5CYII=','base64')});
|
|
const image = other.getByRole('img',{name:pngName,exact:true});
|
|
await image.waitFor({timeout:30000});
|
|
await until(async () => await image.evaluate(img => img.complete && img.naturalWidth === 1));
|
|
const beforeCancelPut = wire.puts;
|
|
holdSlot = true; heldSlot = undefined;
|
|
await page.locator('input[type=file]').setInputFiles({name:'cancel-'+stamp+'.txt',mimeType:'text/plain',buffer:Buffer.alloc(4096)});
|
|
await until(async () => !!heldSlot);
|
|
await page.getByRole('button',{name:'Cancel upload',exact:true}).click();
|
|
holdSlot = false; heldSlot.ws.send(heldSlot.message); heldSlot = undefined;
|
|
await delay(500);
|
|
const cancelled = await page.getByRole('button',{name:'Cancel upload',exact:true}).count()===0;
|
|
return {status:cancelled && wire.slots-before.slots >= 2 && wire.puts-before.puts >= 2 && wire.puts === beforeCancelPut ? 'passed':'failed',expected:'Group image/file upload remains supported and cancellation while awaiting a slot prevents PUT', slots:wire.slots-before.slots, puts:wire.puts-before.puts, cancelled};
|
|
});
|
|
await check('D11-WORKER', async () => {
|
|
await until(async () => await page.evaluate(() => !!navigator.serviceWorker.controller));
|
|
const script = await page.evaluate(async () => (await navigator.serviceWorker.ready).active?.scriptURL);
|
|
return {status:script === origin + '/sw.js' ? 'passed':'failed', expected:'Production-built shell is controlled by the real same-origin worker', script};
|
|
});
|
|
if (candidate && !process.env.UX_CHECKS?.split(',').includes('P04-PUSH-CLICK')) return;
|
|
await check('P01-STANDALONE-SHELL', async () => {
|
|
const state = await page.evaluate(async () => {
|
|
const registration = await navigator.serviceWorker.ready;
|
|
const cache = await caches.open('shell-v1');
|
|
const manifest = await (await fetch('manifest.webmanifest')).json();
|
|
return { injectedStandalone: navigator.standalone && matchMedia('(display-mode: standalone)').matches, manifestDisplay: manifest.display, controlled: !!navigator.serviceWorker.controller, activeWorker: registration.active?.scriptURL, cachedURLs: (await cache.keys()).map(x => x.url) };
|
|
});
|
|
await shot('standalone-shell'); return { status: state.controlled && state.cachedURLs.some(u => u.includes('/assets/')) ? 'passed' : 'needs-review', ...state };
|
|
});
|
|
await check('P02-KEYBOARD-VIEWPORT', async () => {
|
|
await page.locator('textarea').fill('Synthetic keyboard draft'); await page.locator('textarea').focus();
|
|
const layouts = [];
|
|
try {
|
|
for (const v of [{ width: 402, height: 500 }, { width: 874, height: 300 }, viewport]) {
|
|
await page.setViewportSize(v); await delay(200);
|
|
layouts.push(await page.evaluate(() => { const r = document.querySelector('.composer').getBoundingClientRect(); return { width: innerWidth, height: innerHeight, documentWidth: document.documentElement.scrollWidth, composerBottom: r.bottom, composerTop: r.top, visible: r.bottom <= innerHeight && r.top >= 0 }; }));
|
|
await shot(`keyboard-resize-${v.width}x${v.height}`);
|
|
}
|
|
} finally { await page.setViewportSize(viewport); await page.locator('textarea').fill(''); }
|
|
return { status: layouts.every(x => x.visible && x.documentWidth <= x.width) ? 'passed' : 'confirmed-gap', simulation: 'Layout viewport shrink, not an iOS keyboard/visualViewport overlay', layouts };
|
|
});
|
|
if (engine === 'chromium') {
|
|
await check('P03-FREEZE-RESUME', async () => {
|
|
const cdp = await context.newCDPSession(page); const text = `Frozen delivery ${stamp}`; const draft = 'Frozen unsent draft';
|
|
await page.locator('textarea').fill(draft);
|
|
try { await cdp.send('Page.setWebLifecycleState', { state: 'frozen' }); await send(other, text); await delay(800); }
|
|
finally { await cdp.send('Page.setWebLifecycleState', { state: 'active' }); await cdp.detach(); }
|
|
await page.getByText(text, { exact: true }).waitFor({ timeout: 30000 });
|
|
const draftPreserved = await page.locator('textarea').inputValue() === draft; await page.locator('textarea').fill('');
|
|
return { status: draftPreserved ? 'passed' : 'confirmed-gap', draftPreserved, receivedCopies: await page.getByText(text, { exact: true }).count(), simulation: 'Brief Chromium lifecycle freeze, not iOS suspension/eviction' };
|
|
});
|
|
await check('P04-PUSH-CLICK', async () => {
|
|
await context.grantPermissions(['notifications']);
|
|
const cdp = await context.newCDPSession(page); const registrations = new Map();
|
|
cdp.on('ServiceWorker.workerRegistrationUpdated', e => { for (const r of e.registrations) registrations.set(r.registrationId, r); });
|
|
await cdp.send('ServiceWorker.enable'); await until(async () => registrations.size > 0);
|
|
const registration = [...registrations.values()].find(x => x.scopeURL === origin + '/');
|
|
await list(page);
|
|
await cdp.send('ServiceWorker.deliverPushMessage', { origin, registrationId: registration.registrationId, data: `<message from='${accounts[1].jid}/synthetic' type='chat'><body>Synthetic push ${stamp}</body></message>` });
|
|
await until(async () => await page.evaluate(async () => (await (await navigator.serviceWorker.ready).getNotifications()).some(n => n.body.startsWith('Synthetic push '))));
|
|
const notifications = await page.evaluate(async () => (await (await navigator.serviceWorker.ready).getNotifications()).map(n => ({ title: n.title, body: n.body, tag: n.tag, hash: n.data?.hash })));
|
|
const worker = context.serviceWorkers().find(w => w.url().endsWith('/sw.js'));
|
|
const clickResult = await worker.evaluate(async () => {
|
|
const notification = (await self.registration.getNotifications()).find(n => n.body.startsWith('Synthetic push '));
|
|
const event = new NotificationEvent('notificationclick', { notification });
|
|
let completion; event.waitUntil = p => { completion = p; };
|
|
self.dispatchEvent(event);
|
|
try { await completion; return { handlerCompleted: true }; } catch (e) { return { handlerCompleted: false, detail: e.message }; }
|
|
});
|
|
await page.locator('textarea[placeholder="Message"]').waitFor();
|
|
const correctChat = await page.evaluate(hash => location.hash === hash, '#/chat/' + encodeURIComponent(accounts[1].jid));
|
|
await cdp.detach(); return { status: correctChat ? (clickResult.handlerCompleted ? 'passed' : 'passed-with-limit') : 'confirmed-gap', notifications, clickResult, correctChat, simulation: 'Push injected using CDP into real worker; untrusted synthetic click uses real handler. No push relay or OS click test.' };
|
|
});
|
|
}
|
|
await check('P05-UPDATE-CHECK', async () => {
|
|
const result = await page.evaluate(async () => { const r = await navigator.serviceWorker.ready; const before = r.active?.scriptURL; await r.update(); return { before, after: r.active?.scriptURL, waiting: !!r.waiting, installing: !!r.installing, controlled: !!navigator.serviceWorker.controller }; });
|
|
return { status: result.controlled ? 'passed' : 'needs-review', ...result, detail: 'Update check against unchanged live worker; a changed production version was not deployed.' };
|
|
});
|
|
const body = `Persistent message ${stamp}`; await send(other, body); await page.getByText(body, { exact: true }).waitFor();
|
|
if (candidate) return;
|
|
const oldDevice = await page.evaluate(() => localStorage.getItem('device-id'));
|
|
const oldOmemoDevice = (await deviceInfo(page, accounts[0].jid, accounts[1].jid)).ownDeviceId;
|
|
await page.locator('textarea').fill('Unsent before restart');
|
|
await check('P06-PERSISTENT-RESTART', async () => {
|
|
await restart(); await page.goto(origin + '/#/chat/' + encodeURIComponent(accounts[1].jid));
|
|
await page.locator('textarea[placeholder="Message"]').waitFor({ timeout: 30000 });
|
|
await page.getByText(body, { exact: true }).waitFor();
|
|
const sameDevice = oldDevice === await page.evaluate(() => localStorage.getItem('device-id'));
|
|
const sameOmemoDevice = oldOmemoDevice === (await deviceInfo(page, accounts[0].jid, accounts[1].jid)).ownDeviceId;
|
|
const draftPreserved = await page.locator('textarea').inputValue() === 'Unsent before restart';
|
|
await shot('persistent-restart'); return { status: sameDevice && sameOmemoDevice ? 'passed-with-gap' : 'confirmed-gap', automaticLogin: true, sameDevice, sameOmemoDevice, storedMessageVisible: true, unsentDraftPreserved: draftPreserved };
|
|
});
|
|
await check('P07-OFFLINE-COLD-START', async () => {
|
|
await restart(); await context.setOffline(true);
|
|
try {
|
|
const response = await page.goto(origin + '/#/chat/' + encodeURIComponent(accounts[1].jid), { waitUntil: 'domcontentloaded', timeout: 15000 });
|
|
await delay(3500); const offline = { httpStatus: response?.status(), text: (await page.locator('body').innerText()).slice(0, 600), localMessagesVisible: await page.getByText(body, { exact: true }).isVisible(), composerVisible: await page.locator('textarea').isVisible() };
|
|
await shot('offline-cold-start'); await context.setOffline(false);
|
|
let automaticRecovery = true;
|
|
try { await page.locator('textarea[placeholder="Message"]').waitFor({ timeout: 10000 }); }
|
|
catch { automaticRecovery = false; }
|
|
const afterConnectivityRestored = (await page.locator('body').innerText()).slice(0, 600);
|
|
let recoveredAfterReload = false;
|
|
if (!automaticRecovery) { await page.reload(); await page.locator('textarea[placeholder="Message"]').waitFor({ timeout: 30000 }); recoveredAfterReload = true; }
|
|
return { status: offline.localMessagesVisible && automaticRecovery ? 'passed' : 'confirmed-gap', offline, automaticRecovery, afterConnectivityRestored, recoveredAfterReload, detail: 'Persistent profile, cached shell and forced browser offline before navigation. Recovery observed for 10 seconds before retrying by reload.' };
|
|
} finally { await context.setOffline(false); }
|
|
});
|
|
}
|
|
main().catch(e => { report.fatal = clean(e.message); process.exitCode = 1; }).finally(async () => {
|
|
if (candidate && (addedDeviceId || process.env.UX_RETIRE_DEVICE_IDS)) {
|
|
try {
|
|
const ids = [...new Set([addedDeviceId, ...(process.env.UX_RETIRE_DEVICE_IDS || '').split(',').filter(Boolean).map(Number)].filter(Boolean))];
|
|
if (addedDeviceId) {
|
|
await otherContext?.close();
|
|
otherContext = await pw[engine].launchPersistentContext(path.join(profileRoot, 'b'), { headless: true, viewport, executablePath: engine === 'chromium' ? (process.env.UX_CHROMIUM_PATH || '/nix/store/33pxss8h71cl7vmfpy21bidsw0lj1g8q-chromium-152.0.7977.82/bin/chromium') : process.env.UX_WEBKIT_PATH });
|
|
await configure(otherContext); other = otherContext.pages()[0] || await otherContext.newPage();
|
|
await login(other, accounts[1], true);
|
|
}
|
|
await other.evaluate(() => { location.hash = '#/settings'; });
|
|
await other.locator('.row.device').first().waitFor();
|
|
other.on('dialog', dialog => dialog.accept());
|
|
for (const id of ids) {
|
|
const row = other.locator('.row.device').filter({ has: other.getByText(String(id), {exact:true}) });
|
|
if (await row.count()) { await row.getByRole('button',{name:'Remove',exact:true}).click(); await row.waitFor({state:'detached'}); }
|
|
}
|
|
if (addedDeviceId) await until(async () => !(await deviceInfo(page,accounts[0].jid,accounts[1].jid)).peerDevices.includes(addedDeviceId));
|
|
report.cleanup = {status:'passed', retiredDeviceIds:ids, currentAandBProfilesRetained:true};
|
|
} catch (e) { report.cleanup = {status:'failed',detail:clean(e.message)}; process.exitCode=1; }
|
|
}
|
|
clearInterval(timer); await otherContext?.close().catch(() => {}); await context?.close().catch(() => {});
|
|
if (candidate && report.checks.some(x => ['failed', 'execution-error', 'confirmed-gap', 'needs-review'].includes(x.status))) process.exitCode = 1;
|
|
report.finished = new Date().toISOString(); save();
|
|
console.log(JSON.stringify({ report: path.join(out, 'report.json'), fatal: report.fatal, errors: report.errors, checks: report.checks.map(({ id, status }) => ({ id, status })), minAvailableMiB: Math.min(...report.resources.map(x => x.availableMiB)) }));
|
|
});
|