// The screenshots without a PDS: what knotless-serve answered for each page, // recorded once in fixtures.json, and played back by a stand-in for it in // front of the pages. The pages are drawn as they are by the real app, from // web/build; only their data is fixed. // // node harness/shots/fixtures.mjs record them again, from the world // `just shots-up` keeps up, or a new // one: `just shots-record` import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { createServer, request } from 'node:http' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import config from './playwright.config.mjs' import { base, create, now, pages, port, root, saved, serve, until, viewers, zoekt, } from './world.mjs' export const fixtures = join(root, 'harness/shots/fixtures.json') /// What knotless-serve answers itself, rather than handing to the pages. const answered = (path) => /^\/xrpc\//.test(path) || /^\/[^/]+\/[^/]+\/raw\//.test(path) /// The headers of an answer worth keeping. const KEPT = ['content-type', 'cache-control', 'location', 'content-security-policy'] /// knotless-serve's place on `port`, in front of the pages (from `build`, /// else this checkout's): answering from `recorded`, or, given `from`, /// asking knotless-serve there and recording what it says. Returns the /// recording, and how to stop. export async function front({ recorded = {}, from, build } = {}) { const drawn = pages(port + 1, base, build) const answer = async (path) => { if (!from) return recorded[path] const res = await fetch(from + path, { redirect: 'manual' }) const body = Buffer.from(await res.arrayBuffer()) const text = body.toString('utf8') const binary = !Buffer.from(text, 'utf8').equals(body) const headers = Object.fromEntries( KEPT.filter((h) => res.headers.has(h)).map((h) => [h, res.headers.get(h)]), ) return (recorded[path] = { status: res.status, headers, ...(binary ? { base64: body.toString('base64') } : { body: text }), }) } // Who a page asked as `signedIn(handle)` is: that account, as its // recorded profile has it, logged in, with nothing unread. const viewing = async (url, handle) => { const json = (body) => ({ status: 200, headers: { 'content-type': 'application/json', 'cache-control': 'no-cache' }, body: JSON.stringify(body), }) if (url === '/xrpc/dev.knotless.getUnreadCount') return json({ count: 0 }) // What `just look --set` answers instead, when it says. if (url.startsWith('/xrpc/dev.knotless.listNotifications')) return (await answer(url).catch(() => null)) ?? json(await heard(handle)) if (url.startsWith('/xrpc/dev.knotless.listSecrets')) { const recorded = await answer(url).catch(() => null) return recorded?.status === 200 ? recorded : json(await kept(url)) } // A repository and a pull or an issue, with what the account has to do // with it, which the server answers anyone logged in: nothing chosen, // so it hears of what it takes part in. Of a pull or an issue, its // author may close it, and the repository's owner close and hide it. if ( url.startsWith('/xrpc/dev.knotless.getRepo?') || url.startsWith('/xrpc/dev.knotless.getThread?') ) { const recorded = await answer(url).catch(() => null) if (recorded?.status !== 200) return null const body = JSON.parse(recorded.body) if (url.startsWith('/xrpc/dev.knotless.getRepo?')) return { ...recorded, body: JSON.stringify({ viewer: {}, ...body }) } const profile = await answer( `/xrpc/dev.knotless.getProfile?actor=${encodeURIComponent(handle)}`, ) const { did } = JSON.parse(profile.body) const owner = new URLSearchParams(url.split('?')[1]) .get('repo') ?.startsWith(`at://${handle}/`) const author = body.item?.author?.did === did const viewer = author ? { setState: true, notifiedFor: 'author' } : owner ? { setState: true, hide: true } : {} return { ...recorded, body: JSON.stringify({ viewer, ...body }) } } if (url !== '/xrpc/dev.knotless.getViewer') return null const profile = await answer( `/xrpc/dev.knotless.getProfile?actor=${encodeURIComponent(handle)}`, ) if (!profile) return null const { did, displayName, avatar } = JSON.parse(profile.body) // Over what was recorded for no one, so what `just look --set` changes holds. const recorded = await answer(url).catch(() => null) const base = recorded?.status === 200 ? JSON.parse(recorded.body) : {} return json({ ...base, actor: { did, handle, displayName, avatar }, login: true }) } // The secrets an owner keeps for the tally, made from the recording, as // they are asked for logged in, which the recording never is: two sealed // to its runner's key, one for main's pushes alone. const kept = async (url) => { const runners = JSON.parse( ( await answer( `/xrpc/dev.knotless.listRunners?repo=${encodeURIComponent('at://alice.test/dev.knotless.repo/tally')}`, ) ).body, ) const [{ runner, key }] = runners.keys const secret = (name, refs, updatedAt) => ({ name, runner, ...(refs ? { refs: refs.map((pattern) => ({ pattern })) } : {}), key, updatedAt, }) return { secrets: [ secret('DEPLOY_TOKEN', ['refs/heads/main'], '2026-10-25T17:52:00Z'), secret('NPM_TOKEN', null, '2026-10-26T09:10:00Z'), ], } } // What `handle` heard of, made from the recording, as no index there // keeps any for a recorded login: bob.test's doings on alice.test's // tally, each reason, its checks failing and ejected, and an invitation to bob.test as an organization, // the newest three not yet seen. const heard = async (handle) => { const read = async (path) => JSON.parse((await answer(path)).body) const owner = await read(`/xrpc/dev.knotless.getProfile?actor=${encodeURIComponent(handle)}`) const bob = await read('/xrpc/dev.knotless.getProfile?actor=bob.test') const items = (kind) => read( `/xrpc/dev.knotless.listItems?repo=${encodeURIComponent(`at://${handle}/dev.knotless.repo/tally`)}&kind=${kind}`, ) const [issue] = (await items('issue')).items const [pull] = (await items('pull')).items const author = { did: bob.did, handle: bob.handle, ...(bob.avatar ? { avatar: bob.avatar } : {}), } const repo = { did: owner.did, handle, name: 'tally' } const at = (hours) => new Date(Date.parse(now) - hours * 3600_000).toISOString() const comment = `at://${bob.did}/dev.knotless.comment/3myvwhm3ntion` const notifications = [ { uri: comment, reason: 'mention', reasonSubject: issue.uri, title: issue.title, text: '@alice.test can you look at this one? It fails on **every** file with a BOM.', isRead: false, createdAt: at(1), }, { uri: `at://${bob.did}/dev.knotless.org/self`, reason: 'invite', role: 'member', isRead: false, createdAt: at(2), }, { uri: pull.uri, reason: 'pull', title: pull.title, isRead: false, createdAt: at(3) }, { uri: `at://${bob.did}/dev.knotless.comment/3myvwhc0mment`, reason: 'comment', reasonSubject: issue.uri, title: issue.title, text: 'Same here on 0.4.2.', isRead: true, createdAt: at(26), }, { uri: `at://${bob.did}/dev.knotless.check/3myvwhch3cks`, reason: 'checks-failed', reasonSubject: pull.uri, title: pull.title, checks: [ { workflow: 'test', status: 'failure' }, { workflow: 'web', status: 'error' }, ], isRead: true, createdAt: at(30), }, { uri: `at://${bob.did}/dev.knotless.queue/3myvwhqu3ue`, reason: 'ejected', reasonSubject: pull.uri, title: pull.title, checks: [{ workflow: 'shots', status: 'failure' }], isRead: true, createdAt: at(40), }, { uri: issue.uri, reason: 'mention', title: issue.title, isRead: true, createdAt: at(50) }, { uri: `at://${bob.did}/dev.knotless.star/3myvwhst4rred`, reason: 'star', isRead: true, createdAt: at(74), }, { uri: `at://${bob.did}/dev.knotless.follow/3myvwhf0llow`, reason: 'follow', isRead: true, createdAt: at(98), }, ].map((n) => ({ ...n, author, ...(n.reason === 'follow' || n.reason === 'invite' ? {} : { repo }), })) return { notifications, seenAt: at(4) } } const server = createServer(async (req, res) => { if (answered(req.url)) { const as = req.headers.cookie?.match(/(?:^|;\s*)shots_as=([^;]+)/)?.[1] // A choice of what to hear of, as the server keeps it, for what one // signed in clicks to look at. if (as && req.url === '/xrpc/dev.knotless.putSubscription') { let input = '' for await (const chunk of req) input += chunk res.writeHead(200, { 'content-type': 'application/json' }) res.end(JSON.stringify({ ...JSON.parse(input), createdAt: now })) return } const a = (as && (await viewing(req.url, decodeURIComponent(as)))) || (await answer(req.url).catch(() => null)) if (!a) { res.writeHead(404, { 'content-type': 'application/json' }) res.end(JSON.stringify({ message: `nothing recorded for ${req.url}` })) return } res.writeHead(a.status, a.headers) res.end(a.base64 === undefined ? a.body : Buffer.from(a.base64, 'base64')) return } const headers = { ...req.headers, 'x-forwarded-host': req.headers.host, 'x-forwarded-proto': 'http', } const on = request( { host: '127.0.0.1', port: port + 1, path: req.url, method: req.method, headers }, (up) => { res.writeHead(up.statusCode, up.headers) up.pipe(res) }, ) on.on('error', (e) => { res.writeHead(502) res.end(`The pages are not being drawn: ${e.message}`) }) req.pipe(on) }) await new Promise((r) => server.listen(port, '127.0.0.1', r)) const kill = () => { server.close() server.closeAllConnections() drawn.kill() // Gone once both have let go of their ports, for the next to take them. const exited = drawn.exitCode !== null || drawn.signalCode !== null return Promise.all([ new Promise((r) => server.once('close', r)), exited ? null : new Promise((r) => drawn.once('exit', r)), ]) } try { await until('the pages', async () => (await fetch(`${base}/start`)).ok) } catch (e) { kill() throw e } return { recorded, kill } } /// The cookies a page is asked with: the zone the browser has said /// (views.spec.mjs), and for a view in `viewers`, who it is signed in as, /// which front() answers getViewer with. export const cookies = (name) => [ `tz=${encodeURIComponent(config.use.timezoneId)}`, ...(viewers[name] ? [signedIn(viewers[name])] : []), ].join('; ') /// The cookie that signs a page in as `handle`, to front() alone. export const signedIn = (handle) => `shots_as=${encodeURIComponent(handle)}` /// The recording in `file`, this checkout's unless given, if there is one. export const recording = (file = fixtures) => existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : null /// Records every page's answers from `world`, and writes them with the pages. async function record(world) { const at = port + 2 const server = await serve(world, { at, alone: true }) const stand = await front({ from: `http://127.0.0.1:${at}` }) try { // Asked as the browser asks (views.spec.mjs), having said its zone and // signed in where the view is, so what the pages ask then is what is kept. for (const [name, path] of Object.entries(world.views)) { const html = await (await fetch(base + path, { headers: { cookie: cookies(name) } })).text() // What the browser asks for itself: images, from the raw files and // the pictures checks made. for (const [, src] of html.matchAll(/src="(\/[^"]+\/(?:raw|image)\/[^"]+)"/g)) await fetch(base + src) } } finally { stand.kill() server.kill() } const sorted = Object.fromEntries( Object.entries(stand.recorded).sort(([a], [b]) => (a < b ? -1 : 1)), ) writeFileSync(fixtures, JSON.stringify({ views: world.views, answers: sorted }, null, 1) + '\n') return Object.keys(sorted).length } if (process.argv[1] === fileURLToPath(import.meta.url)) { if (!zoekt) { console.error( 'zoekt-webserver and zoekt-git-index are not on PATH: the code views record what they find (see the zoekt step in .knotless/workflows/test.yml)', ) process.exit(1) } const kept = existsSync(saved) ? JSON.parse(readFileSync(saved, 'utf8')) : null const up = kept && (await fetch(`${kept.net.pds}/xrpc/com.atproto.server.describeServer`).then( (r) => r.ok, () => false, )) const world = up ? kept : await create() // A new world's server is stopped first: the recording starts its own. if (!up) world.server.kill() const n = await record(world) if (!up) world.stop() console.log(`recorded ${n} answers for ${Object.keys(world.views).length} pages in ${fixtures}`) process.exit(0) }