1// Run with playwright-cli run-code --filename after opening the disposable UX relay.2async page => {3 const base = await page.evaluate(() => location.origin);4 const results = [], errors = [];5 const onError = error => errors.push(error.message);6 page.on('pageerror', onError);7 const check = (ok, message) => { if (!ok) throw Error(message); };8 const run = async (name, action) => {9 try { const detail = await action(); results.push({name, ok:true, detail}); }10 catch (error) { results.push({name, ok:false, error:error.message}); }11 };12 const clickNav = async name => {13 const link = page.locator('#rail').getByRole('link', {name, exact:true});14 const href = await link.getAttribute('href');15 await link.click();16 await page.waitForURL(href.startsWith('/') ? base+href : href);17 await page.waitForFunction(() => document.querySelector('#content')?.getAttribute('aria-busy') !== 'true');18 };19 const forms = () => page.evaluate(() => [...document.querySelectorAll('rpc-form,signed-form,publish-list')].map(e => ({tag:e.localName,method:e.getAttribute('method'),form:!!e.form?.isConnected,output:!!e.output?.isConnected,nested:!!e.querySelector('form form')})));20 await page.setViewportSize({width:1440,height:1000});21 await page.goto(base + '/repos');22 await page.evaluate(() => { window.__uxMarker = 'preserved'; });23 await run('repository navigation preserves document and ref filters', async () => {24 await page.getByRole('link', {name:'ux-edge-repo',exact:true}).click();25 await page.waitForURL('**/repo?**');26 await clickNav('/code');27 await page.locator('#ref-select').selectOption('refs/heads/feature/café');28 await page.locator('#panel form').getByRole('button', {name:'go',exact:true}).click();29 await page.waitForFunction(() => new URL(location.href).searchParams.get('ref') === 'refs/heads/feature/café');30 check(await page.evaluate(() => window.__uxMarker) === 'preserved', 'navigation reloaded the document');31 const links = await page.locator('#tree a').evaluateAll(nodes => nodes.map(n => ({name:n.textContent,href:n.href})));32 check(links.length > 0, 'tree links missing');33 await clickNav('/history');34 await clickNav('/activity');35 await clickNav('/code');36 await page.screenshot({path:'output/playwright/ux/repo-desktop.png'});37 return {links:links.length,url:page.url()};38 });39 await run('search GET submission and history', async () => {40 await clickNav('← all repositories');41 await clickNav('/search');42 await page.locator('#search input[name=q]').fill('fixture');43 await page.locator('#search').getByRole('button', {name:'Search',exact:true}).click();44 await page.waitForFunction(() => new URL(location.href).searchParams.get('q') === 'fixture');45 const searched = page.url();46 await clickNav('/articles');47 await page.goBack();48 await page.waitForFunction(() => document.querySelector('#search input[name=q]')?.value === 'fixture');49 check(page.url() === searched, 'back URL incorrect');50 await page.goForward();51 await page.waitForFunction(() => document.querySelector('#content h1')?.textContent === 'Articles');52 check(await page.evaluate(() => window.__uxMarker) === 'preserved', 'filter or history reloaded document');53 });54 await run('repeated signed forms survive swaps', async () => {55 await clickNav('/manage');56 for (const name of ['/connect','/identity','/connect','/people','/connect']) {57 await clickNav(name);58 const current = await forms();59 check(current.length > 0 && current.every(x => x.form && x.output && !x.nested), 'detached or nested forms: '+JSON.stringify(current));60 }61 await page.waitForFunction(() => !!window.nostr?.signEvent);62 await page.waitForFunction(() => document.querySelector('connect-list #rows tbody tr[data-index]') !== null);63 check(await page.locator('connect-list select[name=template] option').count() > 1, 'connect list offers no catalog cards');64 await clickNav('/people');65 const list = page.locator('rpc-form[method=listmembers]');66 let submissions = 0;67 const count = request => { if (request.method()==='POST' && request.url().endsWith('/manage/rpc')) submissions++; };68 page.on('request', count);69 try {70 await list.evaluate(node => { node.form.requestSubmit(); node.form.requestSubmit(); });71 await page.waitForFunction(() => document.querySelector('rpc-form[method=listmembers] output')?.textContent === 'Done.');72 check(submissions===1, 'duplicate form submissions sent '+submissions+' signed requests');73 } finally { page.off('request',count); }74 await clickNav('/connect');75 await page.waitForFunction(() => document.querySelector('connect-list #rows tbody tr[data-index]') !== null);76 const result = await forms();77 await page.screenshot({path:'output/playwright/ux/connect-desktop.png'});78 return result;79 });80 await run('job status stream closes when leaving the page', async () => {81 await clickNav('/sync');82 await page.locator('#watch-jobs').click();83 await page.waitForFunction(() => {84 const requests = document.querySelector('#watch-jobs').__fixi?.requests;85 const config = requests && [...requests].find(cfg => cfg.sse);86 if (config) { window.__uxStream = config.sse; return true; }87 return false;88 });89 await clickNav('/connect');90 check(await page.evaluate(() => window.__uxStream.closed), 'job stream remained open after navigation');91 });92 await run('file viewers preserve one content section and media bounds', async () => {93 await page.goto(base+'/files');94 const links = await page.locator('#rows a').evaluateAll(nodes => nodes.map(n => n.href));95 check(links.length >= 4, 'fixture files missing');96 const visited=[];97 for (const href of links.slice(0,4)) {98 await page.goto(href);99 const info = await page.evaluate(() => ({sections:document.querySelectorAll('#content>section').length,children:[...document.querySelector('#content').children].map(e=>e.localName),overflow:document.documentElement.scrollWidth>innerWidth,raw:document.querySelector('#facts a')?.getAttribute('fx-action'),forms:[...document.querySelectorAll('rpc-form,signed-form')].every(e=>e.form?.isConnected)}));100 check(info.sections===1 && info.children.length===1, 'file viewer has content outside section: '+JSON.stringify(info));101 check(!info.raw, 'download intercepted by fixi');102 check(info.forms, 'file forms disconnected');103 visited.push(info);104 }105 await page.setViewportSize({width:360,height:800});106 const mobile = await page.evaluate(() => ({width:innerWidth,scroll:document.documentElement.scrollWidth,content:document.querySelector('#content').scrollWidth}));107 check(mobile.scroll<=mobile.width && mobile.content<=mobile.width, 'mobile file page overflows: '+JSON.stringify(mobile));108 await page.screenshot({path:'output/playwright/ux/file-mobile.png'});109 return {visited,mobile};110 });111 await run('mobile menu and theme survive navigation', async () => {112 await page.goto(base+'/');113 await page.setViewportSize({width:360,height:800});114 await page.locator('#menu').click();115 check(await page.locator('#nav-menu').evaluate(e=>e.open),'menu did not open');116 await clickNav('/articles');117 check(!await page.locator('#nav-menu').evaluate(e=>e.open),'navigation did not close menu');118 await page.locator('#menu').click();119 check(await page.locator('#nav-menu').evaluate(e=>e.open),'morphed menu did not open');120 await page.keyboard.press('Escape');121 check(!await page.locator('#nav-menu').evaluate(e=>e.open),'Escape did not close menu');122 await page.locator('#menu').click();123 const before = await page.locator('html').getAttribute('data-theme');124 await page.locator('#theme').click();125 check(await page.locator('html').getAttribute('data-theme') !== before,'morphed theme button inactive');126 await page.setViewportSize({width:1440,height:1000});127 check(await page.locator('#rail').getAttribute('aria-hidden')!=='true','desktop rail inaccessible');128 });129 await run('no JavaScript mobile content and navigation', async () => {130 const context=await page.context().browser().newContext({javaScriptEnabled:false,viewport:{width:360,height:800}});131 try {132 const noJS=await context.newPage();133 await noJS.goto(base+'/repos');134 check(await noJS.locator('#menu').isVisible(),'no-JS menu missing');135 check(!await noJS.locator('#rail').isVisible(),'no-JS navigation starts expanded');136 await noJS.locator('#menu').click();137 const deadline=Date.now()+2000;138 while ((await noJS.locator('#rail').boundingBox()).x<0 && Date.now()<deadline) await noJS.waitForTimeout(25);139 check((await noJS.locator('#rail').boundingBox()).x===0,'no-JS menu did not finish opening');140 check(await noJS.locator('#rail').isVisible(),'no-JS menu did not open');141 await noJS.locator('#rail').getByRole('link',{name:'/articles',exact:true}).click();142 check(noJS.url()===base+'/articles','no-JS rail link inaccessible');143 check(await noJS.locator('#content h1').first().textContent()==='Articles','no-JS articles missing');144 check(!await noJS.locator('#rail').isVisible(),'no-JS navigation did not reset closed');145 check((await noJS.locator('#content').boundingBox()).y<80,'no-JS navigation pushes content down');146 await noJS.screenshot({path:'output/playwright/ux/nojs-mobile.png'});147 } finally {await context.close();}148 });149 page.off('pageerror',onError);150 results.push({name:'uncaught browser errors',ok:errors.length===0,errors});151 return {passed:results.filter(x=>x.ok).length,failed:results.filter(x=>!x.ok).length,results};152}