tinyrelaycommit

Commit 2109b2c6b0c0

browse files at this commit | history

1diff --git a/docs/testing.md b/docs/testing.md
2index 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/`.
14diff --git a/scripts/qa/README.md b/scripts/qa/README.md
15new file mode 100644
16index 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.
69diff --git a/scripts/qa/ux-browser-edges.js b/scripts/qa/ux-browser-edges.js
70new file mode 100644
71index 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+}
149diff --git a/scripts/qa/ux-browser-flow.js b/scripts/qa/ux-browser-flow.js
150new file mode 100644
151index 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+}
311diff --git a/scripts/qa/ux-browser-menu.js b/scripts/qa/ux-browser-menu.js
312new file mode 100644
313index 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+}
413diff --git a/scripts/qa/ux-fixture.mjs b/scripts/qa/ux-fixture.mjs
414new file mode 100644
415index 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 &amp; 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 }));
547diff --git a/scripts/qa/ux-http-fuzz.mjs b/scripts/qa/ux-http-fuzz.mjs
548new file mode 100644
549index 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 &amp; <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;

About

ownerc01503e8035f
visibilitypublic
head2109b2c6b0c0

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs