tinyrelayfile
tinyrelay/scripts/qa/ux-fixture.mjs/

scripts/qa/ux-fixture.mjs raw
1#!/usr/bin/env node
2// Seed a deterministic, public corpus for browser UX fuzzing.
3// The script is intentionally self-contained so it can be run against a
4// disposable daemon without changing a checkout or a user's home directory.
5import { execFile } from "node:child_process";
6import { mkdir, writeFile, readFile } from "node:fs/promises";
7import { join, resolve } from "node:path";
8import { promisify } from "node:util";
9import { createHash } from "node:crypto";
10import WebSocket from "ws";
11import { finalizeEvent, getPublicKey } from "nostr-tools/pure";
12import { getToken } from "nostr-tools/nip98";
13import { npubEncode } from "nostr-tools/nip19";
14
15const exec = promisify(execFile);
16const args = process.argv.slice(2);
17const option = (key, fallback) => args.includes(key) ? args[args.indexOf(key) + 1] : fallback;
18const http = (option("--relay", "http://127.0.0.1:18447")).replace(/^ws/, "http").replace(/\/$/, "");
19const wsURL = http.replace(/^http/, "ws");
20const dataDir = resolve(option("--data", "/tmp/tinyrelay-ux-20260908"));
21const outDir = resolve(option("--out", "output/playwright/ux"));
22const secret = Uint8Array.from(Buffer.from("fc1d06a0fd5e622dcf448d0b3c2fccc891a5ecf74546a7675460d92441fecfdd", "hex"));
23const owner = getPublicKey(secret);
24const now = () => Math.floor(Date.now() / 1000);
25const json = value => JSON.stringify(value);
26
27// This is printed before network or filesystem work so browser runners can
28// discover the fixture identity even if a later seed operation fails.
29console.log(json({ fixture: "tinyrelay-ux", owner, npub: npubEncode(owner), relay: http, dataDir, out: join(outDir, "fixture.json") }));
30
31async 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
40class 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 already
50 // 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
66function event(kind, content, tags = [], createdAt = now()) { return finalizeEvent({ kind, content, tags, created_at: createdAt }, secret); }
67async 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}
77async 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
79await mkdir(dataDir, { recursive: true }); await mkdir(outDir, { recursive: true });
80const policy = await rpc("getpolicy");
81policy.features = { ...policy.features, files: true, sites: { ...(policy.features?.sites || {}), enabled: true, mirror: true }, grasp: true, grasp02: true, grasp03: true, grasp05: true, grasp06: true };
82await rpc("setpolicy", [policy]);
83const relay = new Client(wsURL); await relay.open;
84// Keep event IDs stable across reruns so the relay's normal event dedupe makes
85// this safe to invoke repeatedly against the same disposable daemon.
86const created = Number(option("--created-at", "1788876000"));
87if (!Number.isSafeInteger(created) || created < 1) throw Error("--created-at must be a positive integer");
88const 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];
92const 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];
96for (const e of [...notes, ...articles]) await relay.publish(e);
97
98const files = [];
99files.push(await upload("plain text with <escaped> & unicode café\n".repeat(20), "text/plain; charset=utf-8", "text"));
100files.push(await upload("# Markdown fixture\n\n[link](https://example.com)\n\n<script>ignored</script>", "text/markdown; charset=utf-8", "markdown"));
101files.push(await upload("<!doctype html><meta charset=utf-8><title>UX HTML</title><p>fixture &amp; markup</p>", "text/html; charset=utf-8", "html"));
102files.push(await upload(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", "base64"), "image/png", "image"));
103
104const repo = join(dataDir, "repo"); await mkdir(repo, { recursive: true });
105await git(repo, "init", "-b", "main");
106const 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]);
112for (const [path, content] of entries) { const full = join(repo, path); await mkdir(join(full, ".."), { recursive: true }); await writeFile(full, content); }
113await git(repo, "add", ".");
114try { await git(repo, "commit", "-m", "UX fixture paths"); } catch (error) { if (!String(error.stdout).includes("nothing to commit")) throw error; }
115const mergeBase = await git(repo, "rev-parse", "HEAD");
116await writeFile(join(repo, "README UX fixture.md"), "UX fixture repository\nPaths and refs contain awkward URL characters.\nPR fixture change.\n");
117await git(repo, "add", "."); await git(repo, "commit", "-m", "PR fixture change");
118const pullTip = await git(repo, "rev-parse", "HEAD");
119await git(repo, "tag", "-f", "release/1.0"); await git(repo, "branch", "-f", "feature/café");
120// GRASP identifiers are intentionally conservative; awkward characters live
121// in the display name and tracked paths, where viewers must still URL-escape.
122const repoId = "ux-edge-repo"; const cloneURL = `${http}/${npubEncode(owner)}/${encodeURIComponent(repoId)}.git`;
123const refs = (await git(repo, "for-each-ref", "--format=%(refname) %(objectname)", "refs/heads", "refs/tags")).split("\n").filter(Boolean).map(line => line.split(" "));
124const announcement = event(30617, "", [["d", repoId], ["name", "UX space/#/?/% café"], ["description", "Repository used by browser navigation fuzzing"], ["clone", cloneURL], ["relays", wsURL], ["maintainers", owner]]);
125await relay.publish(announcement);
126const state = event(30618, "", [["d", repoId], ["HEAD", "ref: refs/heads/main"], ...refs.map(([ref, oid]) => [ref, oid])]);
127await relay.publish(state);
128// Seed NIP-34 collaboration roots and their signed thread events so browser
129// checks cover issue filtering, PR detail rendering and status transitions.
130const coordinate = `30617:${owner}:${repoId}`;
131const issue = event(1621, "Fixture issue body with a [link](https://example.com) and escaped <markup>.", [["a", coordinate], ["subject", "Fixture issue: awkward paths"], ["t", "bug"], ["t", "ux"]], created + 4);
132const pull = event(1618, "Fixture pull request body", [["a", coordinate], ["subject", "Fixture PR: add nested path"], ["t", "enhancement"], ["c", pullTip], ["clone", cloneURL], ["merge-base", mergeBase]], created + 5);
133const patch = event(1617, "diff --git a/README UX fixture.md b/README UX fixture.md\n@@\n+fixture change\n", [["a", coordinate], ["e", pull.id, "", "root"], ["E", pull.id, "", owner], ["K", "1618"], ["P", owner]], created + 6);
134const reply = event(1111, "Signed fixture reply", [["a", coordinate], ["E", issue.id, "", owner], ["K", "1621"], ["P", owner], ["e", issue.id, "", owner], ["k", "1621"], ["p", owner]], created + 7);
135const closed = event(1632, "", [["a", coordinate], ["e", issue.id, "", "root"], ["p", owner]], created + 8);
136for (const collaboration of [issue, pull, patch, reply, closed]) await relay.publish(collaboration);
137try { await git(repo, "remote", "set-url", "origin", cloneURL); } catch { await git(repo, "remote", "add", "origin", cloneURL); }
138await git(repo, "push", "origin", "--all"); await git(repo, "push", "origin", "--tags");
139const 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 })), collaboration: [issue, pull, patch, reply, closed].map(e => ({ id: e.id, kind: e.kind, tags: e.tags })), files, repository: { id: repoId, clone: cloneURL, path: repo, refs } };
140await writeFile(join(outDir, "fixture.json"), json(manifest) + "\n");
141relay.close(); console.log(json({ ok: true, manifest: join(outDir, "fixture.json"), notes: notes.length, articles: articles.length, files: files.length, repository: repoId }));

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs