monal-rebuild/ux-pwa-direct-runner.cjs

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)) }));
});