A seeded fixture relay, read-only HTTP fuzzing of viewer URLs, and Playwright checks for failed requests, history, signed forms, and the phone menu with and without JavaScript.
- commit
- 2109b2c6b0c052bcad33d626f3b532fae09b9745
- parent
- 2500b72813d5a2192d95a9e0ab9bb81dbcaea5d8
- author
- Dami <dami.etoile@gmail.com>
| M | docs/ | 2 | + |
| A | scripts/ | 49 | +++++++++ |
| A | scripts/ | 74 | ++++++++++++++ |
| A | scripts/ | 156 | ++++++++++++++++++++++++++++++ |
| A | scripts/ | 96 | ++++++++++++++++++ |
| A | scripts/ | 128 | +++++++++++++++++++++++++ |
| A | scripts/ | 64 | ++++++++++++ |
@@ -45,6 +45,8 @@ python3 scripts/observe-git-benchmark.py --out artifacts/git/baseline \
4545 4646 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.4747 48+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.49+4850 ## Real Git repositories4951 5052 The Git performance harness below records raw profiles and results under `artifacts/git-performance/`.@@ -0,0 +1,49 @@
1+# UI checks2+3+Use a disposable local relay with the public fixture identity. This identity is for testing only.4+5+```sh6+go build -o /tmp/tiny-ux ./cmd/tiny7+/tmp/tiny-ux tenant create --data-dir /tmp/tinyrelay-ux --name main \8+ --owner 5ac640e5df8f7945381c31f435288ba3f587fbd68efb27abaf941792f9c363699+/tmp/tiny-ux serve --data-dir /tmp/tinyrelay-ux --default-tenant main \10+ --listen 127.0.0.1:18447 --allow-private-relays11+```12+13+In another terminal, seed notes, articles, files and a Git repository, then check valid and malformed URLs:14+15+```sh16+node scripts/qa/ux-fixture.mjs --data /tmp/tinyrelay-ux17+node scripts/qa/ux-http-fuzz.mjs18+go test ./internal/webui -run '^$' -fuzz FuzzInjectBasePreservesText -fuzztime=10s19+```20+21+The HTTP report and browser screenshots are saved under `output/playwright/ux/`.22+23+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.24+25+```sh26+npx --package @playwright/cli playwright-cli -s=tiny-ux open http://127.0.0.1:1844727+npx --package @playwright/cli playwright-cli -s=tiny-ux run-code \28+ --filename scripts/qa/ux-browser-edges.js29+```30+31+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:32+33+```sh34+BUNKER_USER_SECRET=fc1d06a0fd5e622dcf448d0b3c2fccc891a5ecf74546a7675460d92441fecfdd \35+ node scripts/qa/nip46-bunker.mjs36+```37+38+Then run the viewer, filter, history, signed form, mobile and no-JavaScript checks:39+40+```sh41+npx --package @playwright/cli playwright-cli -s=tiny-ux run-code \42+ --filename scripts/qa/ux-browser-flow.js43+npx --package @playwright/cli playwright-cli -s=tiny-ux run-code \44+ --filename scripts/qa/ux-browser-menu.js45+```46+47+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.48+49+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.@@ -0,0 +1,74 @@
1+// Run with playwright-cli run-code --filename after opening the disposable UX relay.2+async page => {3+ const base = await page.evaluate(()=>location.origin);4+ const context = await page.context().browser().newContext({serviceWorkers:'block',viewport:{width:1280,height:900}});5+ const probe=await context.newPage(), results=[], errors=[];6+ probe.on('pageerror',error=>errors.push(error.message));7+ const check=(ok,message)=>{if(!ok)throw Error(message);};8+ const run=async(name,action)=>{try{results.push({name,ok:true,detail:await action()});}catch(error){results.push({name,ok:false,error:error.message});}};9+ try {10+ await probe.goto(base+'/');11+ await probe.evaluate(()=>window.__uxMarker='same');12+ await run('network and HTTP failures leave the current page usable',async()=>{13+ await probe.route('**/articles',route=>route.fulfill({status:503,body:'Temporary failure'}));14+ await probe.locator('#rail').getByRole('link',{name:'/articles',exact:true}).click();15+ await probe.waitForFunction(()=>document.querySelector('#navigation-status')?.textContent.includes('Unable'));16+ check(probe.url()===base+'/','HTTP error changed the URL');17+ await probe.unroute('**/articles');18+ await probe.route('**/articles',route=>route.abort());19+ await probe.locator('#rail').getByRole('link',{name:'/articles',exact:true}).click();20+ await probe.waitForFunction(()=>document.querySelector('#navigation-status')?.textContent.includes('Unable'));21+ check(await probe.locator('#content').getAttribute('aria-busy')!=='true','network error leaves busy state');22+ await probe.unroute('**/articles');23+ });24+ await run('slow earlier navigation cannot overwrite the latest click',async()=>{25+ let finish;26+ const gate=new Promise(resolve=>{finish=resolve;});27+ await probe.route('**/articles',async route=>{await gate;await route.continue().catch(()=>{});});28+ await probe.locator('#rail').getByRole('link',{name:'/articles',exact:true}).click();29+ await probe.locator('#rail').getByRole('link',{name:'/repos',exact:true}).click();30+ await probe.waitForURL(base+'/repos');31+ finish();32+ await probe.unroute('**/articles');33+ check(await probe.locator('#content h1').textContent()==='Repositories','stale response replaced content');34+ });35+ await run('sign-in errors use current status after a shell swap',async()=>{36+ await probe.locator('#who').getByRole('link',{name:'Sign in',exact:true}).click();37+ await probe.waitForURL(base+'/signin');38+ await probe.locator('#session-login').click();39+ await probe.waitForFunction(()=>document.querySelector('#signer-status')?.textContent.includes('Connect a Nostr signer first'));40+ await probe.locator('#bunker-url').fill('https://example.invalid');41+ await probe.locator('#bunker button').click();42+ await probe.waitForFunction(()=>document.querySelector('#signer-status')?.textContent.includes('valid bunker URL'));43+ check(await probe.evaluate(()=>window.__uxMarker)==='same','sign-in navigation reloaded');44+ });45+ await run('modified clicks retain native new-tab behavior',async()=>{46+ const newTab=context.waitForEvent('page');47+ await probe.locator('#rail').getByRole('link',{name:'/repos',exact:true}).click({modifiers:['ControlOrMeta']});48+ const opened=await newTab;49+ await opened.waitForLoadState();50+ check(probe.url()===base+'/signin','modified click navigated the current page');51+ check(opened.url()===base+'/repos','modified click opened wrong URL');52+ await opened.close();53+ });54+ await run('seeded sequence of 60 read-only navigation actions',async()=>{55+ let seed=0x74696e79;56+ const routes=['/home','/search','/repos','/files','/sites','/articles','/inbox','/outbox'];57+ for(let i=0;i<60;i++){58+ seed=(Math.imul(seed,1664525)+1013904223)>>>0;59+ const name=routes[(seed>>>16)%routes.length], path=name==='/home'?'/':name;60+ await probe.locator('#rail').getByRole('link',{name,exact:true}).click();61+ await probe.waitForURL(base+path);62+ await probe.waitForFunction(()=>document.querySelector('#content')?.getAttribute('aria-busy')!=='true');63+ 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}));64+ check(state.marker==='same','document reloaded at '+i);65+ check(state.classes===0,'class attributes introduced at '+i);66+ check(new Set(state.ids).size===state.ids.length,'duplicate DOM IDs at '+i);67+ check(state.sections===1,'content section missing at '+i);68+ }69+ return {seed:'0x74696e79',actions:60};70+ });71+ } finally { await context.close(); }72+ results.push({name:'uncaught browser errors',ok:errors.length===0,errors});73+ return {passed:results.filter(r=>r.ok).length,failed:results.filter(r=>!r.ok).length,results};74+}@@ -0,0 +1,156 @@
1+// Run with playwright-cli run-code --filename after opening the disposable UX relay.2+async 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+ const list = page.locator('rpc-form[method=listconnections]');63+ let submissions = 0;64+ const count = request => { if (request.method()==='POST' && request.url().endsWith('/manage/rpc')) submissions++; };65+ page.on('request', count);66+ try {67+ await list.evaluate(node => { node.form.requestSubmit(); node.form.requestSubmit(); });68+ await page.waitForFunction(() => document.querySelector('rpc-form[method=listconnections] output')?.textContent === 'Done.');69+ check(submissions===1, 'duplicate form submissions sent '+submissions+' signed requests');70+ } finally { page.off('request',count); }71+ await page.getByRole('button', {name:'Preview connections',exact:true}).click();72+ await page.waitForFunction(() => document.querySelector('#connections-preview thead') !== null);73+ const result = await forms();74+ await page.screenshot({path:'output/playwright/ux/connect-desktop.png'});75+ return result;76+ });77+ await run('refused previews show an error without destroying the table', async () => {78+ await page.evaluate(async () => { await fetch(window.tiny.localPath('/session/logout'), {method:'POST'}); });79+ try {80+ await page.getByRole('button', {name:'Preview connections',exact:true}).click();81+ await page.waitForFunction(() => document.querySelector('#navigation-status')?.textContent.includes('Sign in again'));82+ check(await page.locator('#connections-preview').count()===1, 'refused preview removed table');83+ } finally { await page.evaluate(async () => { await window.tiny.signedFetch('/session','POST',''); }); }84+ });85+ await run('job status stream closes when leaving the page', async () => {86+ await clickNav('/sync');87+ await page.locator('#watch-jobs').click();88+ await page.waitForFunction(() => {89+ const requests = document.querySelector('#watch-jobs').__fixi?.requests;90+ const config = requests && [...requests].find(cfg => cfg.sse);91+ if (config) { window.__uxStream = config.sse; return true; }92+ return false;93+ });94+ await clickNav('/connect');95+ check(await page.evaluate(() => window.__uxStream.closed), 'job stream remained open after navigation');96+ });97+ await run('file viewers preserve one content section and media bounds', async () => {98+ await page.goto(base+'/files');99+ const links = await page.locator('#rows a').evaluateAll(nodes => nodes.map(n => n.href));100+ check(links.length >= 4, 'fixture files missing');101+ const visited=[];102+ for (const href of links.slice(0,4)) {103+ await page.goto(href);104+ 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)}));105+ check(info.sections===1 && info.children.length===1, 'file viewer has content outside section: '+JSON.stringify(info));106+ check(!info.raw, 'download intercepted by fixi');107+ check(info.forms, 'file forms disconnected');108+ visited.push(info);109+ }110+ await page.setViewportSize({width:360,height:800});111+ const mobile = await page.evaluate(() => ({width:innerWidth,scroll:document.documentElement.scrollWidth,content:document.querySelector('#content').scrollWidth}));112+ check(mobile.scroll<=mobile.width && mobile.content<=mobile.width, 'mobile file page overflows: '+JSON.stringify(mobile));113+ await page.screenshot({path:'output/playwright/ux/file-mobile.png'});114+ return {visited,mobile};115+ });116+ await run('mobile menu and theme survive navigation', async () => {117+ await page.goto(base+'/');118+ await page.setViewportSize({width:360,height:800});119+ await page.locator('#menu').click();120+ check(await page.locator('#nav-menu').evaluate(e=>e.open),'menu did not open');121+ await clickNav('/articles');122+ check(!await page.locator('#nav-menu').evaluate(e=>e.open),'navigation did not close menu');123+ await page.locator('#menu').click();124+ check(await page.locator('#nav-menu').evaluate(e=>e.open),'morphed menu did not open');125+ await page.keyboard.press('Escape');126+ check(!await page.locator('#nav-menu').evaluate(e=>e.open),'Escape did not close menu');127+ const before = await page.locator('html').getAttribute('data-theme');128+ await page.locator('#theme').click();129+ check(await page.locator('html').getAttribute('data-theme') !== before,'morphed theme button inactive');130+ await page.setViewportSize({width:1440,height:1000});131+ check(await page.locator('#rail').getAttribute('aria-hidden')!=='true','desktop rail inaccessible');132+ });133+ await run('no JavaScript mobile content and navigation', async () => {134+ const context=await page.context().browser().newContext({javaScriptEnabled:false,viewport:{width:360,height:800}});135+ try {136+ const noJS=await context.newPage();137+ await noJS.goto(base+'/repos');138+ check(await noJS.locator('#menu').isVisible(),'no-JS menu missing');139+ check(!await noJS.locator('#rail').isVisible(),'no-JS navigation starts expanded');140+ await noJS.locator('#menu').click();141+ const deadline=Date.now()+2000;142+ while ((await noJS.locator('#rail').boundingBox()).x<0 && Date.now()<deadline) await noJS.waitForTimeout(25);143+ check((await noJS.locator('#rail').boundingBox()).x===0,'no-JS menu did not finish opening');144+ check(await noJS.locator('#rail').isVisible(),'no-JS menu did not open');145+ await noJS.locator('#rail').getByRole('link',{name:'/articles',exact:true}).click();146+ check(noJS.url()===base+'/articles','no-JS rail link inaccessible');147+ check(await noJS.locator('#content h1').first().textContent()==='Articles','no-JS articles missing');148+ check(!await noJS.locator('#rail').isVisible(),'no-JS navigation did not reset closed');149+ check((await noJS.locator('#content').boundingBox()).y<80,'no-JS navigation pushes content down');150+ await noJS.screenshot({path:'output/playwright/ux/nojs-mobile.png'});151+ } finally {await context.close();}152+ });153+ page.off('pageerror',onError);154+ results.push({name:'uncaught browser errors',ok:errors.length===0,errors});155+ return {passed:results.filter(x=>x.ok).length,failed:results.filter(x=>!x.ok).length,results};156+}@@ -0,0 +1,96 @@
1+// Run after signing in to the disposable UX relay as its fixture owner.2+async page => {3+ const base = await page.evaluate(() => location.origin);4+ const storageState = await page.context().storageState();5+ const results = [], errors = [];6+ const check = (ok, message) => { if (!ok) throw Error(message); };7+ const run = async (name, action) => {8+ try { results.push({name, ok:true, detail:await action()}); }9+ catch (error) { results.push({name, ok:false, error:error.message}); }10+ };11+ await page.goto(base+'/manage/connect');12+ check(await page.locator('#session-logout').count() === 1, 'Sign in as the fixture owner before running menu checks.');13+ const routes = ['/', '/search', '/repos', '/files', '/sites', '/articles', '/inbox', '/outbox', '/signin', '/terms', '/tools', '/private', '/chat', '/media', '/marmot', '/grasp'];14+ routes.push(...await page.locator('#rail nav a').evaluateAll(nodes => nodes.map(e=>e.getAttribute('href'))));15+ await page.goto(base+'/repos');16+ const repo = await page.getByRole('link', {name:'ux-edge-repo', exact:true}).getAttribute('href');17+ await page.goto(base+repo);18+ routes.push(...await page.locator('#rail nav a').evaluateAll(nodes => nodes.map(e=>e.getAttribute('href'))));19+ await page.goto(base+'/files');20+ routes.push(...await page.locator('#rows a').evaluateAll(nodes => nodes.map(e=>e.getAttribute('href'))));21+ await page.goto(base+'/articles');22+ routes.push(await page.locator('#content a[href^="/e/"]').first().getAttribute('href'));23+ await page.goto(base+'/');24+ routes.push(await page.locator('#content a[href^="/e/"]').last().getAttribute('href'));25+ routes.push('/a/ux-long-article', '/r/main/articles', '/r/main/manage/connect');26+ for (const javaScriptEnabled of [true, false]) {27+ const context = await page.context().browser().newContext({javaScriptEnabled, storageState, viewport:{width:360,height:800}});28+ try {29+ const probe = await context.newPage();30+ probe.on('pageerror', error=>errors.push(error.message));31+ const waitForMenu = async () => {32+ // Poll outside the page because disabled JavaScript also disables browser timers.33+ const deadline = Date.now()+2000;34+ while ((await probe.locator('#rail').boundingBox()).x<0 && Date.now()<deadline) await probe.waitForTimeout(25);35+ check((await probe.locator('#rail').boundingBox()).x===0,'menu did not finish opening');36+ };37+ for (const path of [...new Set(routes)]) await run(`${javaScriptEnabled?'JS':'no JS'} ${path}`, async () => {38+ await probe.setViewportSize({width:360,height:800});39+ const response = await probe.goto(base+path);40+ check(response.ok(), 'HTTP '+response.status());41+ check(await probe.locator('#menu').isVisible(), 'menu control missing');42+ check(!await probe.locator('#rail').isVisible(), 'navigation starts expanded');43+ check(await probe.locator('#rail nav').count()===1, 'navigation duplicated');44+ const before = await probe.locator('#content').boundingBox();45+ check(before.y<80, 'navigation pushes content down');46+ await probe.locator('#menu').click();47+ await waitForMenu();48+ check(await probe.locator('#rail').isVisible(), 'menu did not open');49+ check(await probe.locator('#rail nav a').first().isVisible(), 'menu links hidden');50+ check((await probe.locator('#content').boundingBox()).y===before.y, 'opening menu moves content');51+ await probe.locator('#menu').click();52+ check(!await probe.locator('#rail').isVisible(), 'menu did not close');53+ await probe.locator('#menu').focus();54+ await probe.keyboard.press('Enter');55+ await waitForMenu();56+ check(await probe.locator('#rail').isVisible(), 'Enter did not open menu');57+ await probe.keyboard.press('Space');58+ check(!await probe.locator('#rail').isVisible(), 'Space did not close menu');59+ await probe.setViewportSize({width:1440,height:1000});60+ check(await probe.locator('#rail').isVisible(), 'desktop sidebar missing');61+ check(!await probe.locator('#menu').isVisible(), 'mobile menu appears on desktop');62+ return {title:await probe.title(),contentTop:before.y};63+ });64+ await run(`${javaScriptEnabled?'JS':'no JS'} navigation resets the menu`, async () => {65+ await probe.setViewportSize({width:360,height:800});66+ await probe.goto(base+'/repos');67+ if(javaScriptEnabled) await probe.evaluate(()=>{window.__menuDocument='preserved';});68+ for(const label of ['/articles','/files','/repos']) {69+ await probe.locator('#menu').click();70+ await waitForMenu();71+ await probe.locator('#rail').getByRole('link',{name:label,exact:true}).click();72+ await probe.waitForURL(base+label);73+ await probe.waitForFunction(()=>document.querySelector('#content').getAttribute('aria-busy')!=='true');74+ check(!await probe.locator('#rail').isVisible(),'navigation left menu open');75+ }76+ if(javaScriptEnabled) {77+ check(await probe.evaluate(()=>window.__menuDocument)==='preserved','navigation reloaded document');78+ await probe.locator('#menu').click();79+ await probe.keyboard.press('Escape');80+ check(!await probe.locator('#rail').isVisible(),'Escape did not close menu');81+ check(await probe.locator('#menu').evaluate(e=>e===document.activeElement),'Escape did not restore menu focus');82+ }83+ });84+ if(!javaScriptEnabled) {85+ await context.clearCookies();86+ await probe.goto(base+'/articles');87+ await probe.screenshot({path:'output/playwright/ux/nojs-mobile.png'});88+ await probe.locator('#menu').click();89+ await waitForMenu();90+ await probe.screenshot({path:'output/playwright/ux/nojs-mobile-menu-open.png'});91+ }92+ } finally {await context.close();}93+ }94+ results.push({name:'uncaught browser errors',ok:errors.length===0,errors});95+ return {passed:results.filter(x=>x.ok).length,failed:results.filter(x=>!x.ok).length,results};96+}@@ -0,0 +1,128 @@
1+2+// Seed a deterministic, public corpus for browser UX fuzzing.3+// The script is intentionally self-contained so it can be run against a4+// disposable daemon without changing a checkout or a user's home directory.5+import { execFile } from "node:child_process";6+import { mkdir, writeFile, readFile } from "node:fs/promises";7+import { join, resolve } from "node:path";8+import { promisify } from "node:util";9+import { createHash } from "node:crypto";10+import WebSocket from "ws";11+import { finalizeEvent, getPublicKey } from "nostr-tools/pure";12+import { getToken } from "nostr-tools/nip98";13+import { npubEncode } from "nostr-tools/nip19";14+15+const exec = promisify(execFile);16+const args = process.argv.slice(2);17+const option = (key, fallback) => args.includes(key) ? args[args.indexOf(key) + 1] : fallback;18+const http = (option("--relay", "http://127.0.0.1:18447")).replace(/^ws/, "http").replace(/\/$/, "");19+const wsURL = http.replace(/^http/, "ws");20+const dataDir = resolve(option("--data", "/tmp/tinyrelay-ux-20260908"));21+const outDir = resolve(option("--out", "output/playwright/ux"));22+const secret = Uint8Array.from(Buffer.from("fc1d06a0fd5e622dcf448d0b3c2fccc891a5ecf74546a7675460d92441fecfdd", "hex"));23+const owner = getPublicKey(secret);24+const now = () => Math.floor(Date.now() / 1000);25+const json = value => JSON.stringify(value);26+27+// This is printed before network or filesystem work so browser runners can28+// discover the fixture identity even if a later seed operation fails.29+console.log(json({ fixture: "tinyrelay-ux", owner, npub: npubEncode(owner), relay: http, dataDir, out: join(outDir, "fixture.json") }));30+31+async function rpc(method, params = []) {32+ const payload = { method, params };33+ const token = await getToken(http, "POST", event => finalizeEvent(event, secret), true, payload);34+ const response = await fetch(http, { method: "POST", headers: { "content-type": "application/nostr+json+rpc", authorization: token }, body: json(payload) });35+ const result = await response.json();36+ if (!response.ok || result.error) throw Error(`${method}: ${json(result)}`);37+ return result.result;38+}39+40+class Client {41+ constructor(url) {42+ this.ws = new WebSocket(url); this.waiters = new Map(); this.events = new Map();43+ this.open = new Promise((resolve, reject) => { this.ws.once("open", resolve); this.ws.once("error", reject); });44+ this.ws.on("message", raw => { let m; try { m = JSON.parse(raw); } catch { return; }45+ if (m[0] === "AUTH") return;46+ const waiter = this.waiters.get(m[1]); if (!waiter) return;47+ if (m[0] === "OK") {48+ const reason = String(m[3] || "publish rejected");49+ // Re-seeding an existing fixture is successful when the relay already50+ // has this event, or has a newer replaceable event for the same d tag.51+ waiter.done(m[2] || /^(duplicate|invalid: a newer version)/.test(reason) ? null : Error(reason), m);52+ }53+ else if (m[0] === "EOSE") waiter.done(null, waiter.items);54+ else if (m[0] === "EVENT") waiter.items.push(m[2]);55+ else if (m[0] === "CLOSED") waiter.done(Error(m[2]));56+ });57+ }58+ async publish(event) { await this.open; return new Promise((resolve, reject) => {59+ const finish = (e, value) => { clearTimeout(timer); this.waiters.delete(event.id); e ? reject(e) : resolve(value); };60+ const timer = setTimeout(() => finish(Error("publish timeout")), 30000);61+ this.waiters.set(event.id, { done: finish, items: [] }); this.ws.send(json(["EVENT", event]));62+ }); }63+ close() { this.ws.close(); }64+}65+66+function event(kind, content, tags = [], createdAt = now()) { return finalizeEvent({ kind, content, tags, created_at: createdAt }, secret); }67+async function upload(body, type, label) {68+ const bytes = Buffer.isBuffer(body) ? body : Buffer.from(body);69+ const hash = createHash("sha256").update(bytes).digest("hex");70+ const authEvent = finalizeEvent({ kind: 24242, created_at: now(), content: "upload", tags: [["t", "upload"], ["x", hash], ["expiration", String(now() + 300)]] }, secret);71+ const token = "Nostr " + Buffer.from(json(authEvent)).toString("base64");72+ const response = await fetch(http + "/upload", { method: "PUT", headers: { authorization: token, "content-type": type }, body: bytes });73+ const result = await response.json().catch(() => ({}));74+ if (!response.ok) throw Error(`upload ${label}: ${response.status} ${json(result)}`);75+ return { label, sha256: hash, type, size: bytes.length, url: result.url || `${http}/${hash}`, response: result };76+}77+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(); }78+79+await mkdir(dataDir, { recursive: true }); await mkdir(outDir, { recursive: true });80+const policy = await rpc("getpolicy");81+policy.features = { ...policy.features, files: true, sites: { ...(policy.features?.sites || {}), enabled: true, mirror: true }, grasp: true, grasp02: true, grasp03: true, grasp05: true, grasp06: true };82+await rpc("setpolicy", [policy]);83+const relay = new Client(wsURL); await relay.open;84+// Keep event IDs stable across reruns so the relay's normal event dedupe makes85+// this safe to invoke repeatedly against the same disposable daemon.86+const created = Number(option("--created-at", "1788876000"));87+if (!Number.isSafeInteger(created) || created < 1) throw Error("--created-at must be a positive integer");88+const notes = [89+ 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),90+ event(1, "Short note with [markdown](https://example.com) and a bare https://example.org/path#fragment", [["t", "ux-fixture"], ["t", "links"]], created + 1),91+];92+const articles = [93+ 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),94+ event(30023, "Second revision with the same identifier", [["d", "ux-revision"], ["title", "Revision"]], created + 3),95+];96+for (const e of [...notes, ...articles]) await relay.publish(e);97+98+const files = [];99+files.push(await upload("plain text with <escaped> & unicode café\n".repeat(20), "text/plain; charset=utf-8", "text"));100+files.push(await upload("# Markdown fixture\n\n[link](https://example.com)\n\n<script>ignored</script>", "text/markdown; charset=utf-8", "markdown"));101+files.push(await upload("<!doctype html><meta charset=utf-8><title>UX HTML</title><p>fixture & markup</p>", "text/html; charset=utf-8", "html"));102+files.push(await upload(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", "base64"), "image/png", "image"));103+104+const repo = join(dataDir, "repo"); await mkdir(repo, { recursive: true });105+await git(repo, "init", "-b", "main");106+const entries = new Map([107+ ["README UX fixture.md", "UX fixture repository\nPaths and refs contain awkward URL characters.\n"],108+ ["nested dir/space # hash/percent%/question?.txt", "nested edge path\n"],109+ ["nested dir/Unicode café/emoji 🧪.md", "unicode path content\n"],110+ ["web/<escaped>.html", "<!doctype html><title>fixture</title><script>ignored</script>"],111+]);112+for (const [path, content] of entries) { const full = join(repo, path); await mkdir(join(full, ".."), { recursive: true }); await writeFile(full, content); }113+await git(repo, "add", ".");114+try { await git(repo, "commit", "-m", "UX fixture paths"); } catch (error) { if (!String(error.stdout).includes("nothing to commit")) throw error; }115+await git(repo, "tag", "-f", "release/1.0"); await git(repo, "branch", "-f", "feature/café");116+// GRASP identifiers are intentionally conservative; awkward characters live117+// in the display name and tracked paths, where viewers must still URL-escape.118+const repoId = "ux-edge-repo"; const cloneURL = `${http}/${npubEncode(owner)}/${encodeURIComponent(repoId)}.git`;119+const refs = (await git(repo, "for-each-ref", "--format=%(refname) %(objectname)", "refs/heads", "refs/tags")).split("\n").filter(Boolean).map(line => line.split(" "));120+const announcement = event(30617, "", [["d", repoId], ["name", "UX space/#/?/% café"], ["description", "Repository used by browser navigation fuzzing"], ["clone", cloneURL], ["relays", wsURL], ["maintainers", owner]]);121+await relay.publish(announcement);122+const state = event(30618, "", [["d", repoId], ["HEAD", "ref: refs/heads/main"], ...refs.map(([ref, oid]) => [ref, oid])]);123+await relay.publish(state);124+try { await git(repo, "remote", "set-url", "origin", cloneURL); } catch { await git(repo, "remote", "add", "origin", cloneURL); }125+await git(repo, "push", "origin", "--all"); await git(repo, "push", "origin", "--tags");126+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 } };127+await writeFile(join(outDir, "fixture.json"), json(manifest) + "\n");128+relay.close(); console.log(json({ ok: true, manifest: join(outDir, "fixture.json"), notes: notes.length, articles: articles.length, files: files.length, repository: repoId }));@@ -0,0 +1,64 @@
1+2+// Read-only HTTP fuzzing for the browser-facing hosted-content viewers.3+import { readFile, mkdir, writeFile } from "node:fs/promises";4+import { dirname, resolve } from "node:path";5+6+const args = process.argv.slice(2);7+const option = (key, fallback) => args.includes(key) ? args[args.indexOf(key) + 1] : fallback;8+const manifestPath = resolve(option("--manifest", "output/playwright/ux/fixture.json"));9+const manifest = JSON.parse(await readFile(manifestPath, "utf8"));10+const base = (option("--relay", manifest.relay)).replace(/\/$/, "");11+const out = resolve(option("--out", "output/playwright/ux/http-fuzz.json"));12+const timeoutMs = Number(option("--timeout-ms", "5000"));13+const unusual = " café/路径 #hash ?query %percent & <script>alert(1)</script>\u0000";14+const prefixes = ["", "/r/main"];15+const cases = [];16+const add = (name, path, query = "") => { for (const prefix of prefixes) cases.push({ name: `${prefix || "root"} ${name}`, url: `${base}${prefix}${path}${query}` }); };17+18+for (const q of ["", "?limit=-1&cursor=%00", `?q=${encodeURIComponent(unusual)}&limit=999999`, "?owner=%00&ref=refs%2Fheads%2Fmain&path=%2F..%2F"])19+ add("search", "/search" + q);20+for (const q of ["", `?owner=${encodeURIComponent(unusual)}&repo=${encodeURIComponent(unusual)}&ref=%00&path=${encodeURIComponent(unusual)}`, "?owner=%00&repo=%00&limit=NaN&cursor=%00"])21+ add("repo", "/repo" + q);22+for (const q of ["", `?hash=${encodeURIComponent(unusual)}&limit=-1&cursor=%00`, "?sha=%00&hash=not-a-sha"])23+ add("file", "/file" + q);24+for (const id of ["", "not-an-event", "0".repeat(64), unusual, manifest.notes[0].id]) add("event", "/e/" + encodeURIComponent(id));25+26+const results = [];27+const duplicateIDs = html => [...html.matchAll(/\bid=["']([^"']+)["']/gi)].map(m => m[1]).filter((id, i, all) => all.indexOf(id) !== i);28+async function request(item) {29+ const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs);30+ const started = performance.now();31+ try {32+ const response = await fetch(item.url, { method: "GET", redirect: "manual", signal: controller.signal });33+ const body = await response.text();34+ const duplicate = duplicateIDs(body);35+ const injected = /<script\s*>\s*alert\s*\(/i.test(body) || body.includes("<script>alert(1)</script>");36+ 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 } : {}) };37+ } catch (error) { return { ...item, status: 0, ms: Math.round(performance.now() - started), ok: false, error: error.name === "AbortError" ? "timeout" : error.message }; }38+ finally { clearTimeout(timer); }39+}40+for (const item of cases) results.push(await request(item));41+42+const valid = [];43+const validGet = async (name, path, expected) => {44+ const item = { name: `valid ${name}`, url: base + path }; const row = await request(item);45+ const body = row.status ? await (await fetch(item.url)).text() : "";46+ row.checks = { ...row.checks, expected: body.includes(expected), noClassAttributes: !/\bclass\s*=/i.test(body) };47+ row.ok = row.ok && row.checks.expected && row.checks.noClassAttributes;48+ valid.push(row);49+};50+const note = manifest.notes[0], article = manifest.articles[0], file = manifest.files.find(f => f.label === "markdown");51+await validGet("note", `/e/${note.id}`, note.id);52+await validGet("article", "/a/ux-long-article", "A long article");53+await validGet("articles", "/articles", "A long article");54+await validGet("files", "/files", "Files");55+await validGet("file", `/file?hash=${file.sha256}`, file.sha256);56+await validGet("repos", "/repos", manifest.repository.id);57+await validGet("repo home", `/repo?owner=${manifest.owner}&repo=${manifest.repository.id}`, manifest.repository.id);58+await validGet("repo tree", `/repo?owner=${manifest.owner}&repo=${manifest.repository.id}&view=tree&ref=refs%2Fheads%2Fmain`, "README UX fixture.md");59+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");60+61+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 };62+await mkdir(dirname(out), { recursive: true }); await writeFile(out, JSON.stringify(report, null, 2) + "\n");63+console.log(JSON.stringify({ ok: report.failed === 0, cases: report.cases, valid: report.validCases, passed: report.passed, failed: report.failed, report: out }));64+process.exitCode = report.failed ? 1 : 0;