| 1 | diff --git a/docs/testing.md b/docs/testing.md |
| 2 | index 2e884b0..70768e3 100644 |
| 3 | --- a/docs/testing.md |
| 4 | +++ b/docs/testing.md |
| 5 | @@ -45,6 +45,8 @@ python3 scripts/observe-git-benchmark.py --out artifacts/git/baseline \ |
| 6 | |
| 7 | The [conformance suite](../scripts/conformance/README.md) runs black-box NIP and GRASP cases against a running local daemon and preserves stdout, stderr, status, and environment artifacts. Start the daemon, then run `node scripts/conformance/run.mjs`. To try the Bunker URL sign-in without a phone, run `node scripts/qa/nip46-bunker.mjs` and paste the printed URL into the sign-in page. |
| 8 | |
| 9 | +The [UI checks](../scripts/qa/README.md) cover hosted viewers, malformed URLs, signer controls, browser history, failed requests and mobile navigation with and without JavaScript. |
| 10 | + |
| 11 | ## Real Git repositories |
| 12 | |
| 13 | The Git performance harness below records raw profiles and results under `artifacts/git-performance/`. |
| 14 | diff --git a/scripts/qa/README.md b/scripts/qa/README.md |
| 15 | new file mode 100644 |
| 16 | index 0000000..393e018 |
| 17 | --- /dev/null |
| 18 | +++ b/scripts/qa/README.md |
| 19 | @@ -0,0 +1,49 @@ |
| 20 | +# UI checks |
| 21 | + |
| 22 | +Use a disposable local relay with the public fixture identity. This identity is for testing only. |
| 23 | + |
| 24 | +```sh |
| 25 | +go build -o /tmp/tiny-ux ./cmd/tiny |
| 26 | +/tmp/tiny-ux tenant create --data-dir /tmp/tinyrelay-ux --name main \ |
| 27 | + --owner 5ac640e5df8f7945381c31f435288ba3f587fbd68efb27abaf941792f9c36369 |
| 28 | +/tmp/tiny-ux serve --data-dir /tmp/tinyrelay-ux --default-tenant main \ |
| 29 | + --listen 127.0.0.1:18447 --allow-private-relays |
| 30 | +``` |
| 31 | + |
| 32 | +In another terminal, seed notes, articles, files and a Git repository, then check valid and malformed URLs: |
| 33 | + |
| 34 | +```sh |
| 35 | +node scripts/qa/ux-fixture.mjs --data /tmp/tinyrelay-ux |
| 36 | +node scripts/qa/ux-http-fuzz.mjs |
| 37 | +go test ./internal/webui -run '^$' -fuzz FuzzInjectBasePreservesText -fuzztime=10s |
| 38 | +``` |
| 39 | + |
| 40 | +The HTTP report and browser screenshots are saved under `output/playwright/ux/`. |
| 41 | + |
| 42 | +For browser checks, open the fixture relay with Playwright CLI. The edge checks use a separate guest session. They cover failed requests, competing navigation, sign-in errors, new tabs and 60 seeded navigation actions. |
| 43 | + |
| 44 | +```sh |
| 45 | +npx --package @playwright/cli playwright-cli -s=tiny-ux open http://127.0.0.1:18447 |
| 46 | +npx --package @playwright/cli playwright-cli -s=tiny-ux run-code \ |
| 47 | + --filename scripts/qa/ux-browser-edges.js |
| 48 | +``` |
| 49 | + |
| 50 | +The full flow checks require signing in as the fixture owner. Start the local test signer, open `/signin` in that browser session and connect using the printed Bunker URL: |
| 51 | + |
| 52 | +```sh |
| 53 | +BUNKER_USER_SECRET=fc1d06a0fd5e622dcf448d0b3c2fccc891a5ecf74546a7675460d92441fecfdd \ |
| 54 | + node scripts/qa/nip46-bunker.mjs |
| 55 | +``` |
| 56 | + |
| 57 | +Then run the viewer, filter, history, signed form, mobile and no-JavaScript checks: |
| 58 | + |
| 59 | +```sh |
| 60 | +npx --package @playwright/cli playwright-cli -s=tiny-ux run-code \ |
| 61 | + --filename scripts/qa/ux-browser-flow.js |
| 62 | +npx --package @playwright/cli playwright-cli -s=tiny-ux run-code \ |
| 63 | + --filename scripts/qa/ux-browser-menu.js |
| 64 | +``` |
| 65 | + |
| 66 | +The menu checks cover public pages, hosted viewers and management pages with JavaScript enabled and disabled. They verify that mobile navigation starts closed, opens with a click or keyboard input, and leaves content in place. Desktop navigation remains visible. |
| 67 | + |
| 68 | +Each browser check returns its results and failure count. A real phone signer is still needed to verify the operating system handoff and approval screens. |
| 69 | diff --git a/scripts/qa/ux-browser-edges.js b/scripts/qa/ux-browser-edges.js |
| 70 | new file mode 100644 |
| 71 | index 0000000..2b3dd2c |
| 72 | --- /dev/null |
| 73 | +++ b/scripts/qa/ux-browser-edges.js |
| 74 | @@ -0,0 +1,74 @@ |
| 75 | +// Run with playwright-cli run-code --filename after opening the disposable UX relay. |
| 76 | +async page => { |
| 77 | + const base = await page.evaluate(()=>location.origin); |
| 78 | + const context = await page.context().browser().newContext({serviceWorkers:'block',viewport:{width:1280,height:900}}); |
| 79 | + const probe=await context.newPage(), results=[], errors=[]; |
| 80 | + probe.on('pageerror',error=>errors.push(error.message)); |
| 81 | + const check=(ok,message)=>{if(!ok)throw Error(message);}; |
| 82 | + const run=async(name,action)=>{try{results.push({name,ok:true,detail:await action()});}catch(error){results.push({name,ok:false,error:error.message});}}; |
| 83 | + try { |
| 84 | + await probe.goto(base+'/'); |
| 85 | + await probe.evaluate(()=>window.__uxMarker='same'); |
| 86 | + await run('network and HTTP failures leave the current page usable',async()=>{ |
| 87 | + await probe.route('**/articles',route=>route.fulfill({status:503,body:'Temporary failure'})); |
| 88 | + await probe.locator('#rail').getByRole('link',{name:'/articles',exact:true}).click(); |
| 89 | + await probe.waitForFunction(()=>document.querySelector('#navigation-status')?.textContent.includes('Unable')); |
| 90 | + check(probe.url()===base+'/','HTTP error changed the URL'); |
| 91 | + await probe.unroute('**/articles'); |
| 92 | + await probe.route('**/articles',route=>route.abort()); |
| 93 | + await probe.locator('#rail').getByRole('link',{name:'/articles',exact:true}).click(); |
| 94 | + await probe.waitForFunction(()=>document.querySelector('#navigation-status')?.textContent.includes('Unable')); |
| 95 | + check(await probe.locator('#content').getAttribute('aria-busy')!=='true','network error leaves busy state'); |
| 96 | + await probe.unroute('**/articles'); |
| 97 | + }); |
| 98 | + await run('slow earlier navigation cannot overwrite the latest click',async()=>{ |
| 99 | + let finish; |
| 100 | + const gate=new Promise(resolve=>{finish=resolve;}); |
| 101 | + await probe.route('**/articles',async route=>{await gate;await route.continue().catch(()=>{});}); |
| 102 | + await probe.locator('#rail').getByRole('link',{name:'/articles',exact:true}).click(); |
| 103 | + await probe.locator('#rail').getByRole('link',{name:'/repos',exact:true}).click(); |
| 104 | + await probe.waitForURL(base+'/repos'); |
| 105 | + finish(); |
| 106 | + await probe.unroute('**/articles'); |
| 107 | + check(await probe.locator('#content h1').textContent()==='Repositories','stale response replaced content'); |
| 108 | + }); |
| 109 | + await run('sign-in errors use current status after a shell swap',async()=>{ |
| 110 | + await probe.locator('#who').getByRole('link',{name:'Sign in',exact:true}).click(); |
| 111 | + await probe.waitForURL(base+'/signin'); |
| 112 | + await probe.locator('#session-login').click(); |
| 113 | + await probe.waitForFunction(()=>document.querySelector('#signer-status')?.textContent.includes('Connect a Nostr signer first')); |
| 114 | + await probe.locator('#bunker-url').fill('https://example.invalid'); |
| 115 | + await probe.locator('#bunker button').click(); |
| 116 | + await probe.waitForFunction(()=>document.querySelector('#signer-status')?.textContent.includes('valid bunker URL')); |
| 117 | + check(await probe.evaluate(()=>window.__uxMarker)==='same','sign-in navigation reloaded'); |
| 118 | + }); |
| 119 | + await run('modified clicks retain native new-tab behavior',async()=>{ |
| 120 | + const newTab=context.waitForEvent('page'); |
| 121 | + await probe.locator('#rail').getByRole('link',{name:'/repos',exact:true}).click({modifiers:['ControlOrMeta']}); |
| 122 | + const opened=await newTab; |
| 123 | + await opened.waitForLoadState(); |
| 124 | + check(probe.url()===base+'/signin','modified click navigated the current page'); |
| 125 | + check(opened.url()===base+'/repos','modified click opened wrong URL'); |
| 126 | + await opened.close(); |
| 127 | + }); |
| 128 | + await run('seeded sequence of 60 read-only navigation actions',async()=>{ |
| 129 | + let seed=0x74696e79; |
| 130 | + const routes=['/home','/search','/repos','/files','/sites','/articles','/inbox','/outbox']; |
| 131 | + for(let i=0;i<60;i++){ |
| 132 | + seed=(Math.imul(seed,1664525)+1013904223)>>>0; |
| 133 | + const name=routes[(seed>>>16)%routes.length], path=name==='/home'?'/':name; |
| 134 | + await probe.locator('#rail').getByRole('link',{name,exact:true}).click(); |
| 135 | + await probe.waitForURL(base+path); |
| 136 | + await probe.waitForFunction(()=>document.querySelector('#content')?.getAttribute('aria-busy')!=='true'); |
| 137 | + const state=await probe.evaluate(()=>({marker:window.__uxMarker,ids:[...document.querySelectorAll('[id]')].map(e=>e.id),classes:document.querySelectorAll('[class]').length,sections:document.querySelectorAll('#content>section').length})); |
| 138 | + check(state.marker==='same','document reloaded at '+i); |
| 139 | + check(state.classes===0,'class attributes introduced at '+i); |
| 140 | + check(new Set(state.ids).size===state.ids.length,'duplicate DOM IDs at '+i); |
| 141 | + check(state.sections===1,'content section missing at '+i); |
| 142 | + } |
| 143 | + return {seed:'0x74696e79',actions:60}; |
| 144 | + }); |
| 145 | + } finally { await context.close(); } |
| 146 | + results.push({name:'uncaught browser errors',ok:errors.length===0,errors}); |
| 147 | + return {passed:results.filter(r=>r.ok).length,failed:results.filter(r=>!r.ok).length,results}; |
| 148 | +} |
| 149 | diff --git a/scripts/qa/ux-browser-flow.js b/scripts/qa/ux-browser-flow.js |
| 150 | new file mode 100644 |
| 151 | index 0000000..5d0f116 |
| 152 | --- /dev/null |
| 153 | +++ b/scripts/qa/ux-browser-flow.js |
| 154 | @@ -0,0 +1,156 @@ |
| 155 | +// Run with playwright-cli run-code --filename after opening the disposable UX relay. |
| 156 | +async page => { |
| 157 | + const base = await page.evaluate(() => location.origin); |
| 158 | + const results = [], errors = []; |
| 159 | + const onError = error => errors.push(error.message); |
| 160 | + page.on('pageerror', onError); |
| 161 | + const check = (ok, message) => { if (!ok) throw Error(message); }; |
| 162 | + const run = async (name, action) => { |
| 163 | + try { const detail = await action(); results.push({name, ok:true, detail}); } |
| 164 | + catch (error) { results.push({name, ok:false, error:error.message}); } |
| 165 | + }; |
| 166 | + const clickNav = async name => { |
| 167 | + const link = page.locator('#rail').getByRole('link', {name, exact:true}); |
| 168 | + const href = await link.getAttribute('href'); |
| 169 | + await link.click(); |
| 170 | + await page.waitForURL(href.startsWith('/') ? base+href : href); |
| 171 | + await page.waitForFunction(() => document.querySelector('#content')?.getAttribute('aria-busy') !== 'true'); |
| 172 | + }; |
| 173 | + 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')}))); |
| 174 | + await page.setViewportSize({width:1440,height:1000}); |
| 175 | + await page.goto(base + '/repos'); |
| 176 | + await page.evaluate(() => { window.__uxMarker = 'preserved'; }); |
| 177 | + await run('repository navigation preserves document and ref filters', async () => { |
| 178 | + await page.getByRole('link', {name:'ux-edge-repo',exact:true}).click(); |
| 179 | + await page.waitForURL('**/repo?**'); |
| 180 | + await clickNav('/code'); |
| 181 | + await page.locator('#ref-select').selectOption('refs/heads/feature/café'); |
| 182 | + await page.locator('#panel form').getByRole('button', {name:'go',exact:true}).click(); |
| 183 | + await page.waitForFunction(() => new URL(location.href).searchParams.get('ref') === 'refs/heads/feature/café'); |
| 184 | + check(await page.evaluate(() => window.__uxMarker) === 'preserved', 'navigation reloaded the document'); |
| 185 | + const links = await page.locator('#tree a').evaluateAll(nodes => nodes.map(n => ({name:n.textContent,href:n.href}))); |
| 186 | + check(links.length > 0, 'tree links missing'); |
| 187 | + await clickNav('/history'); |
| 188 | + await clickNav('/activity'); |
| 189 | + await clickNav('/code'); |
| 190 | + await page.screenshot({path:'output/playwright/ux/repo-desktop.png'}); |
| 191 | + return {links:links.length,url:page.url()}; |
| 192 | + }); |
| 193 | + await run('search GET submission and history', async () => { |
| 194 | + await clickNav('all repositories'); |
| 195 | + await clickNav('/search'); |
| 196 | + await page.locator('#search input[name=q]').fill('fixture'); |
| 197 | + await page.locator('#search').getByRole('button', {name:'Search',exact:true}).click(); |
| 198 | + await page.waitForFunction(() => new URL(location.href).searchParams.get('q') === 'fixture'); |
| 199 | + const searched = page.url(); |
| 200 | + await clickNav('/articles'); |
| 201 | + await page.goBack(); |
| 202 | + await page.waitForFunction(() => document.querySelector('#search input[name=q]')?.value === 'fixture'); |
| 203 | + check(page.url() === searched, 'back URL incorrect'); |
| 204 | + await page.goForward(); |
| 205 | + await page.waitForFunction(() => document.querySelector('#content h1')?.textContent === 'Articles'); |
| 206 | + check(await page.evaluate(() => window.__uxMarker) === 'preserved', 'filter or history reloaded document'); |
| 207 | + }); |
| 208 | + await run('repeated signed forms survive swaps', async () => { |
| 209 | + await clickNav('/manage'); |
| 210 | + for (const name of ['/connect','/identity','/connect','/people','/connect']) { |
| 211 | + await clickNav(name); |
| 212 | + const current = await forms(); |
| 213 | + check(current.length > 0 && current.every(x => x.form && x.output && !x.nested), 'detached or nested forms: '+JSON.stringify(current)); |
| 214 | + } |
| 215 | + await page.waitForFunction(() => !!window.nostr?.signEvent); |
| 216 | + const list = page.locator('rpc-form[method=listconnections]'); |
| 217 | + let submissions = 0; |
| 218 | + const count = request => { if (request.method()==='POST' && request.url().endsWith('/manage/rpc')) submissions++; }; |
| 219 | + page.on('request', count); |
| 220 | + try { |
| 221 | + await list.evaluate(node => { node.form.requestSubmit(); node.form.requestSubmit(); }); |
| 222 | + await page.waitForFunction(() => document.querySelector('rpc-form[method=listconnections] output')?.textContent === 'Done.'); |
| 223 | + check(submissions===1, 'duplicate form submissions sent '+submissions+' signed requests'); |
| 224 | + } finally { page.off('request',count); } |
| 225 | + await page.getByRole('button', {name:'Preview connections',exact:true}).click(); |
| 226 | + await page.waitForFunction(() => document.querySelector('#connections-preview thead') !== null); |
| 227 | + const result = await forms(); |
| 228 | + await page.screenshot({path:'output/playwright/ux/connect-desktop.png'}); |
| 229 | + return result; |
| 230 | + }); |
| 231 | + await run('refused previews show an error without destroying the table', async () => { |
| 232 | + await page.evaluate(async () => { await fetch(window.tiny.localPath('/session/logout'), {method:'POST'}); }); |
| 233 | + try { |
| 234 | + await page.getByRole('button', {name:'Preview connections',exact:true}).click(); |
| 235 | + await page.waitForFunction(() => document.querySelector('#navigation-status')?.textContent.includes('Sign in again')); |
| 236 | + check(await page.locator('#connections-preview').count()===1, 'refused preview removed table'); |
| 237 | + } finally { await page.evaluate(async () => { await window.tiny.signedFetch('/session','POST',''); }); } |
| 238 | + }); |
| 239 | + await run('job status stream closes when leaving the page', async () => { |
| 240 | + await clickNav('/sync'); |
| 241 | + await page.locator('#watch-jobs').click(); |
| 242 | + await page.waitForFunction(() => { |
| 243 | + const requests = document.querySelector('#watch-jobs').__fixi?.requests; |
| 244 | + const config = requests && [...requests].find(cfg => cfg.sse); |
| 245 | + if (config) { window.__uxStream = config.sse; return true; } |
| 246 | + return false; |
| 247 | + }); |
| 248 | + await clickNav('/connect'); |
| 249 | + check(await page.evaluate(() => window.__uxStream.closed), 'job stream remained open after navigation'); |
| 250 | + }); |
| 251 | + await run('file viewers preserve one content section and media bounds', async () => { |
| 252 | + await page.goto(base+'/files'); |
| 253 | + const links = await page.locator('#rows a').evaluateAll(nodes => nodes.map(n => n.href)); |
| 254 | + check(links.length >= 4, 'fixture files missing'); |
| 255 | + const visited=[]; |
| 256 | + for (const href of links.slice(0,4)) { |
| 257 | + await page.goto(href); |
| 258 | + 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)})); |
| 259 | + check(info.sections===1 && info.children.length===1, 'file viewer has content outside section: '+JSON.stringify(info)); |
| 260 | + check(!info.raw, 'download intercepted by fixi'); |
| 261 | + check(info.forms, 'file forms disconnected'); |
| 262 | + visited.push(info); |
| 263 | + } |
| 264 | + await page.setViewportSize({width:360,height:800}); |
| 265 | + const mobile = await page.evaluate(() => ({width:innerWidth,scroll:document.documentElement.scrollWidth,content:document.querySelector('#content').scrollWidth})); |
| 266 | + check(mobile.scroll<=mobile.width && mobile.content<=mobile.width, 'mobile file page overflows: '+JSON.stringify(mobile)); |
| 267 | + await page.screenshot({path:'output/playwright/ux/file-mobile.png'}); |
| 268 | + return {visited,mobile}; |
| 269 | + }); |
| 270 | + await run('mobile menu and theme survive navigation', async () => { |
| 271 | + await page.goto(base+'/'); |
| 272 | + await page.setViewportSize({width:360,height:800}); |
| 273 | + await page.locator('#menu').click(); |
| 274 | + check(await page.locator('#nav-menu').evaluate(e=>e.open),'menu did not open'); |
| 275 | + await clickNav('/articles'); |
| 276 | + check(!await page.locator('#nav-menu').evaluate(e=>e.open),'navigation did not close menu'); |
| 277 | + await page.locator('#menu').click(); |
| 278 | + check(await page.locator('#nav-menu').evaluate(e=>e.open),'morphed menu did not open'); |
| 279 | + await page.keyboard.press('Escape'); |
| 280 | + check(!await page.locator('#nav-menu').evaluate(e=>e.open),'Escape did not close menu'); |
| 281 | + const before = await page.locator('html').getAttribute('data-theme'); |
| 282 | + await page.locator('#theme').click(); |
| 283 | + check(await page.locator('html').getAttribute('data-theme') !== before,'morphed theme button inactive'); |
| 284 | + await page.setViewportSize({width:1440,height:1000}); |
| 285 | + check(await page.locator('#rail').getAttribute('aria-hidden')!=='true','desktop rail inaccessible'); |
| 286 | + }); |
| 287 | + await run('no JavaScript mobile content and navigation', async () => { |
| 288 | + const context=await page.context().browser().newContext({javaScriptEnabled:false,viewport:{width:360,height:800}}); |
| 289 | + try { |
| 290 | + const noJS=await context.newPage(); |
| 291 | + await noJS.goto(base+'/repos'); |
| 292 | + check(await noJS.locator('#menu').isVisible(),'no-JS menu missing'); |
| 293 | + check(!await noJS.locator('#rail').isVisible(),'no-JS navigation starts expanded'); |
| 294 | + await noJS.locator('#menu').click(); |
| 295 | + const deadline=Date.now()+2000; |
| 296 | + while ((await noJS.locator('#rail').boundingBox()).x<0 && Date.now()<deadline) await noJS.waitForTimeout(25); |
| 297 | + check((await noJS.locator('#rail').boundingBox()).x===0,'no-JS menu did not finish opening'); |
| 298 | + check(await noJS.locator('#rail').isVisible(),'no-JS menu did not open'); |
| 299 | + await noJS.locator('#rail').getByRole('link',{name:'/articles',exact:true}).click(); |
| 300 | + check(noJS.url()===base+'/articles','no-JS rail link inaccessible'); |
| 301 | + check(await noJS.locator('#content h1').first().textContent()==='Articles','no-JS articles missing'); |
| 302 | + check(!await noJS.locator('#rail').isVisible(),'no-JS navigation did not reset closed'); |
| 303 | + check((await noJS.locator('#content').boundingBox()).y<80,'no-JS navigation pushes content down'); |
| 304 | + await noJS.screenshot({path:'output/playwright/ux/nojs-mobile.png'}); |
| 305 | + } finally {await context.close();} |
| 306 | + }); |
| 307 | + page.off('pageerror',onError); |
| 308 | + results.push({name:'uncaught browser errors',ok:errors.length===0,errors}); |
| 309 | + return {passed:results.filter(x=>x.ok).length,failed:results.filter(x=>!x.ok).length,results}; |
| 310 | +} |
| 311 | diff --git a/scripts/qa/ux-browser-menu.js b/scripts/qa/ux-browser-menu.js |
| 312 | new file mode 100644 |
| 313 | index 0000000..8509e44 |
| 314 | --- /dev/null |
| 315 | +++ b/scripts/qa/ux-browser-menu.js |
| 316 | @@ -0,0 +1,96 @@ |
| 317 | +// Run after signing in to the disposable UX relay as its fixture owner. |
| 318 | +async page => { |
| 319 | + const base = await page.evaluate(() => location.origin); |
| 320 | + const storageState = await page.context().storageState(); |
| 321 | + const results = [], errors = []; |
| 322 | + const check = (ok, message) => { if (!ok) throw Error(message); }; |
| 323 | + const run = async (name, action) => { |
| 324 | + try { results.push({name, ok:true, detail:await action()}); } |
| 325 | + catch (error) { results.push({name, ok:false, error:error.message}); } |
| 326 | + }; |
| 327 | + await page.goto(base+'/manage/connect'); |
| 328 | + check(await page.locator('#session-logout').count() === 1, 'Sign in as the fixture owner before running menu checks.'); |
| 329 | + const routes = ['/', '/search', '/repos', '/files', '/sites', '/articles', '/inbox', '/outbox', '/signin', '/terms', '/tools', '/private', '/chat', '/media', '/marmot', '/grasp']; |
| 330 | + routes.push(...await page.locator('#rail nav a').evaluateAll(nodes => nodes.map(e=>e.getAttribute('href')))); |
| 331 | + await page.goto(base+'/repos'); |
| 332 | + const repo = await page.getByRole('link', {name:'ux-edge-repo', exact:true}).getAttribute('href'); |
| 333 | + await page.goto(base+repo); |
| 334 | + routes.push(...await page.locator('#rail nav a').evaluateAll(nodes => nodes.map(e=>e.getAttribute('href')))); |
| 335 | + await page.goto(base+'/files'); |
| 336 | + routes.push(...await page.locator('#rows a').evaluateAll(nodes => nodes.map(e=>e.getAttribute('href')))); |
| 337 | + await page.goto(base+'/articles'); |
| 338 | + routes.push(await page.locator('#content a[href^="/e/"]').first().getAttribute('href')); |
| 339 | + await page.goto(base+'/'); |
| 340 | + routes.push(await page.locator('#content a[href^="/e/"]').last().getAttribute('href')); |
| 341 | + routes.push('/a/ux-long-article', '/r/main/articles', '/r/main/manage/connect'); |
| 342 | + for (const javaScriptEnabled of [true, false]) { |
| 343 | + const context = await page.context().browser().newContext({javaScriptEnabled, storageState, viewport:{width:360,height:800}}); |
| 344 | + try { |
| 345 | + const probe = await context.newPage(); |
| 346 | + probe.on('pageerror', error=>errors.push(error.message)); |
| 347 | + const waitForMenu = async () => { |
| 348 | + // Poll outside the page because disabled JavaScript also disables browser timers. |
| 349 | + const deadline = Date.now()+2000; |
| 350 | + while ((await probe.locator('#rail').boundingBox()).x<0 && Date.now()<deadline) await probe.waitForTimeout(25); |
| 351 | + check((await probe.locator('#rail').boundingBox()).x===0,'menu did not finish opening'); |
| 352 | + }; |
| 353 | + for (const path of [...new Set(routes)]) await run(`${javaScriptEnabled?'JS':'no JS'} ${path}`, async () => { |
| 354 | + await probe.setViewportSize({width:360,height:800}); |
| 355 | + const response = await probe.goto(base+path); |
| 356 | + check(response.ok(), 'HTTP '+response.status()); |
| 357 | + check(await probe.locator('#menu').isVisible(), 'menu control missing'); |
| 358 | + check(!await probe.locator('#rail').isVisible(), 'navigation starts expanded'); |
| 359 | + check(await probe.locator('#rail nav').count()===1, 'navigation duplicated'); |
| 360 | + const before = await probe.locator('#content').boundingBox(); |
| 361 | + check(before.y<80, 'navigation pushes content down'); |
| 362 | + await probe.locator('#menu').click(); |
| 363 | + await waitForMenu(); |
| 364 | + check(await probe.locator('#rail').isVisible(), 'menu did not open'); |
| 365 | + check(await probe.locator('#rail nav a').first().isVisible(), 'menu links hidden'); |
| 366 | + check((await probe.locator('#content').boundingBox()).y===before.y, 'opening menu moves content'); |
| 367 | + await probe.locator('#menu').click(); |
| 368 | + check(!await probe.locator('#rail').isVisible(), 'menu did not close'); |
| 369 | + await probe.locator('#menu').focus(); |
| 370 | + await probe.keyboard.press('Enter'); |
| 371 | + await waitForMenu(); |
| 372 | + check(await probe.locator('#rail').isVisible(), 'Enter did not open menu'); |
| 373 | + await probe.keyboard.press('Space'); |
| 374 | + check(!await probe.locator('#rail').isVisible(), 'Space did not close menu'); |
| 375 | + await probe.setViewportSize({width:1440,height:1000}); |
| 376 | + check(await probe.locator('#rail').isVisible(), 'desktop sidebar missing'); |
| 377 | + check(!await probe.locator('#menu').isVisible(), 'mobile menu appears on desktop'); |
| 378 | + return {title:await probe.title(),contentTop:before.y}; |
| 379 | + }); |
| 380 | + await run(`${javaScriptEnabled?'JS':'no JS'} navigation resets the menu`, async () => { |
| 381 | + await probe.setViewportSize({width:360,height:800}); |
| 382 | + await probe.goto(base+'/repos'); |
| 383 | + if(javaScriptEnabled) await probe.evaluate(()=>{window.__menuDocument='preserved';}); |
| 384 | + for(const label of ['/articles','/files','/repos']) { |
| 385 | + await probe.locator('#menu').click(); |
| 386 | + await waitForMenu(); |
| 387 | + await probe.locator('#rail').getByRole('link',{name:label,exact:true}).click(); |
| 388 | + await probe.waitForURL(base+label); |
| 389 | + await probe.waitForFunction(()=>document.querySelector('#content').getAttribute('aria-busy')!=='true'); |
| 390 | + check(!await probe.locator('#rail').isVisible(),'navigation left menu open'); |
| 391 | + } |
| 392 | + if(javaScriptEnabled) { |
| 393 | + check(await probe.evaluate(()=>window.__menuDocument)==='preserved','navigation reloaded document'); |
| 394 | + await probe.locator('#menu').click(); |
| 395 | + await probe.keyboard.press('Escape'); |
| 396 | + check(!await probe.locator('#rail').isVisible(),'Escape did not close menu'); |
| 397 | + check(await probe.locator('#menu').evaluate(e=>e===document.activeElement),'Escape did not restore menu focus'); |
| 398 | + } |
| 399 | + }); |
| 400 | + if(!javaScriptEnabled) { |
| 401 | + await context.clearCookies(); |
| 402 | + await probe.goto(base+'/articles'); |
| 403 | + await probe.screenshot({path:'output/playwright/ux/nojs-mobile.png'}); |
| 404 | + await probe.locator('#menu').click(); |
| 405 | + await waitForMenu(); |
| 406 | + await probe.screenshot({path:'output/playwright/ux/nojs-mobile-menu-open.png'}); |
| 407 | + } |
| 408 | + } finally {await context.close();} |
| 409 | + } |
| 410 | + results.push({name:'uncaught browser errors',ok:errors.length===0,errors}); |
| 411 | + return {passed:results.filter(x=>x.ok).length,failed:results.filter(x=>!x.ok).length,results}; |
| 412 | +} |
| 413 | diff --git a/scripts/qa/ux-fixture.mjs b/scripts/qa/ux-fixture.mjs |
| 414 | new file mode 100644 |
| 415 | index 0000000..1709705 |
| 416 | --- /dev/null |
| 417 | +++ b/scripts/qa/ux-fixture.mjs |
| 418 | @@ -0,0 +1,128 @@ |
| 419 | +#!/usr/bin/env node |
| 420 | +// Seed a deterministic, public corpus for browser UX fuzzing. |
| 421 | +// The script is intentionally self-contained so it can be run against a |
| 422 | +// disposable daemon without changing a checkout or a user's home directory. |
| 423 | +import { execFile } from "node:child_process"; |
| 424 | +import { mkdir, writeFile, readFile } from "node:fs/promises"; |
| 425 | +import { join, resolve } from "node:path"; |
| 426 | +import { promisify } from "node:util"; |
| 427 | +import { createHash } from "node:crypto"; |
| 428 | +import WebSocket from "ws"; |
| 429 | +import { finalizeEvent, getPublicKey } from "nostr-tools/pure"; |
| 430 | +import { getToken } from "nostr-tools/nip98"; |
| 431 | +import { npubEncode } from "nostr-tools/nip19"; |
| 432 | + |
| 433 | +const exec = promisify(execFile); |
| 434 | +const args = process.argv.slice(2); |
| 435 | +const option = (key, fallback) => args.includes(key) ? args[args.indexOf(key) + 1] : fallback; |
| 436 | +const http = (option("--relay", "http://127.0.0.1:18447")).replace(/^ws/, "http").replace(/\/$/, ""); |
| 437 | +const wsURL = http.replace(/^http/, "ws"); |
| 438 | +const dataDir = resolve(option("--data", "/tmp/tinyrelay-ux-20260908")); |
| 439 | +const outDir = resolve(option("--out", "output/playwright/ux")); |
| 440 | +const secret = Uint8Array.from(Buffer.from("fc1d06a0fd5e622dcf448d0b3c2fccc891a5ecf74546a7675460d92441fecfdd", "hex")); |
| 441 | +const owner = getPublicKey(secret); |
| 442 | +const now = () => Math.floor(Date.now() / 1000); |
| 443 | +const json = value => JSON.stringify(value); |
| 444 | + |
| 445 | +// This is printed before network or filesystem work so browser runners can |
| 446 | +// discover the fixture identity even if a later seed operation fails. |
| 447 | +console.log(json({ fixture: "tinyrelay-ux", owner, npub: npubEncode(owner), relay: http, dataDir, out: join(outDir, "fixture.json") })); |
| 448 | + |
| 449 | +async function rpc(method, params = []) { |
| 450 | + const payload = { method, params }; |
| 451 | + const token = await getToken(http, "POST", event => finalizeEvent(event, secret), true, payload); |
| 452 | + const response = await fetch(http, { method: "POST", headers: { "content-type": "application/nostr+json+rpc", authorization: token }, body: json(payload) }); |
| 453 | + const result = await response.json(); |
| 454 | + if (!response.ok || result.error) throw Error(`${method}: ${json(result)}`); |
| 455 | + return result.result; |
| 456 | +} |
| 457 | + |
| 458 | +class Client { |
| 459 | + constructor(url) { |
| 460 | + this.ws = new WebSocket(url); this.waiters = new Map(); this.events = new Map(); |
| 461 | + this.open = new Promise((resolve, reject) => { this.ws.once("open", resolve); this.ws.once("error", reject); }); |
| 462 | + this.ws.on("message", raw => { let m; try { m = JSON.parse(raw); } catch { return; } |
| 463 | + if (m[0] === "AUTH") return; |
| 464 | + const waiter = this.waiters.get(m[1]); if (!waiter) return; |
| 465 | + if (m[0] === "OK") { |
| 466 | + const reason = String(m[3] || "publish rejected"); |
| 467 | + // Re-seeding an existing fixture is successful when the relay already |
| 468 | + // has this event, or has a newer replaceable event for the same d tag. |
| 469 | + waiter.done(m[2] || /^(duplicate|invalid: a newer version)/.test(reason) ? null : Error(reason), m); |
| 470 | + } |
| 471 | + else if (m[0] === "EOSE") waiter.done(null, waiter.items); |
| 472 | + else if (m[0] === "EVENT") waiter.items.push(m[2]); |
| 473 | + else if (m[0] === "CLOSED") waiter.done(Error(m[2])); |
| 474 | + }); |
| 475 | + } |
| 476 | + async publish(event) { await this.open; return new Promise((resolve, reject) => { |
| 477 | + const finish = (e, value) => { clearTimeout(timer); this.waiters.delete(event.id); e ? reject(e) : resolve(value); }; |
| 478 | + const timer = setTimeout(() => finish(Error("publish timeout")), 30000); |
| 479 | + this.waiters.set(event.id, { done: finish, items: [] }); this.ws.send(json(["EVENT", event])); |
| 480 | + }); } |
| 481 | + close() { this.ws.close(); } |
| 482 | +} |
| 483 | + |
| 484 | +function event(kind, content, tags = [], createdAt = now()) { return finalizeEvent({ kind, content, tags, created_at: createdAt }, secret); } |
| 485 | +async function upload(body, type, label) { |
| 486 | + const bytes = Buffer.isBuffer(body) ? body : Buffer.from(body); |
| 487 | + const hash = createHash("sha256").update(bytes).digest("hex"); |
| 488 | + const authEvent = finalizeEvent({ kind: 24242, created_at: now(), content: "upload", tags: [["t", "upload"], ["x", hash], ["expiration", String(now() + 300)]] }, secret); |
| 489 | + const token = "Nostr " + Buffer.from(json(authEvent)).toString("base64"); |
| 490 | + const response = await fetch(http + "/upload", { method: "PUT", headers: { authorization: token, "content-type": type }, body: bytes }); |
| 491 | + const result = await response.json().catch(() => ({})); |
| 492 | + if (!response.ok) throw Error(`upload ${label}: ${response.status} ${json(result)}`); |
| 493 | + return { label, sha256: hash, type, size: bytes.length, url: result.url || `${http}/${hash}`, response: result }; |
| 494 | +} |
| 495 | +async function git(cwd, ...command) { return (await exec("git", ["-C", cwd, ...command], { encoding: "utf8", maxBuffer: 4 * 1024 * 1024, env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_AUTHOR_NAME: "tinyrelay UX fixture", GIT_AUTHOR_EMAIL: "ux-fixture@example.invalid", GIT_COMMITTER_NAME: "tinyrelay UX fixture", GIT_COMMITTER_EMAIL: "ux-fixture@example.invalid" } })).stdout.trim(); } |
| 496 | + |
| 497 | +await mkdir(dataDir, { recursive: true }); await mkdir(outDir, { recursive: true }); |
| 498 | +const policy = await rpc("getpolicy"); |
| 499 | +policy.features = { ...policy.features, files: true, sites: { ...(policy.features?.sites || {}), enabled: true, mirror: true }, grasp: true, grasp02: true, grasp03: true, grasp05: true, grasp06: true }; |
| 500 | +await rpc("setpolicy", [policy]); |
| 501 | +const relay = new Client(wsURL); await relay.open; |
| 502 | +// Keep event IDs stable across reruns so the relay's normal event dedupe makes |
| 503 | +// this safe to invoke repeatedly against the same disposable daemon. |
| 504 | +const created = Number(option("--created-at", "1788876000")); |
| 505 | +if (!Number.isSafeInteger(created) || created < 1) throw Error("--created-at must be a positive integer"); |
| 506 | +const notes = [ |
| 507 | + event(1, "UX fixture note: <script>alert('xss')</script> & \"quoted\" — emoji 🧪\n\nhttps://example.com/a?x=1&y=2\n\n" + "long text ".repeat(120), [["t", "ux-fixture"], ["d", "note-edge"]], created), |
| 508 | + event(1, "Short note with [markdown](https://example.com) and a bare https://example.org/path#fragment", [["t", "ux-fixture"], ["t", "links"]], created + 1), |
| 509 | +]; |
| 510 | +const articles = [ |
| 511 | + event(30023, "# Article heading\n\nBody containing <b>escaped markup</b>, `code`, and a very long paragraph.\n\n" + "article words ".repeat(180), [["d", "ux-long-article"], ["title", "A long article & edge cases"], ["summary", "Summary with <angle> & ampersand"], ["published_at", String(created)]], created + 2), |
| 512 | + event(30023, "Second revision with the same identifier", [["d", "ux-revision"], ["title", "Revision"]], created + 3), |
| 513 | +]; |
| 514 | +for (const e of [...notes, ...articles]) await relay.publish(e); |
| 515 | + |
| 516 | +const files = []; |
| 517 | +files.push(await upload("plain text with <escaped> & unicode café\n".repeat(20), "text/plain; charset=utf-8", "text")); |
| 518 | +files.push(await upload("# Markdown fixture\n\n[link](https://example.com)\n\n<script>ignored</script>", "text/markdown; charset=utf-8", "markdown")); |
| 519 | +files.push(await upload("<!doctype html><meta charset=utf-8><title>UX HTML</title><p>fixture & markup</p>", "text/html; charset=utf-8", "html")); |
| 520 | +files.push(await upload(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", "base64"), "image/png", "image")); |
| 521 | + |
| 522 | +const repo = join(dataDir, "repo"); await mkdir(repo, { recursive: true }); |
| 523 | +await git(repo, "init", "-b", "main"); |
| 524 | +const entries = new Map([ |
| 525 | + ["README UX fixture.md", "UX fixture repository\nPaths and refs contain awkward URL characters.\n"], |
| 526 | + ["nested dir/space # hash/percent%/question?.txt", "nested edge path\n"], |
| 527 | + ["nested dir/Unicode café/emoji 🧪.md", "unicode path content\n"], |
| 528 | + ["web/<escaped>.html", "<!doctype html><title>fixture</title><script>ignored</script>"], |
| 529 | +]); |
| 530 | +for (const [path, content] of entries) { const full = join(repo, path); await mkdir(join(full, ".."), { recursive: true }); await writeFile(full, content); } |
| 531 | +await git(repo, "add", "."); |
| 532 | +try { await git(repo, "commit", "-m", "UX fixture paths"); } catch (error) { if (!String(error.stdout).includes("nothing to commit")) throw error; } |
| 533 | +await git(repo, "tag", "-f", "release/1.0"); await git(repo, "branch", "-f", "feature/café"); |
| 534 | +// GRASP identifiers are intentionally conservative; awkward characters live |
| 535 | +// in the display name and tracked paths, where viewers must still URL-escape. |
| 536 | +const repoId = "ux-edge-repo"; const cloneURL = `${http}/${npubEncode(owner)}/${encodeURIComponent(repoId)}.git`; |
| 537 | +const refs = (await git(repo, "for-each-ref", "--format=%(refname) %(objectname)", "refs/heads", "refs/tags")).split("\n").filter(Boolean).map(line => line.split(" ")); |
| 538 | +const announcement = event(30617, "", [["d", repoId], ["name", "UX space/#/?/% café"], ["description", "Repository used by browser navigation fuzzing"], ["clone", cloneURL], ["relays", wsURL], ["maintainers", owner]]); |
| 539 | +await relay.publish(announcement); |
| 540 | +const state = event(30618, "", [["d", repoId], ["HEAD", "ref: refs/heads/main"], ...refs.map(([ref, oid]) => [ref, oid])]); |
| 541 | +await relay.publish(state); |
| 542 | +try { await git(repo, "remote", "set-url", "origin", cloneURL); } catch { await git(repo, "remote", "add", "origin", cloneURL); } |
| 543 | +await git(repo, "push", "origin", "--all"); await git(repo, "push", "origin", "--tags"); |
| 544 | +const manifest = { schema: 1, fixture: "tinyrelay-ux", relay: http, owner, npub: npubEncode(owner), dataDir, notes: notes.map(e => ({ id: e.id, kind: e.kind, tags: e.tags })), articles: articles.map(e => ({ id: e.id, tags: e.tags })), files, repository: { id: repoId, clone: cloneURL, path: repo, refs } }; |
| 545 | +await writeFile(join(outDir, "fixture.json"), json(manifest) + "\n"); |
| 546 | +relay.close(); console.log(json({ ok: true, manifest: join(outDir, "fixture.json"), notes: notes.length, articles: articles.length, files: files.length, repository: repoId })); |
| 547 | diff --git a/scripts/qa/ux-http-fuzz.mjs b/scripts/qa/ux-http-fuzz.mjs |
| 548 | new file mode 100644 |
| 549 | index 0000000..dd7c13e |
| 550 | --- /dev/null |
| 551 | +++ b/scripts/qa/ux-http-fuzz.mjs |
| 552 | @@ -0,0 +1,64 @@ |
| 553 | +#!/usr/bin/env node |
| 554 | +// Read-only HTTP fuzzing for the browser-facing hosted-content viewers. |
| 555 | +import { readFile, mkdir, writeFile } from "node:fs/promises"; |
| 556 | +import { dirname, resolve } from "node:path"; |
| 557 | + |
| 558 | +const args = process.argv.slice(2); |
| 559 | +const option = (key, fallback) => args.includes(key) ? args[args.indexOf(key) + 1] : fallback; |
| 560 | +const manifestPath = resolve(option("--manifest", "output/playwright/ux/fixture.json")); |
| 561 | +const manifest = JSON.parse(await readFile(manifestPath, "utf8")); |
| 562 | +const base = (option("--relay", manifest.relay)).replace(/\/$/, ""); |
| 563 | +const out = resolve(option("--out", "output/playwright/ux/http-fuzz.json")); |
| 564 | +const timeoutMs = Number(option("--timeout-ms", "5000")); |
| 565 | +const unusual = " café/路径 #hash ?query %percent & <script>alert(1)</script>\u0000"; |
| 566 | +const prefixes = ["", "/r/main"]; |
| 567 | +const cases = []; |
| 568 | +const add = (name, path, query = "") => { for (const prefix of prefixes) cases.push({ name: `${prefix || "root"} ${name}`, url: `${base}${prefix}${path}${query}` }); }; |
| 569 | + |
| 570 | +for (const q of ["", "?limit=-1&cursor=%00", `?q=${encodeURIComponent(unusual)}&limit=999999`, "?owner=%00&ref=refs%2Fheads%2Fmain&path=%2F..%2F"]) |
| 571 | + add("search", "/search" + q); |
| 572 | +for (const q of ["", `?owner=${encodeURIComponent(unusual)}&repo=${encodeURIComponent(unusual)}&ref=%00&path=${encodeURIComponent(unusual)}`, "?owner=%00&repo=%00&limit=NaN&cursor=%00"]) |
| 573 | + add("repo", "/repo" + q); |
| 574 | +for (const q of ["", `?hash=${encodeURIComponent(unusual)}&limit=-1&cursor=%00`, "?sha=%00&hash=not-a-sha"]) |
| 575 | + add("file", "/file" + q); |
| 576 | +for (const id of ["", "not-an-event", "0".repeat(64), unusual, manifest.notes[0].id]) add("event", "/e/" + encodeURIComponent(id)); |
| 577 | + |
| 578 | +const results = []; |
| 579 | +const duplicateIDs = html => [...html.matchAll(/\bid=["']([^"']+)["']/gi)].map(m => m[1]).filter((id, i, all) => all.indexOf(id) !== i); |
| 580 | +async function request(item) { |
| 581 | + const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); |
| 582 | + const started = performance.now(); |
| 583 | + try { |
| 584 | + const response = await fetch(item.url, { method: "GET", redirect: "manual", signal: controller.signal }); |
| 585 | + const body = await response.text(); |
| 586 | + const duplicate = duplicateIDs(body); |
| 587 | + const injected = /<script\s*>\s*alert\s*\(/i.test(body) || body.includes("<script>alert(1)</script>"); |
| 588 | + return { ...item, status: response.status, ms: Math.round(performance.now() - started), bytes: body.length, ok: response.status < 500 && !injected && duplicate.length === 0, checks: { no5xx: response.status < 500, noScriptInjection: !injected, noDuplicateIDs: duplicate.length === 0 }, ...(duplicate.length ? { duplicateIDs: duplicate } : {}) }; |
| 589 | + } catch (error) { return { ...item, status: 0, ms: Math.round(performance.now() - started), ok: false, error: error.name === "AbortError" ? "timeout" : error.message }; } |
| 590 | + finally { clearTimeout(timer); } |
| 591 | +} |
| 592 | +for (const item of cases) results.push(await request(item)); |
| 593 | + |
| 594 | +const valid = []; |
| 595 | +const validGet = async (name, path, expected) => { |
| 596 | + const item = { name: `valid ${name}`, url: base + path }; const row = await request(item); |
| 597 | + const body = row.status ? await (await fetch(item.url)).text() : ""; |
| 598 | + row.checks = { ...row.checks, expected: body.includes(expected), noClassAttributes: !/\bclass\s*=/i.test(body) }; |
| 599 | + row.ok = row.ok && row.checks.expected && row.checks.noClassAttributes; |
| 600 | + valid.push(row); |
| 601 | +}; |
| 602 | +const note = manifest.notes[0], article = manifest.articles[0], file = manifest.files.find(f => f.label === "markdown"); |
| 603 | +await validGet("note", `/e/${note.id}`, note.id); |
| 604 | +await validGet("article", "/a/ux-long-article", "A long article"); |
| 605 | +await validGet("articles", "/articles", "A long article"); |
| 606 | +await validGet("files", "/files", "Files"); |
| 607 | +await validGet("file", `/file?hash=${file.sha256}`, file.sha256); |
| 608 | +await validGet("repos", "/repos", manifest.repository.id); |
| 609 | +await validGet("repo home", `/repo?owner=${manifest.owner}&repo=${manifest.repository.id}`, manifest.repository.id); |
| 610 | +await validGet("repo tree", `/repo?owner=${manifest.owner}&repo=${manifest.repository.id}&view=tree&ref=refs%2Fheads%2Fmain`, "README UX fixture.md"); |
| 611 | +await validGet("repo file", `/repo?owner=${manifest.owner}&repo=${manifest.repository.id}&view=file&ref=refs%2Fheads%2Fmain&path=${encodeURIComponent("nested dir/Unicode café/emoji 🧪.md")}`, "unicode path content"); |
| 612 | + |
| 613 | +const report = { schema: 1, fixture: manifest.fixture, relay: base, generatedAt: new Date().toISOString(), cases: cases.length, fuzz: results.length, validCases: valid.length, passed: [...results, ...valid].filter(r => r.ok).length, failed: [...results, ...valid].filter(r => !r.ok).length, results, valid }; |
| 614 | +await mkdir(dirname(out), { recursive: true }); await writeFile(out, JSON.stringify(report, null, 2) + "\n"); |
| 615 | +console.log(JSON.stringify({ ok: report.failed === 0, cases: report.cases, valid: report.validCases, passed: report.passed, failed: report.failed, report: out })); |
| 616 | +process.exitCode = report.failed ? 1 : 0; |