// The world the screenshots show: a reference PDS holding the same // repositories, pulls and issues every time, so each page renders to the // same pixels. Commits carry fixed dates, so their hashes are fixed too, and // every record's createdAt is rewritten to a fixed time, in the order the // records were made. The index a knotless-serve built while it was seeded // is kept in `dir`, with its mirrors, for the next server to start from. // // node harness/shots/world.mjs seed one and keep it up, for // `just shots` runs to reuse, until // ctrl-c; written to target/shots/world.json import { spawn, execFileSync } from 'node:child_process' import { createHash } from 'node:crypto' import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { createInterface } from 'node:readline' import { fileURLToPath } from 'node:url' const here = dirname(fileURLToPath(import.meta.url)) export const root = resolve(here, '../..') const fixture = join(here, 'fixture') const bin = join(root, 'target/debug') // Where the world answers, and the pages behind it on the next two ports: // three of their own for each checkout, from its path, so the shots of two // side by side do not meet; SHOTS_PORT moves them. const own = 20000 + 3 * (createHash('sha256').update(root).digest().readUInt16BE(0) % 3000) export const port = Number(process.env.SHOTS_PORT ?? own) export const base = `http://127.0.0.1:${port}` export const saved = join(root, 'target/shots/world.json') const people = { alice: { name: 'Alice Moreau', email: 'alice@example.com' }, bob: { name: 'Bob Tanaka', email: 'bob@example.com' }, } export const until = async (what, check) => { for (let i = 0; i < 300; i++) { try { if (await check()) return } catch {} await new Promise((r) => setTimeout(r, 100)) } throw new Error(`timed out waiting for ${what}`) } export const page = async (path) => { const res = await fetch(base + path) return res.ok ? res.text() : '' } /// The environment `who` runs the CLI, git or the server in. const environment = (net, dir, who, when) => { const clean = Object.fromEntries(Object.entries(process.env).filter(([k]) => !k.startsWith('KNOTLESS_'))) const person = people[who] ?? { name: who, email: `${who}@example.com` } return { ...clean, PATH: `${bin}:${process.env.PATH}`, KNOTLESS_HANDLE_RESOLVER: net.pds, KNOTLESS_PLC_URL: net.plc, KNOTLESS_COMPACT: 'off', KNOTLESS_APPVIEW: base, KNOTLESS_CONFIG_DIR: join(dir, who, 'config'), KNOTLESS_CACHE_DIR: join(dir, who, 'cache'), GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1', GIT_AUTHOR_NAME: person.name, GIT_AUTHOR_EMAIL: person.email, GIT_COMMITTER_NAME: person.name, GIT_COMMITTER_EMAIL: person.email, GIT_AUTHOR_DATE: when ?? '2026-10-01T09:00:00Z', GIT_COMMITTER_DATE: when ?? '2026-10-01T09:00:00Z', } } /// The pages, from web/build, answering on `at` and reading from `data`. export const pages = (at, data) => spawn('node', [join(root, 'web/build')], { env: { ...process.env, HOST: '127.0.0.1', PORT: String(at), KNOTLESS_SERVE: data, HOST_HEADER: 'x-forwarded-host', PROTOCOL_HEADER: 'x-forwarded-proto', }, stdio: 'inherit', }) /// knotless-serve from this build, over the world's index and mirrors, on /// `at`: in front of the pages, unless `alone`. export const serve = async (world, { at = port, alone = false } = {}) => { const drawn = alone ? null : pages(port + 1, base) const server = spawn( join(bin, 'knotless-serve'), [ '--listen', `127.0.0.1:${at}`, '--index', join(world.dir, 'index.db'), '--firehose', world.net.pds.replace('http', 'ws'), ...(alone ? [] : ['--web', `http://127.0.0.1:${port + 1}`]), ], { env: environment(world.net, world.dir, 'server'), stdio: 'inherit' }, ) const kill = () => { server.kill() drawn?.kill() } const up = alone ? `http://127.0.0.1:${at}/healthz` : base try { await until('knotless-serve and the pages', async () => (await fetch(up)).ok) } catch (e) { kill() throw e } return { kill } } /// A new world: its PDS, a server, and the seeded data. Returns them with /// the pages to shoot. export async function create() { const dir = mkdtempSync(join(tmpdir(), 'knotless-shots-')) const children = [] const stop = () => { for (const child of children.reverse()) child.kill() rmSync(dir, { recursive: true, force: true }) } try { const pds = spawn('node', ['harness/pds.mjs', '--accounts', 'alice,bob'], { cwd: root, stdio: ['pipe', 'pipe', 'inherit'], }) children.push(pds) const [line] = await createInterface({ input: pds.stdout })[Symbol.asyncIterator]().next().then((r) => [r.value]) const net = JSON.parse(line) const account = Object.fromEntries(net.accounts.map((a) => [a.handle.split('.')[0], a])) const work = dir const env = (who, when) => environment(net, dir, who, when) const run = (who, cmd, args, opts = {}) => execFileSync(cmd, args, { env: env(who, opts.when), cwd: opts.cwd ?? work, input: opts.input ?? '' }) .toString() .trim() const knotless = (who, ...args) => run(who, join(bin, 'knotless'), args) const git = (who, dir, when, ...args) => run(who, 'git', args, { cwd: dir, when }) /// Copies files from a fixture step into `dir` and commits them. const commit = (who, dir, when, message, files) => { for (const [from, to] of Object.entries(files)) { mkdirSync(dirname(join(dir, to)), { recursive: true }) cpSync(join(fixture, from), join(dir, to)) } git(who, dir, when, 'add', '-A') git(who, dir, when, 'commit', '-q', '-m', message) } children.push(await serve({ net, dir })) for (const who of ['alice', 'bob']) { run(who, join(bin, 'knotless'), ['login', account[who].handle, '--password-stdin'], { input: account[who].password }) } const tally = 'knotless://alice.test/tally' knotless('alice', 'create', tally, '--description', 'Counts lines, words and bytes, with output that lines up') const a = join(work, 'alice/tally') mkdirSync(a, { recursive: true }) git('alice', a, null, 'init', '-q', '-b', 'main') commit('alice', a, '2026-10-01T09:12:00Z', 'Start tally', { 'start/Cargo.toml': 'Cargo.toml', 'start/.gitignore': '.gitignore', 'start/README.md': 'README.md', 'start/main.rs': 'src/main.rs', }) commit('alice', a, '2026-10-02T14:40:00Z', 'Count words, lines and bytes', { 'count/lib.rs': 'src/lib.rs', 'count/main.rs': 'src/main.rs', }) commit( 'alice', a, '2026-10-03T08:05:00Z', 'Read from stdin when no file is given\n\nSo tally works at the end of a pipe, as wc does. The name shown\nfor stdin is "-".', { 'stdin/main.rs': 'src/main.rs' }, ) commit('alice', a, '2026-10-05T17:21:00Z', 'Add a logo', { 'logo/logo.svg': 'assets/logo.svg' }) commit('alice', a, '2026-10-06T10:02:00Z', 'Document usage and output', { 'docs/README.md': 'README.md', 'docs/design.md': 'docs/design.md', }) git('alice', a, null, 'mv', 'docs/design.md', 'docs/DESIGN.md') commit('alice', a, '2026-10-08T11:47:00Z', 'Split parsing into its own module', { 'parse/parse.rs': 'src/parse.rs', 'parse/lib.rs': 'src/lib.rs', }) const parse = git('alice', a, null, 'rev-parse', 'HEAD') git('alice', a, null, 'push', '-q', tally, 'main') const notes = 'knotless://alice.test/notes' knotless('alice', 'create', notes, '--description', 'Scratch notes 📝') const n = join(work, 'alice/notes') mkdirSync(n, { recursive: true }) git('alice', n, null, 'init', '-q', '-b', 'main') commit('alice', n, '2026-09-20T19:30:00Z', 'Begin', { 'notes/README.md': 'README.md' }) git('alice', n, null, 'push', '-q', notes, 'main') // A branch, then main moved past it: the feed lists only what main // brought that the branch had not. git('alice', n, null, 'checkout', '-q', '-b', 'reading') commit('alice', n, '2026-09-22T08:15:00Z', 'Start a reading list', { 'reading/reading.md': 'reading.md' }) git('alice', n, null, 'push', '-q', notes, 'reading') git('alice', n, null, 'checkout', '-q', 'main') git('alice', n, null, 'merge', '-q', '--ff-only', 'reading') commit('alice', n, '2026-09-23T21:40:00Z', 'Add Working in Public', { 'reading/more.md': 'reading.md' }) knotless('alice', 'create', 'knotless://alice.test/scratch') git('alice', n, null, 'push', '-q', notes, 'main') const fork = knotless('bob', 'fork', tally) const b = join(work, 'bob/tally') git('bob', work, null, 'clone', '-q', fork, b) git('bob', b, null, 'checkout', '-q', '-b', 'empty-input') commit('bob', b, '2026-10-09T09:31:00Z', 'Count no lines in empty input', { 'fix/parse.rs': 'src/parse.rs' }) git('bob', b, null, 'push', '-q', 'origin', 'empty-input') git('bob', b, null, 'checkout', '-q', '-b', 'json', 'main') commit('bob', b, '2026-10-10T16:12:00Z', 'Print counts as JSON with --json', { 'json/main.rs': 'src/main.rs' }) git('bob', b, null, 'push', '-q', 'origin', 'json') const pull = knotless( 'bob', 'pull', tally, 'main', fork, 'empty-input', 'Count no lines in empty input', '-m', '`tally` printed one line for an empty file. `lines` now yields nothing when the text is empty, and a test covers it.', ) knotless('alice', 'comment', tally, pull, 'Could `lines` check for emptiness once, rather than in the filter?', '--changes') knotless('bob', 'comment', tally, pull, 'It only runs per line, so the cost is one comparison each. Happy to change it if you prefer.') knotless('alice', 'comment', tally, pull, 'Fair enough. Thanks!', '--approve') // Merging and closing read the item from the index, which follows the // PDS a moment behind. await until('the pull', async () => (await page('/alice.test/tally/pulls')).includes('Count no lines in empty input')) knotless('alice', 'merge', tally, pull) const json = knotless( 'bob', 'pull', tally, 'main', fork, 'json', 'Print counts as JSON with --json', '-m', 'For scripts. The object has `lines`, `words`, `bytes` and `name`.', ) const utf8 = knotless( 'bob', 'issue', tally, 'Fails on files that are not UTF-8', '-m', 'Running `tally image.png` panics:\n\n```\nthread \'main\' panicked at src/main.rs:8:56:\nstream did not contain valid UTF-8\n```\n\nwc counts bytes for any file.', ) knotless('alice', 'comment', tally, utf8, 'Confirmed. Reading bytes and counting words lossily would fix it.') const typo = knotless('bob', 'issue', tally, 'Typo in the README ✏️') await until('the issue', async () => (await page('/alice.test/tally/issues')).includes('Typo in the README')) knotless('alice', 'close', tally, typo) // Every record a fixed createdAt, keeping their order. const records = [] for (const who of ['alice', 'bob']) { const session = await fetch(`${net.pds}/xrpc/com.atproto.server.createSession`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ identifier: account[who].handle, password: account[who].password }), }).then((r) => r.json()) const did = account[who].did if (who === 'alice') { const res = await fetch(`${net.pds}/xrpc/com.atproto.repo.putRecord`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${session.accessJwt}` }, body: JSON.stringify({ repo: did, collection: 'app.bsky.actor.profile', rkey: 'self', record: { $type: 'app.bsky.actor.profile', displayName: people.alice.name, description: 'Small tools for text. Writes tally.', }, }), }) if (!res.ok) throw new Error(`profile: ${await res.text()}`) } const { collections } = await fetch(`${net.pds}/xrpc/com.atproto.repo.describeRepo?repo=${did}`).then((r) => r.json()) for (const collection of collections) { let cursor = '' do { const listed = await fetch( `${net.pds}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${collection}&limit=100${cursor}`, ).then((r) => r.json()) for (const r of listed.records) { if (typeof r.value.createdAt === 'string') records.push({ ...r, did, collection, jwt: session.accessJwt }) } cursor = listed.cursor ? `&cursor=${listed.cursor}` : '' } while (cursor) } } // One write's records can be stamped a millisecond apart or in the same // one, which would order them either way: those made within 50 ms of // each other are ordered by collection and key instead. const at = (r) => Date.parse(r.value.createdAt) records.sort((x, y) => at(x) - at(y)) const writes = [] for (const r of records) { const last = writes.at(-1) if (last && at(r) - at(last.at(-1)) < 50) last.push(r) else writes.push([r]) } const named = (r) => `${r.collection} ${r.uri}` records.splice(0, records.length, ...writes.flatMap((w) => w.sort((x, y) => (named(x) < named(y) ? -1 : 1)))) const start = Date.parse('2026-10-09T10:00:00Z') const cids = new Map() // Strong references to a record whose CID changed are pointed at the new one. const repoint = (v) => { if (Array.isArray(v)) return v.map(repoint) if (v && typeof v === 'object') { const out = Object.fromEntries(Object.entries(v).map(([k, x]) => [k, repoint(x)])) if (typeof out.uri === 'string' && typeof out.cid === 'string' && cids.has(out.uri)) out.cid = cids.get(out.uri) return out } return v } for (const [i, r] of records.entries()) { const value = repoint({ ...r.value, createdAt: new Date(start + i * 37 * 60_000).toISOString() }) const res = await fetch(`${net.pds}/xrpc/com.atproto.repo.putRecord`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${r.jwt}` }, body: JSON.stringify({ repo: r.did, collection: r.collection, rkey: r.uri.split('/').pop(), record: value }), }) if (!res.ok) throw new Error(`putRecord ${r.uri}: ${await res.text()}`) cids.set(r.uri, (await res.json()).cid) } const item = (kind, uri) => { const [, , did, , rkey] = uri.split('/') return `/alice.test/tally/${kind}/${did}/${rkey}` } const views = { home: '/', search: '/?q=t', start: '/start', profile: '/alice.test', 'profile-fork': '/bob.test', repository: '/alice.test/tally', directory: '/alice.test/tally/tree/main/src', file: '/alice.test/tally/blob/main/src/lib.rs', markdown: '/alice.test/tally/blob/main/README.md', image: '/alice.test/tally/blob/main/assets/logo.svg', commits: '/alice.test/tally/commits/main', commit: `/alice.test/tally/commit/${parse}`, pulls: '/alice.test/tally/pulls', 'pulls-closed': '/alice.test/tally/pulls?state=closed', pull: item('pull', pull), 'pull-open': item('pull', json), issues: '/alice.test/tally/issues', 'issues-closed': '/alice.test/tally/issues?state=closed', issue: item('issue', utf8), 'issue-closed': item('issue', typo), fork: '/bob.test/tally', empty: '/alice.test/scratch', missing: '/alice.test/tally/blob/main/nowhere', } // The pages whose social cards are shot, by the names of the shots. const cards = { home: '/', repository: '/alice.test/tally', notes: '/alice.test/notes', pull: views.pull, 'pull-open': views['pull-open'], issue: views.issue, 'issue-closed': item('issue', typo), } // The index has caught up when no page shows today's date, every // record now being dated before it, and the merge shows. const today = new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' }) await until('the index', async () => { const pages = await Promise.all(Object.values(views).map(page)) const all = pages.join('') return !all.includes(today) && all.includes('Merged as') && all.includes('Forked from') && all.includes('1 Closed') }) return { net, dir, views, cards, pds, server: children[1], stop } } catch (e) { stop() throw e } } if (process.argv[1] === fileURLToPath(import.meta.url)) { const world = await create() world.server.kill() mkdirSync(dirname(saved), { recursive: true }) writeFileSync(saved, JSON.stringify({ net: world.net, dir: world.dir, views: world.views, cards: world.cards }, null, 2)) console.log(`seeded; kept up for \`just shots\` until ctrl-c (${saved})`) const end = () => { rmSync(saved, { force: true }) world.stop() process.exit(0) } process.on('SIGINT', end) process.on('SIGTERM', end) process.stdin.on('end', end) process.stdin.resume() }