monal-rebuild/ux-state-runner.cjs

480 lines
47 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Candidate checks for packages 4–6. Dedicated audit accounts only; profiles,
// credentials and reports stay outside Git. One engine at a time.
const fs = require('node:fs');
const path = require('node:path');
const assert = require('node:assert/strict');
const crypto = require('node:crypto');
const {client,xml} = require('@xmpp/client');
const engine = process.argv[2] || 'chromium';
if(engine==='webkit') {
process.env.UX_WEBKIT_PATH??='/nix/store/kvbchbp55jd09wh2h1bv3xx9cz26k6vg-playwright-webkit/pw_run.sh';
process.env.LIBGL_ALWAYS_SOFTWARE??='1';process.env.EGL_PLATFORM??='surfaceless';
process.env.LIBGL_DRIVERS_PATH??='/nix/store/ppphyhp48gzdji3q9q945gmamy4rrj3f-mesa-26.2.2/lib/dri';
process.env.__EGL_VENDOR_LIBRARY_FILENAMES??='/nix/store/ppphyhp48gzdji3q9q945gmamy4rrj3f-mesa-26.2.2/share/glvnd/egl_vendor.d/50_mesa.json';
}
const pw = require(process.env.UX_PLAYWRIGHT_MODULE || (engine==='webkit'?'/tmp/chat-ux-webkit/node_modules/playwright':'/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 option=name=>process.argv.find(arg=>arg.startsWith('--'+name+'='))?.slice(name.length+3);
const checks=process.env.UX_CHECKS || option('checks');
const origin = process.env.UX_BASE_URL || option('origin') || 'http://127.0.0.1:4173';
const profile = process.env.UX_PROFILE_DIR || `/tmp/chat-ux-runner/candidate-profiles/${engine}/a`;
const out = process.env.UX_RESULTS_DIR || `/tmp/chat-ux-runner/results/state-${engine}-${Date.now()}`;
fs.mkdirSync(out,{recursive:true,mode:0o700});
const report = {engine,origin,checks:[],errors:[],simulation:'Linux browser with real IndexedDB, service worker and audit XMPP accounts. Locally seeded pagination history; not native iOS certification.'};
let ctx, page;
let blockConnection=false, failDevices=false, holdDevices=false, held=[];
let failMembership=false, holdMembership=false, heldMembership=[];
let protocolClient;
const liveSockets=new Set();
const wire = {connections:0,messages:0};
const sentMessages=[];
const delay = ms => new Promise(r=>setTimeout(r,ms));
async function until(fn, timeout=20000) { const end=Date.now()+timeout; do {if(await fn())return; await delay(100);} while(Date.now()<end); throw Error('Condition timed out'); }
function save() {fs.writeFileSync(path.join(out,'report.json'),JSON.stringify(report,null,2),{mode:0o600});}
async function check(id,fn) {
if(checks && !checks.split(',').includes(id))return;
try {report.checks.push({id,status:'passed',...await fn()});}
catch(e) {report.checks.push({id,status:'failed',detail:String(e.message).replaceAll(accounts[0].password,'[redacted]')});await page.screenshot({path:path.join(out,id+'.png')}).catch(()=>{});}
save(); console.log(JSON.stringify(report.checks.at(-1)));
}
async function launch(offline=false) {
ctx=await pw[engine].launchPersistentContext(profile,{headless:true,viewport:{width:402,height:874},isMobile:true,hasTouch:true,offline,
executablePath:engine==='chromium' ? process.env.UX_CHROMIUM_PATH || '/nix/store/33pxss8h71cl7vmfpy21bidsw0lj1g8q-chromium-152.0.7977.82/bin/chromium' : process.env.UX_WEBKIT_PATH});
await ctx.routeWebSocket('**/xmpp-websocket',ws=>{
wire.connections++;
wire.lastConnectionAt=Date.now();
if(blockConnection) {ws.close();return;}
liveSockets.add(ws);ws.onClose(()=>liveSockets.delete(ws));
const server=ws.connectToServer();
ws.onMessage(message=>{
const s=String(message);
if(s.startsWith('<message')&&s.includes('<body>')){wire.messages++;sentMessages.push(s);if(sentMessages.length>30)sentMessages.shift();}
if(s.startsWith('<iq')&&s.includes('eu.siacs.conversations.axolotl.devicelist')&&s.includes('<items')) {
if(failDevices) {const id=s.match(/\bid="([^"]+)"/)[1];const from=s.match(/\bto="([^"]+)"/)?.[1] || accounts[0].jid;ws.send(`<iq xmlns="jabber:client" type="error" id="${id}" from="${from}"><error type="cancel"><service-unavailable xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/></error></iq>`);return;}
if(holdDevices){held.push(()=>server.send(message));return;}
}
if(s.startsWith('<iq')&&s.includes('http://jabber.org/protocol/muc#admin')&&s.includes('type="get"')) {
if(holdMembership){heldMembership.push(()=>server.send(message));return;}
if(failMembership){const id=s.match(/\bid="([^"]+)"/)[1],from=s.match(/\bto="([^"]+)"/)[1];ws.send(`<iq xmlns="jabber:client" type="error" id="${id}" from="${from}"><error type="cancel"><service-unavailable xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/></error></iq>`);return;}
}
server.send(message);
});
server.onMessage(m=>ws.send(m));
});
page=ctx.pages()[0]||await ctx.newPage();page.setDefaultTimeout(15000);
page.on('pageerror',e=>{report.errors.push(e.message);(report.errorDetails??=[]).push({source:'pageerror',phase:report.replyPhase,stack:e.stack});});
page.on('requestfailed',r=>{(report.networkFailures??=[]).push({url:r.url(),type:r.resourceType(),error:r.failure()?.errorText});});
}
async function login(p=page,account=accounts[0]) {
await p.goto(origin);
let submitted=false;
await until(async()=>{
if(!submitted && await p.locator('[name=username]').isVisible()) {
submitted=true;await p.locator('[name=username]').fill(account.jid);await p.locator('[name=password]').fill(account.password);
await p.getByRole('checkbox',{name:'Stay logged in'}).check();await p.getByRole('button',{name:'Log in',exact:true}).click();
}
return await p.locator('.app').count()>0 && !await p.locator('.connection').count();
},30000);
}
async function online() { await until(async()=>await page.locator('.app').count()>0 && !await page.locator('.connection').count(),30000); }
async function chat(jid=accounts[1].jid,p=page) {await p.evaluate(j=>location.hash='#/chat/'+encodeURIComponent(j),jid);await p.locator('.composer textarea:enabled').waitFor();}
async function list() {await page.evaluate(()=>location.hash='');await page.getByRole('button',{name:'Settings',exact:true}).waitFor();}
async function phoneControls() {
const focused=locator=>locator.evaluate(el=>document.activeElement===el);
const targets=async selector=>{
const small=await page.locator(selector).evaluateAll(els=>els.filter(el=>el.getClientRects().length).map(el=>({label:el.getAttribute('aria-label')||el.textContent,width:el.getBoundingClientRect().width,height:el.getBoundingClientRect().height})).filter(r=>r.width<43.9||r.height<43.9));
assert.deepEqual(small,[],'touch target dimensions');
};
const noOverflow=async label=>assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1),label+' overflows');
const originalTheme=await page.evaluate(()=>localStorage.getItem('theme'));
let originalDraft,largeText;
try {
await list();await targets('.list header button');
const newChat=page.getByRole('button',{name:'New chat',exact:true});
await newChat.click();const address=page.getByRole('textbox',{name:'Contact or room address',exact:true});
assert.ok(await focused(address));await address.press('Escape');assert.ok(await focused(newChat));assert.equal(await newChat.getAttribute('aria-expanded'),'false');
await page.getByRole('button',{name:'Settings',exact:true}).click();
for(const theme of ['Light','Dark']){
await page.getByRole('radio',{name:theme,exact:true}).click();
const colors=await page.evaluate(()=>({meta:document.querySelector('meta[name="theme-color"]').content,canvas:getComputedStyle(document.documentElement).backgroundColor,header:getComputedStyle(document.querySelector('.settings header')).backgroundColor}));
assert.equal(colors.canvas,colors.header);assert.match(colors.meta,theme==='Light'?/^#(?:fff|ffffff)$/i:/^#1c1c1e$/i);
}
await page.getByRole('radio',{name:'System',exact:true}).click();
for(const colorScheme of ['light','dark']){
await page.emulateMedia({colorScheme});
await until(async()=>await page.evaluate(dark=>document.documentElement.style.colorScheme===(dark?'dark':'light'),colorScheme==='dark'));
assert.match(await page.locator('meta[name="theme-color"]').getAttribute('content'),colorScheme==='light'?/^#(?:fff|ffffff)$/i:/^#1c1c1e$/i);
}
await page.getByRole('button',{name:'Back to chats',exact:true}).click();await chat();
// The roster supplies the first menu item; wait for it before testing order.
await page.getByRole('button',{name:'Call',exact:true}).waitFor();
originalDraft=await text().inputValue();await text().fill('Phone focus draft\nsecond line');
const more=page.getByRole('button',{name:'More actions',exact:true});
await more.focus();await more.press('ArrowDown');const menu=page.getByRole('menu',{name:'Chat actions',exact:true});
await menu.waitFor();const entries=menu.locator('[role=menuitem]:enabled');assert.ok(await focused(entries.first()));
await page.keyboard.press('End');assert.ok(await focused(entries.last()));await page.keyboard.press('Home');assert.ok(await focused(entries.first()));
await page.keyboard.press('ArrowUp');assert.ok(await focused(entries.last()));await page.keyboard.press('Escape');assert.ok(await focused(more));assert.equal(await more.getAttribute('aria-expanded'),'false');
await more.press('ArrowUp');await menu.waitFor();assert.ok(await focused(entries.last()));await page.keyboard.press('Tab');assert.equal(await menu.count(),0);
await more.click();await menu.waitFor();await text().click();assert.equal(await menu.count(),0);assert.ok(await focused(text()));
const lock=page.locator('.chat button[aria-haspopup="dialog"]');await lock.waitFor();await lock.click();
const dialog=page.getByRole('dialog',{name:'Device fingerprints',exact:true});await dialog.waitFor();assert.ok(await dialog.evaluate(el=>el.matches(':modal')));
for(const key of ['Tab','Tab','Shift+Tab','Shift+Tab']){await page.keyboard.press(key);assert.ok(await dialog.evaluate(el=>el.contains(document.activeElement)));}
await text().evaluate(el=>el.focus());assert.ok(await dialog.evaluate(el=>el.contains(document.activeElement)),'background is inert');
await page.keyboard.press('Escape');assert.equal(await dialog.count(),0);assert.ok(await focused(lock));assert.equal(await text().inputValue(),'Phone focus draft\nsecond line');
await text().focus();await lock.dispatchEvent('click');await dialog.getByRole('button',{name:'Close',exact:true}).click();assert.ok(await focused(lock),'return to opener even when opening did not focus it');
const layouts=[];
for(const [width,height,left,right] of [[320,680,0,0],[402,874,0,0],[740,360,58,58],[1280,800,0,0]]){
await page.setViewportSize({width,height});
await page.evaluate(({left,right})=>{const root=document.documentElement;for(const [name,value] of Object.entries({top:62,bottom:34,left,right}))root.style.setProperty('--safe-'+name,value+'px');},{left,right});
await chat();await noOverflow('chat '+width);await targets('.chat header button,.composer button');
const box=await page.locator('.composer').boundingBox();assert.ok(box.y+box.height<=height+1,'composer stays on screen');
const top=await page.locator('.chat header button:visible').first().boundingBox();assert.ok(top.y>=62,'header respects top safe area');
await page.screenshot({path:path.join(out,'phone-'+width+'.png')});
await list();await noOverflow('list '+width);await page.getByRole('button',{name:'Settings',exact:true}).click();await noOverflow('settings '+width);await targets('.settings button');
layouts.push({width,height,left,right});
}
await page.setViewportSize({width:320,height:680});await chat();
largeText=await page.addStyleTag({content:'button,input,textarea {font-size:32px!important} .title strong {font-size:28px!important}'});
await noOverflow('large text');await targets('.chat header button,.composer button');
await page.screenshot({path:path.join(out,'phone-large-text.png')});
await lock.click();await dialog.waitFor();await noOverflow('large text dialog');await targets('dialog button');
const sheet=await dialog.locator('.sheet').boundingBox();assert.ok(sheet.y>=62&&sheet.y+sheet.height<=680-34,'modal respects safe areas');
await page.keyboard.press('Escape');await list();await noOverflow('large text list');
await page.getByRole('button',{name:'Settings',exact:true}).click();await noOverflow('large text settings');
await page.getByRole('button',{name:'Back to chats',exact:true}).click();await chat();
await ctx.setOffline(true);await page.locator('.connection').waitFor();
assert.equal(await page.locator('.chat header').evaluate(el=>getComputedStyle(el).paddingTop),'8px','top inset consumed once');
await ctx.setOffline(false);await online();
return {nativeModal:true,focusContained:true,escapeAndReturn:true,menuKeyboard:true,outsideDismissal:true,minimumTouchSize:44,layouts,largeText:true,themeMatched:true,iosStatusBar:'configured; physical iPhone check remains'};
} finally {
await ctx.setOffline(false);await page.emulateMedia({colorScheme:null});
await largeText?.evaluate(el=>el.remove());
await page.evaluate(theme=>{if(theme===null)localStorage.removeItem('theme');else localStorage.setItem('theme',theme);for(const name of ['top','bottom','left','right'])document.documentElement.style.removeProperty('--safe-'+name);},originalTheme);
await page.setViewportSize({width:402,height:874});await page.reload();await chat();if(originalDraft!==undefined)await text().fill(originalDraft);
}
}
const text = () => page.locator('.composer textarea');
async function anchor() {return page.locator('.messages').evaluate(el=>{const top=el.getBoundingClientRect().top;const row=[...el.querySelectorAll('[data-message-key]')].find(r=>r.getBoundingClientRect().bottom>top);return {key:row?.dataset.messageKey,text:row?.querySelector('.text')?.textContent,offset:row?.getBoundingClientRect().top-top};});}
async function expireDevices() {
await page.evaluate(async me=>{
const db=await new Promise((res,rej)=>{const r=indexedDB.open('omemo:'+me);r.onsuccess=()=>res(r.result);r.onerror=()=>rej(r.error);});
const tx=db.transaction('devices','readwrite');const store=tx.objectStore('devices');const r=store.openCursor();
r.onsuccess=()=>{const c=r.result;if(c){c.update({...c.value,checked:0});c.continue();}};
await new Promise((res,rej)=>{tx.oncomplete=res;tx.onerror=()=>rej(tx.error);});db.close();
},accounts[0].jid);
}
async function protocol(to,child) {
return protocolClient.iqCaller.set(child,to,10000);
}
async function replyChecks() {
let bctx,b,room,deviceCache;
const tag='UX reply '+Date.now(), original=tag+' original 🐱\nsecond line', answer=tag+' answer 🎉';
const row=(p,body)=>p.locator('.row').filter({has:p.locator('.text').filter({hasText:body})});
const choose=async(p,body)=>{const r=row(p,body);await r.getByRole('button',{name:'Reply to message',exact:true}).click();return r;};
const preview=p=>p.locator('.reply-preview');
const restoreDevices=async()=>{if(!deviceCache)return;await page.evaluate(async({me,row})=>{
const db=await new Promise(res=>{const r=indexedDB.open('omemo:'+me);r.onsuccess=()=>res(r.result);});const tx=db.transaction('devices','readwrite');tx.objectStore('devices').put(row);await new Promise(res=>tx.oncomplete=res);db.close();
},{me:accounts[0].jid,row:deviceCache});deviceCache=undefined;};
try {
bctx=await pw[engine].launchPersistentContext(path.join(path.dirname(profile),'b'),{headless:true,viewport:{width:402,height:874},isMobile:true,hasTouch:true,
executablePath:engine==='chromium'?process.env.UX_CHROMIUM_PATH||'/nix/store/33pxss8h71cl7vmfpy21bidsw0lj1g8q-chromium-152.0.7977.82/bin/chromium':process.env.UX_WEBKIT_PATH});
b=await bctx.newPage();b.setDefaultTimeout(15000);await login(b,accounts[1]);await chat(accounts[0].jid,b);await chat();
const send=async(p,body)=>{await p.locator('.composer textarea').fill(body);await p.getByRole('button',{name:'Send',exact:true}).click();};
await send(b,original);await row(page,original).waitFor();await text().fill('typed before reply');
report.replyPhase='select';let selected=await choose(page,original);await preview(page).waitFor();assert.equal(await text().inputValue(),'typed before reply');
await page.getByRole('button',{name:'Cancel reply',exact:true}).click();assert.equal(await preview(page).count(),0);
assert.ok(await selected.getByRole('button',{name:'Reply to message',exact:true}).evaluate(el=>document.activeElement===el));
report.replyPhase='restart';await choose(page,original);await page.reload();await chat();await preview(page).waitFor();assert.equal(await text().inputValue(),'typed before reply');
report.replyPhase='settings';await list();await page.getByRole('button',{name:'Settings',exact:true}).click();await page.getByRole('button',{name:'Back to chats',exact:true}).click();await chat();await preview(page).waitFor();
report.replyPhase='isolation';await chat(accounts[0].jid);assert.equal(await preview(page).count(),0);await chat();await preview(page).waitFor();
report.replyPhase='encrypted-send';
// A known encrypted peer with an explicit empty list fails closed. Unlike
// expiry alone, this fixture cannot be healed by an already pending fetch.
await delay(1000);failDevices=true;
deviceCache=await page.evaluate(async({me,peer})=>{
const db=await new Promise(res=>{const r=indexedDB.open('omemo:'+me);r.onsuccess=()=>res(r.result);});const tx=db.transaction('devices','readwrite'),store=tx.objectStore('devices');const row=await new Promise(res=>{const r=store.get(peer);r.onsuccess=()=>res(r.result);});
if(!row)throw Error('Peer device cache is not ready');store.put({...row,deviceIds:[],everCapable:true,checked:Date.now()});await new Promise(res=>tx.oncomplete=res);db.close();return row;
},{me:accounts[0].jid,peer:accounts[1].jid});
await send(page,answer);await page.locator('.send-error').waitFor();
assert.ok((await preview(page).innerText()).includes(original));assert.equal(await text().inputValue(),answer);await restoreDevices();failDevices=false;await expireDevices();
const count=sentMessages.length;await page.getByRole('button',{name:'Send',exact:true}).click();await row(b,answer).waitFor();
report.replyPhase='incoming-quote';const incoming=row(b,answer);assert.ok((await incoming.locator('.quoted').innerText()).includes(original));
assert.equal(await incoming.locator('.text').innerText(),answer);await until(async()=>!await preview(page).count());
const stanza=sentMessages.slice(count).find(s=>s.includes('urn:xmpp:reply:0'));
assert.ok(stanza?.includes('eu.siacs.conversations.axolotl'));assert.ok(!stanza.includes(original));assert.ok(!stanza.includes(answer));
// Reload exercises archived/carbon copies without duplicate decryption/rendering.
report.replyPhase='carbon-archive';await b.reload();await chat(accounts[0].jid,b);await row(b,answer).waitFor();assert.equal(await row(b,answer).count(),1);
await row(b,answer).getByRole('button',{name:'Go to original message',exact:true}).click();
await until(()=>row(b,original).evaluate(el=>el.classList.contains('highlighted')&&document.activeElement===el));
const originalKey=await row(page,original).getAttribute('data-message-key');
const answerKey=await row(page,answer).getAttribute('data-message-key');
report.replyPhase='older-history';// Put the real original beyond the latest local page, plus a missing-ID reply.
await page.evaluate(async({me,peer,tag,key,answerKey})=>{
const db=await new Promise((res,rej)=>{const r=indexedDB.open('chat:'+me);r.onsuccess=()=>res(r.result);r.onerror=()=>rej(r.error);});
const tx=db.transaction(['messages','meta'],'readwrite'),store=tx.objectStore('messages'),r=store.get(key);
tx.objectStore('meta').put({k:'anchor:'+peer,v:{bottom:true,offset:0}});
r.onsuccess=()=>{const m=r.result;for(let i=1;i<=80;i++)store.put({key:tag+'-seed-'+i,peer,type:'chat',dir:'in',from:peer,body:tag+' newer '+i,ts:m.ts+1000+i,id:tag+'-seed-'+i});
store.put({key:tag+'-missing',peer,type:'chat',dir:'in',from:peer,body:tag+' missing',ts:m.ts+2000,id:tag+'-missing',reply:{id:'absent-'+tag,to:peer,quote:'Fallback for missing original'}});
const a=store.get(answerKey);a.onsuccess=()=>store.put({...a.result,key:tag+'-jump',id:tag+'-jump',originId:tag+'-jump',body:tag+' older jump',ts:m.ts+2100});};
await new Promise((res,rej)=>{tx.oncomplete=res;tx.onerror=()=>rej(tx.error);});db.close();const c=new BroadcastChannel('chat:'+me);c.postMessage(['messages']);c.close();
},{me:accounts[0].jid,peer:accounts[1].jid,tag,key:originalKey,answerKey});
await page.reload();await chat();await row(page,tag+' missing').waitFor();
await row(page,tag+' missing').getByRole('button',{name:'Go to original message',exact:true}).click();await page.getByText('Original message is not saved on this device.',{exact:true}).waitFor();
const latest=page.getByRole('button',{name:'Jump to latest messages',exact:true});if(await latest.count())await latest.click();await row(page,tag+' older jump').waitFor();assert.equal(await row(page,original).count(),0);
await row(page,tag+' older jump').getByRole('button',{name:'Go to original message',exact:true}).click();
await delay(300);const positioned=await row(page,original).evaluate(el=>{const box=el.getBoundingClientRect(),view=el.closest('.messages').getBoundingClientRect();return el.classList.contains('highlighted')&&box.bottom>view.top&&box.top<view.bottom;});assert.ok(positioned,'older original is visible after jump');
await choose(page,original);await text().press('Escape');assert.equal(await preview(page).count(),0);
for(const viewport of [{width:320,height:680},{width:402,height:874},{width:740,height:360},{width:1280,height:800}]){
await page.setViewportSize(viewport);await choose(page,original);assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1));
const sizes=await page.locator('.reply-preview button,.quoted:visible,.reply-action:visible').evaluateAll(els=>els.map(el=>({width:el.getBoundingClientRect().width,height:el.getBoundingClientRect().height})));assert.ok(sizes.every(r=>r.width>=43.9&&r.height>=43.9));await page.getByRole('button',{name:'Cancel reply',exact:true}).click();
}await page.setViewportSize({width:402,height:874});
// Plain group reply: reference must be the room-issued stanza ID.
report.replyPhase='group';room=`ux-state-${engine}-${Date.now()}@muc.0ff.space`;report.groupFixture=room;
protocolClient=client({service:'wss://chat.0ff.space/xmpp-websocket',domain:'0ff.space',username:accounts[0].id,password:accounts[0].password,resource:'ux-reply-fixture-'+Date.now()});protocolClient.on('error',()=>{});await protocolClient.start();
await chat(room);const join=page.getByRole('button',{name:'Join',exact:true});if(await join.count())await join.click();await page.locator('textarea[placeholder="Message"]').waitFor();
await protocol(room,xml('query',{xmlns:'http://jabber.org/protocol/muc#admin'},xml('item',{affiliation:'member',jid:accounts[1].jid})));
await chat(room,b);const bj=b.getByRole('button',{name:'Join',exact:true});if(await bj.count())await bj.click();await b.locator('textarea[placeholder="Message"]').waitFor();
const groupOriginal=tag+' group original',groupAnswer=tag+' group answer';await send(b,groupOriginal);await row(page,groupOriginal).waitFor();await choose(page,groupOriginal);await send(page,groupAnswer);await row(b,groupAnswer).waitFor();
await row(b,groupAnswer).getByRole('button',{name:'Go to original message',exact:true}).click();await until(()=>row(b,groupOriginal).evaluate(el=>el.classList.contains('highlighted')));
const groupStanza=sentMessages.find(s=>s.includes(groupAnswer));assert.ok(groupStanza?.includes('urn:xmpp:reply:0'));assert.ok(!groupStanza.includes('eu.siacs.conversations.axolotl'));
return {encryptedDirect:true,noQuotePlaintext:true,unicode:true,draftRestart:true,cancelFocus:true,failedSendRetained:true,carbonArchiveReload:true,originalNavigation:true,missingHistory:true,olderHistory:true,groupStableId:true,layouts:true,monal:'6.4.21 source-checked; physical display is recorded separately'};
} finally {
failDevices=false;holdDevices=false;for(const release of held.splice(0))release();
await restoreDevices().catch(()=>{});
if(room){await protocol(room,xml('query',{xmlns:'http://jabber.org/protocol/muc#owner'},xml('destroy'))).catch(()=>{});for(const p of [b,page])if(p&&!p.isClosed()){await chat(room,p).catch(()=>{});await p.getByRole('button',{name:'More actions',exact:true}).click().catch(()=>{});p.once('dialog',d=>d.accept());await p.getByRole('menuitem',{name:'Leave room',exact:true}).click().catch(()=>{});}}
await protocolClient?.stop();protocolClient=undefined;await bctx?.close();
await page.evaluate(async({me,tag})=>{const db=await new Promise(res=>{const r=indexedDB.open('chat:'+me);r.onsuccess=()=>res(r.result);});const tx=db.transaction('messages','readwrite'),r=tx.objectStore('messages').openCursor();r.onsuccess=()=>{const c=r.result;if(c){if(String(c.key).startsWith(tag))c.delete();c.continue();}};await new Promise(res=>tx.oncomplete=res);db.close();},{me:accounts[0].jid,tag}).catch(()=>{});
await page.setViewportSize({width:402,height:874});await chat();
}
}
async function groupChecks() {
const room=`ux-state-${engine}-${Date.now()}@muc.0ff.space`;report.groupFixture=room;
let bctx,b;
const info=async p=>{await p.getByRole('button',{name:/^Group information for /}).click();await p.locator('.group-info').waitFor();};
const ready=async p=>{await until(async()=>!await p.getByText('Loading membership…',{exact:true}).count());await delay(100);};
const join=async p=>{await chat(room,p);const button=p.getByRole('button',{name:'Join',exact:true});if(await button.count())await button.click();await p.locator('textarea[placeholder="Message"]').waitFor({timeout:30000});};
const affiliation=value=>protocol(room,xml('query',{xmlns:'http://jabber.org/protocol/muc#admin'},xml('item',{affiliation:value,jid:accounts[1].jid})));
try {
protocolClient=client({service:'wss://chat.0ff.space/xmpp-websocket',domain:'0ff.space',username:accounts[0].id,password:accounts[0].password,resource:'ux-fixture-'+Date.now()});
protocolClient.on('error',()=>{});await protocolClient.start();
await join(page);await text().fill('Group info draft\nsecond line');await info(page);await ready(page);
assert.equal(await page.evaluate(()=>document.activeElement.tagName),'H1');
holdMembership=true;await page.getByRole('button',{name:'Refresh membership',exact:true}).click();await page.getByText('Loading membership…',{exact:true}).waitFor();
holdMembership=false;for(const release of heldMembership.splice(0))release();await ready(page);
failMembership=true;await page.getByRole('button',{name:'Refresh membership',exact:true}).click();await page.getByText(/Could not load the member list:/).waitFor();
assert.equal(await page.getByText('No affiliated members are listed.',{exact:true}).count(),0);
failMembership=false;await page.getByRole('button',{name:'Refresh membership',exact:true}).click();await ready(page);
await page.getByPlaceholder('name@server',{exact:true}).fill(accounts[1].jid);await page.getByRole('button',{name:'Send invitation',exact:true}).click();
await until(async()=>await page.getByPlaceholder('name@server',{exact:true}).inputValue()==='');await page.getByRole('button',{name:'Refresh membership',exact:true}).click();await ready(page);
await page.getByRole('button',{name:'Person actions for '+accounts[1].jid,exact:true}).waitFor();
const absentPerson=page.getByRole('button',{name:'Person actions for '+accounts[1].jid,exact:true});
await absentPerson.click();await until(()=>page.locator('.person-actions h2').evaluate(el=>document.activeElement===el));
await page.getByRole('button',{name:'Close person actions',exact:true}).click();assert.ok(await absentPerson.evaluate(el=>document.activeElement===el));
assert.equal(await page.getByText('Membership information is incomplete.',{exact:true}).count(),0);
assert.equal(await page.getByRole('button',{name:'Person actions for '+accounts[0].jid,exact:true}).count(),0); // owner is already present, no duplicate
bctx=await pw[engine].launchPersistentContext(path.join(path.dirname(profile),'b'),{headless:true,viewport:{width:402,height:874},isMobile:true,hasTouch:true,
executablePath:engine==='chromium'?process.env.UX_CHROMIUM_PATH||'/nix/store/33pxss8h71cl7vmfpy21bidsw0lj1g8q-chromium-152.0.7977.82/bin/chromium':process.env.UX_WEBKIT_PATH});
let renameNick;
await bctx.routeWebSocket('**/xmpp-websocket',ws=>{const server=ws.connectToServer();ws.onMessage(m=>{
if(renameNick && String(m).startsWith('<message') && String(m).includes('http://jabber.org/protocol/chatstates')) {
m=xml('presence',{xmlns:'jabber:client',to:room+'/'+renameNick}).toString();renameNick=undefined;
}
server.send(m);
});server.onMessage(m=>ws.send(m));});
b=await bctx.newPage();b.setDefaultTimeout(15000);await login(b,accounts[1]);await join(b);await info(b);await ready(b);
assert.equal(await b.getByText('Membership information is incomplete.',{exact:true}).count(),1);
assert.equal(await b.getByText('No affiliated members are listed.',{exact:true}).count(),0);
const denied=await b.locator('[role=status]').allTextContents();assert.ok(denied.some(t=>t.includes('member list')));
const bNick=accounts[1].id;
await page.getByRole('button',{name:'Person actions for '+bNick,exact:true}).click();
await page.getByRole('button',{name:'Private message in room',exact:true}).click();await text().waitFor();assert.equal(await page.evaluate(()=>decodeURIComponent(location.hash)), '#/chat/'+room+'/'+bNick);
await chat(room);assert.equal(await text().inputValue(),'Group info draft\nsecond line');
await b.getByRole('button',{name:'Person actions for '+accounts[0].id,exact:true}).click();assert.equal(await b.getByRole('button',{name:'Open direct chat',exact:true}).count(),0);
await b.getByRole('button',{name:'Back to group chat',exact:true}).click();
const body='UX group nickname '+Date.now();await b.locator('.composer textarea').fill(body);await b.getByRole('button',{name:'Send',exact:true}).click();
const bubble=page.locator('.bubble').filter({has:page.getByText(body,{exact:true})});await bubble.waitFor();await bubble.locator('button.nick').click();
await page.getByRole('button',{name:'Private message in room',exact:true}).waitFor();assert.ok((await page.locator('.person-actions').innerText()).includes(bNick));
// Nickname changes are exercised through presence in this disposable room.
// Substitute one counted typing stanza with a nickname-change presence.
// Stream-management counters stay aligned; the server emits actual 303/110.
await chat(accounts[0].jid,b);renameNick=bNick+'-renamed';await b.locator('.composer textarea').fill('nickname change fixture');
await page.getByRole('button',{name:'Person actions for '+bNick+'-renamed',exact:true}).waitFor();
assert.equal(await page.getByRole('button',{name:'Person actions for '+bNick,exact:true}).count(),0);
await chat(room,b);await chat(accounts[0].jid,b);renameNick=bNick;await b.locator('.composer textarea').fill('restore nickname fixture');
await page.getByRole('button',{name:'Person actions for '+bNick,exact:true}).waitFor();
await chat(room,b);await affiliation('admin');await info(b);await b.getByRole('button',{name:'Refresh membership',exact:true}).click();await ready(b);
assert.equal(await b.getByText('The room does not allow you to view the member list.',{exact:true}).count(),0);
assert.equal(await b.getByText('The room does not allow you to view the admin list.',{exact:true}).count(),0);
await affiliation('member');
await b.getByRole('button',{name:'Leave room',exact:true}).click({trial:true});b.once('dialog',d=>d.accept());await b.getByRole('button',{name:'Leave room',exact:true}).click();
await page.getByRole('heading',{name:'Present participants (1)',exact:true}).waitFor();await page.getByRole('button',{name:'Refresh membership',exact:true}).click();await ready(page);
await page.getByRole('button',{name:'Person actions for '+accounts[1].jid,exact:true}).waitFor();
const layouts=[];for(const viewport of [{width:320,height:700},{width:402,height:874},{width:874,height:402},{width:1280,height:800}]){
await page.setViewportSize(viewport);layouts.push(await page.evaluate(()=>({width:innerWidth,documentWidth:document.documentElement.scrollWidth})));assert.ok(layouts.at(-1).documentWidth<=viewport.width);
}await page.setViewportSize({width:402,height:874});
return {room,owner:true,admin:true,ordinaryMemberDenied:true,membershipLoadingAndError:true,absentMember:true,personActions:true,nicknameChanges:true,inviteLeave:true,draftPreserved:true,layouts};
} finally {
failMembership=false;holdMembership=false;for(const release of heldMembership.splice(0))release();
await affiliation('member').catch(()=>{});
await protocol(room,xml('query',{xmlns:'http://jabber.org/protocol/muc#owner'},xml('destroy'))).catch(()=>{});
if(b && !b.isClosed()) {await chat(room,b).catch(()=>{});const leave=b.getByRole('menuitem',{name:'Leave room',exact:true});await b.getByRole('button',{name:'More actions',exact:true}).click().catch(()=>{});if(await leave.count()){b.once('dialog',d=>d.accept());await leave.click().catch(()=>{});}}
await bctx?.close();await chat(room).catch(()=>{});await page.getByRole('button',{name:'More actions',exact:true}).click().catch(()=>{});page.once('dialog',d=>d.accept());await page.getByRole('menuitem',{name:'Leave room',exact:true}).click().catch(()=>{});
await protocolClient?.stop();protocolClient=undefined;
}
}
async function main() {
await launch();await login();
if(checks?.split(',').includes('SHELL-PROBE')) await check('SHELL-PROBE',async()=>{
await page.evaluate(()=>navigator.serviceWorker.ready);await delay(500);
const inspect=()=>page.evaluate(async()=>({online:navigator.onLine,controlled:!!navigator.serviceWorker.controller,
caches:await Promise.all((await caches.keys()).map(async key=>({key,paths:(await(await caches.open(key)).keys()).map(r=>new URL(r.url).pathname)})))}));
const before=await inspect();await ctx.setOffline(true);await delay(200);const disconnected=await inspect();await page.reload();await delay(1000);const after=await inspect();
const rendered=await page.locator('.app').count();await ctx.setOffline(false);await login();return {before,disconnected,after,rendered};
});
await check('BUILD',async()=>{
const res=await ctx.request.get(origin);const html=await res.text();const asset=html.match(/src="\.\/(assets\/[^\"]+\.js)"/)[1];
const bytes=await(await ctx.request.get(origin+'/'+asset)).body();const loaded=await page.evaluate(()=>[...document.scripts].map(s=>s.src).find(s=>s.includes('/assets/')));
assert.ok(loaded.endsWith('/'+asset));return {asset,sha256:crypto.createHash('sha256').update(bytes).digest('hex'),csp:res.headers()['content-security-policy']};
});
await check('CONNECTION-START',async()=>{
const started=Date.now();
await page.reload({waitUntil:'domcontentloaded'});await page.locator('.app').waitFor();
const savedChatsMs=Date.now()-started;
await online();return {savedChatsMs,onlineMs:Date.now()-started,rememberedLogin:true};
});
await check('PHONE-CONTROLS',phoneControls);
await check('QUOTED-REPLIES',replyChecks);
await check('DRAFT-NAVIGATION',async()=>{
await chat();await text().fill('Draft A\nsecond line');await list();await page.getByText('Draft: ',{exact:false}).first().waitFor();
await chat('draft-check@example.org');await text().fill('Draft B');await list();
await page.getByRole('button',{name:'Settings',exact:true}).click();await page.getByRole('button',{name:'Back to chats',exact:true}).click();
await chat();assert.equal(await text().inputValue(),'Draft A\nsecond line');
await page.reload();await text().waitFor();await until(async()=>await text().inputValue()==='Draft A\nsecond line');
await chat('draft-check@example.org');assert.equal(await text().inputValue(),'Draft B');await chat();
return {multiline:true,independentPeers:true,settings:true,reload:true};
});
await check('READING-ANCHOR',async()=>{
await chat();await text().fill('');
const stamp=Date.now();
await page.evaluate(async({me,peer,stamp})=>{
const db=await new Promise((res,rej)=>{const r=indexedDB.open('chat:'+me);r.onsuccess=()=>res(r.result);r.onerror=()=>rej(r.error);});
const tx=db.transaction(['messages','conversations'],'readwrite');
for(let i=0;i<180;i++)tx.objectStore('messages').put({key:'anchor-fixture-'+stamp+'-'+i,peer,type:'chat',dir:'in',from:peer,body:'Local history '+i+'\nsecond line\nthird line',ts:stamp+i});
tx.objectStore('conversations').put({peer,type:'chat',lastBody:'Local history 179',lastDir:'in',lastTs:stamp+179,unread:0,lastReadTs:stamp+179});
await new Promise((res,rej)=>{tx.oncomplete=res;tx.onerror=()=>rej(tx.error);});db.close();
},{me:accounts[0].jid,peer:accounts[1].jid,stamp});
await page.reload();await chat();await until(async()=>await page.getByText('Local history 179',{exact:false}).count()>0);await delay(300);
await page.locator('.messages').evaluate(el=>el.scrollTop=el.scrollHeight);await delay(300);
await page.locator('.messages').evaluate(el=>el.scrollTop=el.scrollHeight-el.clientHeight-500);await delay(500);
const before=await anchor();assert.ok(before.text.includes('Local history'));
await list();await chat();await delay(500);const after=await anchor();assert.equal(after.key,before.key,'navigation anchor');assert.ok(Math.abs(after.offset-before.offset)<3);
await page.reload();await chat();await delay(500);const reloaded=await anchor();assert.equal(reloaded.key,before.key,'reload anchor');assert.ok(Math.abs(reloaded.offset-before.offset)<3);
// Load older history while preserving the first visible message.
await page.locator('.messages').evaluate(el=>el.scrollTop=80);await delay(200);const paginationBefore=await anchor();
await page.getByRole('button',{name:'Load older messages',exact:true}).dispatchEvent('click');await delay(400);const paged=await anchor();
// New older rows can occupy the space formerly used by Load older/date.
// The original visible message must retain its position, even if a newly
// loaded preceding row becomes visible in that space.
const preserved=await page.locator('.messages').evaluate((el,key)=>{const row=[...el.querySelectorAll('[data-message-key]')].find(row=>row.dataset.messageKey===key);return row?{offset:row.getBoundingClientRect().top-el.getBoundingClientRect().top,visible:row.getBoundingClientRect().bottom>el.getBoundingClientRect().top}:null;},paginationBefore.key);
report.paginationProbe={before:paginationBefore,after:paged,preserved};assert.ok(preserved?.visible,'pagination anchor remains visible');assert.ok(Math.abs(preserved.offset-paginationBefore.offset)<3,'pagination anchor offset');
// Simulate late media sizing above the viewport; measure visible content.
await page.locator('.messages .row').first().evaluate(el=>el.querySelector('.bubble').style.minHeight='260px');await delay(300);
const resized=await anchor();assert.equal(resized.key,paged.key,'late size anchor');assert.ok(Math.abs(resized.offset-paged.offset)<3);
await page.getByRole('button',{name:'Jump to latest messages',exact:true}).click();await delay(200);
assert.ok(await page.locator('.messages').evaluate(el=>el.scrollHeight-el.clientHeight-el.scrollTop<120));
return {before,after,reloaded,pagination:true,lateSizingSimulation:true,jumpToLatest:true};
});
await check('DRAFT-SEND-RACES',async()=>{
await chat();await page.getByRole('button',{name:'End-to-end encrypted',exact:true}).waitFor();await delay(300);await expireDevices();
failDevices=true;await text().fill('failed draft retained');await page.getByRole('button',{name:'Send',exact:true}).click();await page.locator('.send-error').waitFor();
assert.equal(await text().inputValue(),'failed draft retained');failDevices=false;await expireDevices();
holdDevices=true;await text().fill('UX draft send race '+Date.now());await page.getByRole('button',{name:'Send',exact:true}).click();await until(()=>held.length>0);
await text().fill('typed during send');holdDevices=false;for(const release of held.splice(0))release();
await until(async()=>await page.getByRole('button',{name:'Send',exact:true}).isEnabled());assert.equal(await text().inputValue(),'typed during send');
// A second tab writes the same account/peer; both views must converge.
const second=await ctx.newPage();await second.goto(origin+'/#/chat/'+encodeURIComponent(accounts[1].jid));await second.locator('.composer textarea:enabled').waitFor();
await second.locator('.composer textarea').fill('draft from second tab');await until(async()=>await text().inputValue()==='draft from second tab');await delay(2500);
assert.equal(await page.getByRole('button',{name:'Send',exact:true}).isEnabled(),true);
assert.equal(await second.getByRole('button',{name:'Send',exact:true}).isEnabled(),true);await second.close();
return {failedSend:true,typingDuringSend:true,crossTab:true};
});
await check('OFFLINE-RESTART',async()=>{
await chat();await text().fill('Offline restart\nkeeps this draft');
await page.evaluate(()=>navigator.serviceWorker.ready);await delay(500);
await ctx.close();await launch(true);
let offlineEmulationLimitation;
try {await page.goto(origin+'/#/chat/'+encodeURIComponent(accounts[1].jid));}
catch(err) {
if(engine!=='webkit' || !String(err.message).includes('WebKit encountered an internal error'))throw err;
offlineEmulationLimitation='Linux WebKit rejects service-worker navigation with setOffline; upstream playwright#42775. Alternative restarts the same profile with shell online and XMPP blocked.';
await ctx.close();blockConnection=true;await launch(false);await page.goto(origin+'/#/chat/'+encodeURIComponent(accounts[1].jid));
}
await page.locator('.composer textarea:enabled').waitFor();assert.equal(await text().inputValue(),'Offline restart\nkeeps this draft');
await page.locator('.bubble .text').first().waitFor();assert.ok(await page.locator('.connection').innerText().then(t=>t.includes('Offline')));
assert.equal(await page.getByRole('button',{name:'Send',exact:true}).isDisabled(),true);
blockConnection=false;await ctx.setOffline(false);
const retry=page.getByRole('button',{name:'Retry connection',exact:true});
if(await retry.count() && await retry.isEnabled())await retry.click({timeout:1000}).catch(()=>{}); // automatic recovery can remove it first
await online();assert.equal(await page.getByRole('button',{name:'Send',exact:true}).isEnabled(),true);
for(let i=0;i<3;i++){await ctx.setOffline(true);await delay(100);await ctx.setOffline(false);await delay(100);}await online();
return {status:offlineEmulationLimitation?'not-tested':'passed',closedProfileOffline:!offlineEmulationLimitation,history:true,draft:true,recoveredWithoutReload:true,offlineEmulationLimitation};
});
await check('OFFLINE-NETWORK-RETRY',async()=>{
blockConnection=true;await page.reload();await page.locator('.connection').waitFor();await text().waitFor();
const before=wire.connections;await delay(3500);assert.ok(wire.connections>before);assert.ok(await page.evaluate(()=>!!localStorage.getItem('fast-token')));
blockConnection=false;await page.getByRole('button',{name:'Retry connection',exact:true}).click();await online();
return {automaticAttempts:wire.connections-before,tokenRetained:true,explicitRetry:true};
});
await check('RETRY-LATENCY',async()=>{
const before=wire.connections;
const probe=report.retryProbe={stage:'disconnect',before,sockets:liveSockets.size};
blockConnection=true;
for(const socket of liveSockets)socket.close();
try {
await page.locator('.connection').waitFor();
probe.stage='failed automatic attempts';
await until(()=>wire.connections>=before+2,15000);
blockConnection=false;
const clickedAt=Date.now(),atClick=wire.connections;
probe.stage='explicit retry';probe.atClick=atClick;
await page.getByRole('button',{name:'Retry connection',exact:true}).click();
await until(()=>wire.connections>atClick,15000);
const firstAttemptMs=wire.lastConnectionAt-clickedAt;
await online();
const onlineMs=Date.now()-clickedAt;
probe.stage='recovered';
assert.ok(firstAttemptMs<1000,`Retry waited ${firstAttemptMs}ms before opening a socket`);
return {failedAttempts:atClick-before,firstAttemptMs,onlineMs,tokenRetained:!!await page.evaluate(()=>localStorage.getItem('fast-token'))};
} finally {blockConnection=false;probe.connections=wire.connections;probe.banner=await page.locator('.connection').count()?await page.locator('.connection').textContent():null;}
});
await check('ACCOUNT-GATE',async()=>{
const saved=await page.evaluate(()=>localStorage.getItem('fast-token'));
await page.evaluate(()=>localStorage.removeItem('fast-token'));await page.reload();await page.locator('[name=username]').waitFor();assert.equal(await page.locator('.bubble').count(),0);
await page.evaluate(v=>localStorage.setItem('fast-token',v),saved);await page.reload();await online();await chat();
return {lastJidAloneShowsLogin:true};
});
await check('AUTH-REJECTION',async()=>{
await page.evaluate(()=>{const saved=JSON.parse(localStorage.getItem('fast-token'));saved.token.token='intentionally-invalid-audit-token';localStorage.setItem('fast-token',JSON.stringify(saved));});
await page.reload();await page.locator('[name=username]').waitFor({timeout:30000});assert.equal(await page.evaluate(()=>localStorage.getItem('fast-token')),null);assert.equal(await page.locator('.bubble').count(),0);
await login();return {rejectedTokenReturnsToLogin:true,localHistoryHidden:true};
});
await check('GROUP-INFO',groupChecks);
await check('OFFLINE-EMPTY-HISTORY',async()=>{
const saved=await page.evaluate(()=>localStorage.getItem('fast-token'));
if(engine==='webkit')blockConnection=true;else await ctx.setOffline(true);
await page.evaluate(()=>{const v=JSON.parse(localStorage.getItem('fast-token'));v.jid='offline-empty@0ff.space';localStorage.setItem('fast-token',JSON.stringify(v));location.hash='';});
try {await page.reload();report.offlineEmptyProbe=await page.evaluate(()=>({url:location.href,online:navigator.onLine,html:document.documentElement.outerHTML.slice(0,1000),scripts:[...document.scripts].map(s=>s.src)}));await page.getByText('No chats yet. Start one with +.',{exact:true}).waitFor();assert.ok(await page.locator('.connection').count());}
finally {await page.evaluate(v=>localStorage.setItem('fast-token',v),saved);blockConnection=false;await ctx.setOffline(false);await login();await page.evaluate(()=>new Promise((res,rej)=>{const r=indexedDB.deleteDatabase('chat:offline-empty@0ff.space');r.onsuccess=res;r.onerror=()=>rej(r.error);r.onblocked=()=>rej(Error('Fixture database is still open'));}));}
return {emptyRememberedStore:true,scope:engine==='webkit'?'Shell online, XMPP unavailable (offline-emulation limitation)':'Network offline'};
});
await check('LOGOUT',async()=>{
await chat();await text().fill('Logout removes this draft');await list();await page.getByRole('button',{name:'Settings',exact:true}).click();page.once('dialog',d=>d.accept());await page.getByRole('button',{name:'Log out',exact:true}).click();
await page.locator('[name=username]').waitFor();assert.equal(await page.evaluate(()=>localStorage.getItem('fast-token')),null);
assert.ok(!await page.evaluate(async me=>(await indexedDB.databases()).some(db=>db.name==='chat:'+me),accounts[0].jid));
await login();await chat();assert.equal(await text().inputValue(),'');return {accountLocalDataDeleted:true,draftDeleted:true};
});
}
main().catch(e=>{report.errors.push(String(e.message).replaceAll(accounts[0].password,'[redacted]'));(report.errorDetails??=[]).push({source:'runner',stack:String(e.stack).replaceAll(accounts[0].password,'[redacted]')});}).finally(async()=>{
await ctx?.close();save();console.log(JSON.stringify({report:path.join(out,'report.json'),errors:report.errors,checks:report.checks.map(c=>({id:c.id,status:c.status}))}));
if(report.errors.length||report.checks.some(c=>c.status==='failed'))process.exitCode=1;
});