// 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 (/]+urn:xmpp:http:upload:0/.test(text)) wire.slots++; if (/]*id="([^"]+)"/)?.[1] }); if (role === 'a' && faultDevice && text.includes(']*id="([^"]+)"/)?.[1]; text = text.replaceAll('rid="' + faultDevice + '"', 'rid="2147483647"'); faultDevice = undefined; } server.send(text); }); server.onMessage(message => { if (role === 'a' && holdSlot && String(message).includes(' { 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: `Synthetic push ${stamp}` }); 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)) })); });