480 lines
47 KiB
JavaScript
480 lines
47 KiB
JavaScript
// 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;
|
||
});
|