// Regenerates docs/img/*.png: real screenshots of the popup in each status // state, plus toolbar badge tiles rendered from the badge specs of the // running service worker (via the __substandard debug hook). Runs a real // headless Chrome with the built extension loaded, twice: // // - offline: external DNS is mapped to 127.0.0.1 and the pages come from // local fixture servers, so the failure states (no publication, fetch // failed, offline) are real failures and nothing leaves the machine. // - online: real network, real standard.site publications (see REAL), so // every state that shows a publication card shows a real one — the store // listing screenshots are composed from these. The one exception is the // moderated card (see BAD_BLOG): no real publisher is going to model // "blocked, and labeled by two labelers", so that publication is invented // and the labelers' answers about it are stubbed. The labelers themselves // are real, and so is every other step — nothing about how a label reads // is asserted by this script. // // Usage: // // npm run capture # builds the extension first, then re-shoots docs/img/ // npm run recapture # the above, then every asset derived from the shots // // The captures are of the built extension in dist/, never of the tree directly, // so a run against a stale build is refused (assertFreshBuild) rather than // quietly screenshotting the previous popup. // // The toolbar itself does not render in headless Chrome, so the badge tiles // are drawn by Chrome from the worker's authoritative BadgeSpec values on a // toolbar-like ground; the popup screenshots are the real popup end to end. import { spawn } from 'node:child_process' import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync, } from 'node:fs' import { createServer } from 'node:http' import { tmpdir } from 'node:os' import path from 'node:path' import process from 'node:process' // The same constant the extension defaults its labeler list to, so the stubbed // moderation capture cannot end up naming a labeler the popup never asks. import { BSKY_LABELER_DID } from '@atproto/api' const ROOT = path.resolve(import.meta.dirname, '..') const DIST = path.join(ROOT, 'dist') const OUT = path.join(ROOT, 'docs', 'img') const CHROME = process.env.CHROME_BIN ?? 'google-chrome' // What the popup header should be showing: it reads its own manifest, and the // captures run against the built extension. Filled in by main(), which has // checked dist/ exists by then. let BUILT_VERSION const FIXTURE_DID = 'did:plc:capturefixture000000000000' const FAKE_AT_URI = `at://${FIXTURE_DID}/site.standard.publication/3kcapture` // Real publications for the online run. Each reaches detection by a different // route, so the captured states cover the detection paths as well as the // popup layouts (all three confirmed live 2026-08-12). // // Sites and projects only, never a person's personal blog: these pages are // shown in the docs, in the store listing and on substandard.blog, and // nobody consented to being the extension's model. The one publication here // that belongs to an individual is the author's own. const REAL = { // No hint at all — found by the origin well-known probe alone. standardSite: 'https://standard.site/', // Carries a hint, and verifies // through the path-scoped /.well-known/site.standard.publication/blog — // the origin well-known is a 404 here. atprotoBlog: 'https://atproto.com/blog', // The extension author's own publication, and the belt-and-braces shape: // it emits the hint *and* answers the origin well-known, so either // route alone would find it. // // Handle -> DID normalization is not exercised by any live capture — this // well-known answers in DID form — and lives in the unit tests instead // (src/lib/detection.test.ts, src/lib/atproto.test.ts). permadeath: 'https://permadeath.com/', } // A publication nobody should subscribe to: its account is one you blocked, // and two labelers you listen to have labeled it. Invented end to end — an // obvious placeholder name on example.com — because no real publication is // going to model "moderators have judged this one" in the store listing. // // Only the subject is a fixture. The labelers below are real accounts with // real service records, and the labels are stubbed as their *answers* about // this fixture (see stubbedLabels), so the pills are named, ranked and // covered by the labelers' own definitions rather than by anything invented // here. const BAD_BLOG_DID = 'did:plc:0xdeadbeef00000000000000' /** An inline SVG as a data URI, which is all an in the popup needs. */ function svgDataUri(svg) { return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}` } /** * The fixture's picture, standing in for both the account's avatar and the * publication's icon: a puppy with the no-entry sign over it. Emoji rather * than drawn art, so a placeholder still looks like one and the whole fixture * stays in this file instead of becoming a committed asset. * * Both glyphs are drawn by the capture machine's own color-emoji font (Noto * Color Emoji on Linux). A machine without one shoots tofu boxes here; there * is nothing to assert against, so looking at the shot is the check. */ const BAD_BLOG_AVATAR = svgDataUri( '' + '🐶' + '🚫' + '', ) const BAD_BLOG = { uri: `at://${BAD_BLOG_DID}/site.standard.publication/3kpuppyhater`, did: BAD_BLOG_DID, handle: 'puppyhater.example.com', // Nothing resolvable: no PDS answers for a fixture DID, so the icon is // inlined above rather than being a blob url on the publisher's own server. pds: 'https://pds.invalid', iconUrl: BAD_BLOG_AVATAR, record: { $type: 'site.standard.publication', url: 'https://puppyhater.example.com/', name: 'We Hate Puppies', description: 'An independent platform for free thought.' }, verified: true, } // The account behind it. No directory can resolve a fixture DID, so the card // the popup would build from that account's own data is stubbed too — the // popup renders it exactly as it renders a resolved one. const BAD_BLOG_OWNER = { did: BAD_BLOG_DID, handle: BAD_BLOG.handle, claimedHandle: BAD_BLOG.handle, displayName: 'i literally hate puppies', description: 'Also sunshine. And kittens. And rainbows. And puppies.', avatarUrl: BAD_BLOG_AVATAR, } /** * The labelers whose answers about the fixture are stubbed, and what each one * says. Both values are ones the labeler really declares (`intolerant` is * Bluesky's, `platform-manipulation` is skywatch.blue's), so a labeler that * drops or renames a value fails the capture instead of quietly relabelling * the docs. */ const STUB_LABELERS = [ // Bluesky's own moderation service: on the account, not the publication — // what a labeler says about who is publishing. { did: BSKY_LABELER_DID, subject: BAD_BLOG.did, values: ['intolerant'] }, // skywatch.blue, a third-party labeler, on the publication record itself. { did: 'did:plc:e4elbtctnfqocyfcml6h2lf7', subject: BAD_BLOG.uri, values: ['platform-manipulation'], }, ] /** Subscribe has taken its first click on a blocked publisher and is armed. */ const SUBSCRIBE_ARMED = `document.getElementById('subscribe').classList.contains('confirm')` // Real detection over the network; generous next to the local fixtures. const SETTLE_MS = 45000 /** * The reader each card capture is preset to open in (storage.local's * `defaultReader`, the key the popup reads), so the set shows the readers the * extension offers rather than the default four times over. * * Every reader offered now has both a publication and an article view (see * src/lib/readers.ts), so any of them can be preset here without the capture * catching a disabled button. The value is the vendored icon the popup draws for that * reader, which is the only thing in the popup's DOM that names the reader by * id — the label comes from the Waypoints catalog. A stored id the catalog no * longer has falls back to the first reader, so checking the icon is what * makes that fail the capture instead of quietly reshooting the default. */ const READER_ICONS = { standardReader: 'standard.svg', leaflet: 'leaflet.png', pdsls: 'pdsls.png', } /** Standard Reader's view of a publication: a real page that is not its home. */ function readerUrl(pub) { return `https://standard-reader.app/p/${pub.did}/${pub.uri.split('/').pop()}` } // --- tiny CDP client over the browser websocket (flattened sessions) -------- class Cdp { /** @param {WebSocket} ws */ constructor(ws) { this.ws = ws this.nextId = 1 this.pending = new Map() /** Event name -> handler; one at a time, which is all the captures need. */ this.events = new Map() ws.addEventListener('message', (ev) => { const msg = JSON.parse(ev.data) if (msg.id && this.pending.has(msg.id)) { const { resolve, reject } = this.pending.get(msg.id) this.pending.delete(msg.id) if (msg.error) reject(new Error(`${msg.error.message} (${msg.error.code})`)) else resolve(msg.result) } else if (msg.method) { this.events.get(msg.method)?.(msg.params, msg.sessionId) } }) } /** Subscribe to a CDP event. The handler gets (params, sessionId). */ on(method, handler) { this.events.set(method, handler) } off(method) { this.events.delete(method) } static connect(url) { return new Promise((resolve, reject) => { const ws = new WebSocket(url) ws.addEventListener('open', () => resolve(new Cdp(ws))) ws.addEventListener('error', () => reject(new Error(`websocket connect failed: ${url}`))) }) } send(method, params = {}, sessionId = undefined) { const id = this.nextId++ return new Promise((resolve, reject) => { this.pending.set(id, { resolve, reject }) this.ws.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })) }) } close() { this.ws.close() } } /** Runtime.evaluate an async expression and return its JSON value. */ async function evaluate(cdp, sessionId, expression) { const res = await cdp.send( 'Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }, sessionId, ) if (res.exceptionDetails) { throw new Error(`evaluate failed: ${res.exceptionDetails.text} ${res.exceptionDetails.exception?.description ?? ''}`) } return res.result.value } async function poll(fn, what, timeoutMs = 8000) { const start = Date.now() for (;;) { const value = await fn() if (value) return value if (Date.now() - start > timeoutMs) throw new Error(`timed out waiting for ${what}`) await new Promise((r) => setTimeout(r, 150)) } } // --- when a popup is done ----------------------------------------------------- // // These run in the popup, not here: they are stringified into a // Runtime.evaluate (see popupExpression), which is why they take `document` // rather than closing over it, and why the one helper they share is put back // beside them there. Kept as functions so the rule the captures depend on can // be unit tested (capture-status-docs.test.mjs). /** Every image the popup has actually asked for has finished, one way or another. */ export function imagesLanded(document) { // `complete` is true for an image that failed as well as one that loaded, // which is what this wants: a publication whose icon 404s is a real state, // and the popup draws its monogram fallback for it. An `img` with no `src` // is one nothing has been asked of yet — `complete` is true for it too, and // that is the trap this whole file exists to avoid. return [...document.images].every((img) => !img.getAttribute('src') || img.complete) } /** * A popup with nothing left to wait for: no lookup out, no placeholder * standing, every image landed. * * One question rather than three waits in a row, because each of these * *creates* the next. The account lookup is what gives `#owner-avatar` its * `src`, so an images check that ran before that lookup landed was a check of * a popup that had no avatar yet — it passed, and the shot went out with a * grey circle where the account's picture goes. Asked as one thing, it cannot * be satisfied by three answers that were never true at the same moment. */ export function popupSettled(document) { // The card element itself, so a build without it fails here rather than // passing a check that found nothing to object to. if (!document.getElementById('pub')) return false // Every region that reports its own work: the publication card while a card // lookup is out, the status region while the worker has not answered about // the page at all. Nothing else in the popup sets it. if (document.querySelector('[aria-busy]')) return false if (document.querySelector('.loading')) return false return imagesLanded(document) } /** * The opposite, for the one capture that is *of* the placeholders: its lookups * are held open and never land, so what it waits for is the card saying it is * busy with a placeholder actually standing (src/popup/cards/loading.ts). * Stated rather than assumed, so a capture of the loading state that caught * nothing loading fails instead of shooting an ordinary popup. */ export function popupStalled(document) { const pub = document.getElementById('pub') if (!pub || !pub.hasAttribute('aria-busy')) return false if (!document.querySelector('.loading')) return false return imagesLanded(document) } /** * One of the predicates above as an expression the popup can evaluate. The * helper is declared alongside it: a stringified function arrives with nothing * it referred to, so what it called by name has to be in scope where it lands. */ export function popupExpression(predicate) { return `(() => { const imagesLanded = ${imagesLanded} return (${predicate})(document) })()` } /** * Wait for the popup to reach `predicate`, hold, and ask again — the shot is * taken from a state that survived the hold rather than from one that was true * some hundreds of milliseconds and several round trips before the shutter. */ async function settle(cdp, sessionId, predicate, holdMs, what) { const expr = popupExpression(predicate) await poll( async () => { if (!(await evaluate(cdp, sessionId, expr))) return false await new Promise((r) => setTimeout(r, holdMs)) return await evaluate(cdp, sessionId, expr) }, what, SETTLE_MS, ) } // --- fixture servers --------------------------------------------------------- /** Plain page; its /.well-known probe 404s, so detection finds nothing. */ function plainServer() { return createServer((req, res) => { if (req.url === '/') { res.writeHead(200, { 'content-type': 'text/html' }) res.end('capture fixture

capture fixture page

') } else { res.writeHead(404) res.end() } }) } /** * Publication-claiming page: the well-known endpoint answers with an at-uri * whose DID cannot resolve (external DNS is mapped to localhost), so record * loading genuinely fails and detection reports fetch-failed. */ function claimingServer() { return createServer((req, res) => { if (req.url === '/.well-known/site.standard.publication') { res.writeHead(200, { 'content-type': 'text/plain' }) res.end(FAKE_AT_URI) } else if (req.url === '/') { res.writeHead(200, { 'content-type': 'text/html' }) res.end('capture fixture

claims a publication

') } else { res.writeHead(404) res.end() } }) } function listen(server) { return new Promise((resolve) => { server.listen(0, '127.0.0.1', () => resolve(server.address().port)) }) } // --- stubbed labeler answers ------------------------------------------------- // // There is no local stand-in for a labeler: it is addressed by DID and the // extension refuses a cleartext labeler endpoint, rightly. So the moderation // capture leaves every step real — resolving each labeler's DID document, its // service record, and interpreting the label value definitions in it — and // stubs only the one request that would otherwise answer "nothing to say // about this fixture": com.atproto.label.queryLabels. /** Fixed so the capture is reproducible; the popup never shows a label's date. */ const LABEL_CTS = '2026-08-12T00:00:00.000Z' /** How many pills the stub should produce, all labelers together. */ const STUB_LABEL_COUNT = STUB_LABELERS.reduce((n, l) => n + l.values.length, 0) /** A labeler's query host, resolved from its DID document as the popup does. */ async function labelerHost(did) { const res = await fetch(`https://plc.directory/${did}`) if (!res.ok) throw new Error(`plc.directory answered ${res.status} for ${did}`) const doc = await res.json() const endpoint = doc.service?.find((s) => s.id.endsWith('#atproto_labeler'))?.serviceEndpoint if (!endpoint) throw new Error(`no labeler service in the DID document for ${did}`) return new URL(endpoint).host } /** The stub labelers, keyed by the host each one answers queryLabels on. */ async function stubLabelersByHost() { const byHost = new Map() for (const labeler of STUB_LABELERS) { const host = await labelerHost(labeler.did) byHost.set(host, labeler) console.log(`capture: stubbing ${host} (${labeler.did}) -> ${labeler.values.join(', ')}`) } return byHost } /** * Answer one target's queryLabels calls from the stub, leaving every other * request alone. Which labeler answers is decided by the host the popup * called, so the attribution in the pill is the popup's own resolution rather * than something asserted here. Returns the set of labelers actually asked. */ async function stubLabelerAnswers(cdp, sessionId, byHost) { const asked = new Set() cdp.on('Fetch.requestPaused', ({ requestId, request }, from) => { if (from !== sessionId) return const labeler = byHost.get(new URL(request.url).host) if (!labeler) { void cdp.send('Fetch.continueRequest', { requestId }, sessionId) return } asked.add(labeler.did) const labels = labeler.values.map((val) => ({ ver: 1, src: labeler.did, uri: labeler.subject, val, cts: LABEL_CTS, })) void cdp.send( 'Fetch.fulfillRequest', { requestId, responseCode: 200, responseHeaders: [ { name: 'content-type', value: 'application/json' }, { name: 'access-control-allow-origin', value: '*' }, ], body: Buffer.from(JSON.stringify({ labels })).toString('base64'), }, sessionId, ) }) await cdp.send( 'Fetch.enable', { patterns: [{ urlPattern: '*com.atproto.label.queryLabels*' }] }, sessionId, ) return asked } /** * Hold some of one popup's lookups open, so a capture can show what it draws * while it waits. The requests are paused and never answered — a slow network, * not a failed one, which is the difference between the state the placeholders * are for and the state the error pills are for. */ async function stallRequests(cdp, sessionId, patterns) { cdp.on('Fetch.requestPaused', ({ request }, from) => { if (from !== sessionId) return console.log(`capture: holding ${new URL(request.url).host} open`) }) await cdp.send( 'Fetch.enable', { patterns: patterns.map((p) => ({ urlPattern: `*${p}*` })) }, sessionId, ) } // --- main -------------------------------------------------------------------- /** Files whose contents end up in the loaded extension, newest mtime wins. */ function sourceFiles() { const src = path.join(ROOT, 'src') const nested = readdirSync(src, { recursive: true, withFileTypes: true }) .filter((e) => e.isFile()) .map((e) => path.join(e.parentPath ?? e.path, e.name)) return [ ...nested, ...['popup.html', 'offscreen.html', 'vite.config.ts', 'package.json'].map((f) => path.join(ROOT, f), ), ] } /** * The capture screenshots whatever is in dist/, not the tree it is run from, so * an unbuilt change captures the previous popup. That failure surfaces as a * settle timeout on a state the built popup does not have, which reads as a * broken script rather than a stale build — hence a mtime check up front. * * The version in dist/manifest.json is no help here: it only changes on a * release bump, so a stale build can carry the current version. */ function assertFreshBuild() { const built = ['popup.js', 'background.js', 'content.js', 'manifest.json'].map((f) => path.join(DIST, f), ) for (const file of built) { if (!existsSync(file)) { throw new Error(`${path.relative(ROOT, file)} is missing — run \`npm run capture\``) } } const builtAt = Math.min(...built.map((f) => statSync(f).mtimeMs)) const stale = sourceFiles().filter((f) => statSync(f).mtimeMs > builtAt) if (stale.length) { throw new Error( `dist/ is older than ${stale.length} source file(s), starting with ` + `${path.relative(ROOT, stale[0])} — run \`npm run capture\`, which builds first`, ) } } /** * `--only ` re-shoots one capture and leaves the rest of docs/img * alone. Every image here is a real screenshot over the real network, so a * full run rewrites fifteen files to say one thing; this is how a change to * one popup state is captured without a diff full of resampled pixels. */ const ONLY = (() => { const at = process.argv.indexOf('--only') if (at === -1) return undefined const name = process.argv[at + 1] if (!name) throw new Error('--only needs the name of a capture, e.g. --only popup-loading') return name })() /** Whether this run is shooting `name`. */ function wanted(name) { return !ONLY || name === ONLY } async function main() { assertFreshBuild() BUILT_VERSION = JSON.parse(readFileSync(path.join(DIST, 'manifest.json'), 'utf8')).version mkdirSync(OUT, { recursive: true }) // Both runs start whatever `--only` selected; the one whose captures were // all filtered out spends a couple of seconds on a browser it never uses, // which is cheaper than keeping a second list of which run shoots what. await withChrome({ online: false }, captureFixtureStates) await withChrome({ online: true }, captureRealStates) console.log(`capture: wrote images to ${OUT}`) } /** * Run `fn({ cdp, inWorker, popupUrl })` against a throwaway headless Chrome * with the built extension loaded. Offline runs map all non-loopback DNS to * localhost, where nothing listens. */ async function withChrome({ online }, fn) { const profile = path.join(tmpdir(), `substandard-capture-${process.pid}-${online ? 'online' : 'offline'}`) const chrome = spawn(CHROME, [ '--headless=new', '--remote-debugging-port=0', `--user-data-dir=${profile}`, '--no-first-run', '--no-default-browser-check', '--disable-sync', '--enable-unsafe-extension-debugging', `--load-extension=${DIST}`, ...(online ? [] : ['--host-resolver-rules=MAP * 127.0.0.1']), '--window-size=800,700', ]) const wsUrl = await new Promise((resolve, reject) => { let err = '' chrome.stderr.on('data', (chunk) => { err += chunk const m = err.match(/DevTools listening on (ws:\/\/\S+)/) if (m) resolve(m[1]) }) chrome.on('exit', (code) => reject(new Error(`chrome exited early (${code}):\n${err}`))) }) const cdp = await Cdp.connect(wsUrl) try { // The branded Chrome build ignores --load-extension; fall back to the // debugger-mediated load when no extension worker shows up. let worker = await findWorker(cdp, 2000).catch(() => undefined) if (!worker) { console.log('capture: --load-extension ignored; using Extensions.loadUnpacked') await cdp.send('Extensions.loadUnpacked', { path: DIST }) worker = await findWorker(cdp, 8000) } const { sessionId } = await cdp.send('Target.attachToTarget', { targetId: worker.targetId, flatten: true }) const extensionId = new URL(worker.url).host console.log(`capture: extension ${extensionId} worker attached`) const inWorker = (expr) => evaluate(cdp, sessionId, expr) await poll(() => inWorker('typeof __substandard === "object"'), 'worker debug hook') await fn({ cdp, inWorker, popupUrl: `chrome-extension://${extensionId}/popup.html` }) } finally { cdp.close() // Wait for Chrome to actually exit before deleting the profile: kill() // only sends the signal, and Chrome rewrites profile files on shutdown, // which resurrects the directory after an early rmSync. const exited = new Promise((resolve) => { if (chrome.exitCode !== null) return resolve() chrome.once('exit', resolve) setTimeout(resolve, 5000).unref() }) chrome.kill() await exited // Even after the main process exits, Chrome's service children (network, // cache) flush profile files behind it; delete until the directory stays // gone instead of racing them once. for (let attempt = 0; attempt < 10; attempt++) { rmSync(profile, { recursive: true, force: true }) await new Promise((r) => setTimeout(r, 300)) if (!existsSync(profile)) break } if (existsSync(profile)) console.warn(`capture: could not fully remove ${profile}`) } } // --- offline run: badge tiles and the states that are failures --------------- async function captureFixtureStates({ cdp, inWorker, popupUrl }) { const servers = { plain: plainServer(), claiming: claimingServer() } const urls = { plain: `http://127.0.0.1:${await listen(servers.plain)}/`, claiming: `http://127.0.0.1:${await listen(servers.claiming)}/`, } try { if (BADGE_STATES.some((state) => wanted(`badge-${state}`))) await captureBadges(cdp, inWorker) /** @type {Scenario[]} */ const scenarios = [ // Natural end-to-end detection: nothing on the page. { name: 'popup-none', url: urls.plain, expectPills: 1, expectCard: false }, // Stacked pills: real fetch-failed detection (error) + expired session (info). { name: 'popup-stack', url: urls.claiming, session: true, expectPills: 2, expectCard: false }, // Real offline detection: the worker's navigator reports no connection. { name: 'popup-offline', url: urls.plain, workerOffline: true, expectPills: 1, expectCard: false }, ] for (const s of scenarios.filter((s) => wanted(s.name))) { await captureScenario(cdp, inWorker, popupUrl, s) } } finally { servers.plain.close() servers.claiming.close() } } // --- online run: the states that show a publication -------------------------- async function captureRealStates({ cdp, inWorker, popupUrl }) { // Both of these are network setup for one capture each, so `--only` skips // the one it is not shooting. // // Detected up front so the unverified capture can show this publication // from a page that is not its home. const atproto = wanted('popup-unverified') ? await probePublication(inWorker, REAL.atprotoBlog) : undefined // Resolved against the real network, so a labeler that moved its endpoint // fails the capture rather than silently going unstubbed. const stubLabels = wanted('popup-labeled') ? await stubLabelersByHost() : undefined // The moderated publication is invented, so its page is local: a plain page // detection finds nothing on, with the fixture state seeded over it. const plain = plainServer() const plainUrl = `http://127.0.0.1:${await listen(plain)}/` /** @type {Scenario[]} */ const scenarios = [ // The store listing screenshot: a verified publication, signed out, after // clicking the inert Subscribe. Detection is real end to end — no hint // tag on the page, so the origin well-known probe found it. { name: 'popup-publication', url: REAL.standardSite, reader: 'standardReader', clicks: [{ sel: '#subscribe' }], expectPills: 1, expectCard: true, }, // Signed in and subscribed, on the author's own publication. Detection is // real end to end; only the subscription and the account mirror are // fixtures, so no real subscription record is written. { name: 'popup-subscribed', url: REAL.permadeath, reader: 'leaflet', needsPub: true, state: (detected) => ({ ...detected, subscriptionRkey: '3kcapturesub' }), session: true, popupOffline: true, expectPills: 0, expectCard: true, }, // The publication belongs to an account you blocked, with Subscribe // clicked once so the armed confirm is what the shot shows. Detection is // real, but the owner is rewritten to the capture fixture: the block is // invented, and the docs must not show a real account as blocked. The // icon url is left alone, so the card still loads the real icon. An // organization's publication rather than a person's, for the same reason // the handle is a fixture. { name: 'popup-blocked', url: REAL.atprotoBlog, reader: 'standardReader', needsPub: true, state: (detected) => ({ ...detected, pub: { ...detected.pub, did: FIXTURE_DID, handle: 'blocked.example.com' }, blocked: true, subscriptionRkey: null, }), session: true, popupOffline: true, // The pill is up from the start here; the click only arms the button. settledPills: 1, clicks: [{ sel: '#subscribe', settles: SUBSCRIBE_ARMED }], expectPills: 1, expectCard: true, }, // A real publication seen from a third-party reader, which is not its // verified home: the warn pill links back to the real one. { name: 'popup-unverified', url: atproto && readerUrl(atproto), reader: 'leaflet', state: (detected) => ({ ...detected, pub: { ...atproto, verified: false }, subscriptionRkey: undefined, }), expectPills: 1, expectCard: true, }, // Everything at once, and the only card in the set that is a fixture from // top to bottom: an account you blocked, publishing something two labelers // you listen to have both labeled. The publication is invented (see // BAD_BLOG) and the labels are stubbed answers from real labelers, so the // pill names, their severity and the cover all come from those labelers' // own definitions. // // Both values blur content, so the popup covers the card as soon as the // labels land; the capture clicks "Show anyway" and shoots what a reader // who asked for it sees, the cover having already done its job. { name: 'popup-labeled', url: plainUrl, reader: 'pdsls', session: true, popupOffline: true, owner: BAD_BLOG_OWNER, labelers: STUB_LABELERS.map((l) => l.did), stubLabels, state: (detected) => ({ ...detected, pub: BAD_BLOG, blocked: true, subscriptionRkey: null, }), // Wait for the cover before clicking it away: the labels arrive after // the blocked pill does, and clicking early would shoot an uncovered // card that the cover had never been in front of. settled: `!document.getElementById('label-cover').hidden`, settledPills: 1, clicks: [ // Past the cover, then one click of Subscribe: blocked publishers take // two, and the shot is of the armed confirm the second click would go // through with — the whole point of the state. { sel: '#label-show', settles: `document.getElementById('label-cover').hidden && document.querySelectorAll('#labels .label-pill').length === ${STUB_LABEL_COUNT}`, }, { sel: '#subscribe', settles: SUBSCRIBE_ARMED }, ], expectPills: 1, expectCard: true, }, // The same publication as popup-publication, caught while it is still // waiting: the account lookup and the subscriber count are held open, so // the card shows the placeholders it draws once a lookup has run past // SHOW_AFTER_MS (src/popup/cards/loading.ts). Held open rather than // hurried — the popup sees a slow network, not a failure, which is the // state the placeholders exist for. { name: 'popup-loading', url: REAL.standardSite, reader: 'standardReader', stall: ['plc.directory', 'constellation.microcosm.blue'], // Long enough that the delay has passed with room to spare, whatever // the machine running this is doing. hold: 800, expectPills: 0, expectCard: true, }, ] try { for (const s of scenarios.filter((s) => wanted(s.name))) { await captureScenario(cdp, inWorker, popupUrl, s) } } finally { plain.close() } } /** * Open a page, let detection run for real, and return the verified PubInfo it * produced. Throws when the page does not verify, so a publication that moved * or went away fails the capture instead of quietly changing the docs. */ async function probePublication(inWorker, url) { const tab = await inWorker(`chrome.tabs.create({ url: ${JSON.stringify(url)}, active: true })`) try { await poll( () => inWorker(`chrome.tabs.get(${tab.id}).then((t) => t.status === 'complete')`), `tab load for ${url}`, SETTLE_MS, ) const state = await tabState(inWorker, tab.id, true, `publication detection on ${url}`) if (!state.pub.verified) throw new Error(`${url}: publication detected but not verified`) console.log(`capture: ${url} -> ${state.pub.record.name} ${state.pub.uri}`) return state.pub } finally { await inWorker(`chrome.tabs.remove(${tab.id})`).catch(() => {}) } } /** Wait for the worker's own detection state for a tab, optionally with a pub. */ function tabState(inWorker, tabId, needsPub, what) { const key = `tab:${tabId}` return poll( () => inWorker( `chrome.storage.session.get(${JSON.stringify(key)}).then((r) => { const s = r[${JSON.stringify(key)}] return s && (${!needsPub} || s.pub) ? s : false })`, ), what, SETTLE_MS, ) } async function findWorker(cdp, timeoutMs) { return poll( async () => { const { targetInfos } = await cdp.send('Target.getTargets') return targetInfos.find((t) => t.type === 'service_worker' && t.url.includes('background.js')) }, 'extension service worker', timeoutMs, ) } // --- badge tiles ------------------------------------------------------------- const BADGE_STATES = [ 'none', 'checking', 'detected', 'signedout', 'subscribed', 'detected-unverified', 'signedout-unverified', 'subscribed-unverified', 'failed', 'blocked', ] async function captureBadges(cdp, inWorker) { const iconUri = `data:image/png;base64,${readFileSync(path.join(DIST, 'icons', 'icon32.png')).toString('base64')}` for (const state of BADGE_STATES) { const spec = await inWorker(`__substandard.badgeFor(${JSON.stringify(state)})`) const badge = spec ? spec.text === '•' ? // Chrome renders the checking dot compactly; keep the tile honest. `` : `${spec.text}` : '' const html = `${badge}` const png = await screenshotPage(cdp, `data:text/html,${encodeURIComponent(html)}`, { width: 84, height: 68, }) writeFileSync(path.join(OUT, `badge-${state}.png`), png) console.log(`capture: badge-${state}.png ${spec ? `"${spec.text}" on ${spec.background}` : '(no badge)'}`) } } /** Docs show the canonical light rendering; headless follows the host theme. */ function forceLightScheme(cdp, sessionId) { return cdp.send( 'Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: 'light' }] }, sessionId, ) } async function screenshotPage(cdp, url, { width, height }) { const { targetId } = await cdp.send('Target.createTarget', { url: 'about:blank' }) const { sessionId } = await cdp.send('Target.attachToTarget', { targetId, flatten: true }) try { await cdp.send('Page.enable', {}, sessionId) await cdp.send( 'Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 2, mobile: false }, sessionId, ) await forceLightScheme(cdp, sessionId) await cdp.send('Page.navigate', { url }, sessionId) await poll(() => evaluate(cdp, sessionId, 'document.readyState === "complete"'), `load of ${url.slice(0, 40)}`) await new Promise((r) => setTimeout(r, 150)) const shot = await cdp.send( 'Page.captureScreenshot', { format: 'png', clip: { x: 0, y: 0, width, height, scale: 1 } }, sessionId, ) return Buffer.from(shot.data, 'base64') } finally { await cdp.send('Target.closeTarget', { targetId }).catch(() => {}) } } // --- popup scenarios --------------------------------------------------------- /** * @typedef {object} Scenario * @property {string} name output basename in docs/img/ * @property {string} url page the popup opens over * @property {(state: object) => object} [state] seed for the tab's detection * state, built from the state the worker detected on its own * @property {boolean} [needsPub] wait for real detection to find a * publication before seeding (the seed derives from it) * @property {boolean} [session] install the fixture account mirror * @property {keyof READER_ICONS} [reader] reader this capture is preset to * open in, checked against the button the popup rendered * @property {boolean} [popupOffline] * @property {boolean} [workerOffline] * @property {object} [owner] publication-owner card to seed the popup's * own cache with, for a fixture account no directory can resolve; its * `handle` is what the rendered card is checked against afterwards * @property {string[]} [labelers] labeler DIDs to put in storage.local, so * the popup asks these rather than only Bluesky's own moderation service * @property {Map} [stubLabels] stub labeler answers by host * (stubLabelersByHost); queryLabels is served from these for this popup * @property {string[]} [stall] url substrings whose requests this popup never * gets an answer to, for capturing what it shows while it waits * @property {number} [hold] ms the popup must stay settled for before the * shot (default 200); a capture of a transient state waits out its own delays * @property {{sel: string, settles?: string}[]} [clicks] clicked in order once * the popup settles, each waiting for its own `settles` expression (or for * the popup to reach expectPills) before the next one * @property {number} [settledPills] pills expected before the clicks (0 when * a click is what raises them) * @property {string} [settled] extra expression that must be true before * the popup counts as settled (and before any click) * @property {number} expectPills * @property {boolean} expectCard */ /** What the popup is showing right now, for a settle that never happened. */ function popupShape(cdp, sessionId) { return evaluate( cdp, sessionId, // "missing" rather than a boolean for the sections: an element this popup // does not have at all means the loaded build is not the one being // captured, and that has to be distinguishable from it being hidden. `(() => { const where = (id) => { const el = document.getElementById(id) return !el ? 'missing' : el.hidden ? 'hidden' : 'shown' } return JSON.stringify({ readyState: document.readyState, pills: [...document.querySelectorAll('#status .pill')].map((p) => p.className + ': ' + p.textContent), card: where('pub'), cover: where('label-cover'), labels: [...document.querySelectorAll('#labels .label-pill')].map((p) => p.textContent), }) })()`, ) } /** @param {Scenario} s */ async function captureScenario(cdp, inWorker, popupUrl, s) { // Fresh storage per scenario; the fixture session mirror has no restorable // OAuth session behind it, which is exactly what makes the expiry pill real. await inWorker('chrome.storage.session.clear().then(() => chrome.storage.local.clear())') if (s.session) { await inWorker( `chrome.storage.local.set({ session: { did: 'did:plc:capturefixture000000000000', handle: 'you.example.com' } })`, ) } if (s.labelers) { // The key labelerDids() reads: with no restorable session behind the // fixture mirror, the popup falls back to this list rather than the // account's own labeler subscriptions. await inWorker(`chrome.storage.local.set({ labelers: ${JSON.stringify(s.labelers)} })`) } if (s.reader) { if (!READER_ICONS[s.reader]) throw new Error(`${s.name}: no icon known for reader ${s.reader}`) await inWorker(`chrome.storage.local.set({ defaultReader: ${JSON.stringify(s.reader)} })`) } // Put the worker's connection state where this scenario wants it. Restoring // means putting the *native* accessor back: deleting it leaves // navigator.onLine undefined, which computeState reads as being offline, so // every capture after the first would have claimed a disconnected browser. // The descriptor is stashed on the worker's globalThis, and re-read from a // pristine prototype whenever the worker has been restarted since. await inWorker(`(() => { const proto = WorkerNavigator.prototype globalThis.__captureOnLine ??= Object.getOwnPropertyDescriptor(proto, 'onLine') Object.defineProperty( proto, 'onLine', ${s.workerOffline ? `{ get: () => false, configurable: true }` : `globalThis.__captureOnLine`}, ) return navigator.onLine })()`) const tab = await inWorker(`chrome.tabs.create({ url: ${JSON.stringify(s.url)}, active: true })`) await poll( () => inWorker(`chrome.tabs.get(${tab.id}).then((t) => t.status === 'complete')`), `tab load for ${s.name}`, SETTLE_MS, ) if (s.state) { // Seed only once the worker has written its own state for the tab: a // late content-script report would otherwise land on top of the seed. const detected = await tabState(inWorker, tab.id, !!s.needsPub, `detection state for ${s.name}`) const seeded = s.state(detected) await inWorker(`chrome.storage.session.set({ 'tab:${tab.id}': ${JSON.stringify(seeded)} })`) } if (s.owner) { // The popup's own cache entry, in the layout src/lib/cache.ts writes: // `:/` holding `{ at, value }`. Hand-written, because // this script cannot import the extension's TypeScript — so the capture // checks afterwards that the card really came from the seed (see below) // instead of trusting this key to still be the right one. await inWorker( `chrome.storage.session.set({ 'world:${s.owner.did}/owner': { at: Date.now(), value: ${JSON.stringify(s.owner)} } })`, ) } // Open the popup page in its own target, keeping the fixture tab active so // the popup's active-tab query sees it, not itself. const { targetId } = await cdp.send('Target.createTarget', { url: 'about:blank' }) const { sessionId } = await cdp.send('Target.attachToTarget', { targetId, flatten: true }) try { await cdp.send('Page.enable', {}, sessionId) // Roomier than any popup: the popup sizes itself (body has a fixed width // in popup.css), and the shot is clipped to what it actually took, so // widening the popup can never crop the captures again. await cdp.send( 'Emulation.setDeviceMetricsOverride', { width: 600, height: 900, deviceScaleFactor: 2, mobile: false }, sessionId, ) await forceLightScheme(cdp, sessionId) if (s.popupOffline) { // Keeps probeSessionExpiry from treating the fixture session (which has // no restorable OAuth state) as expired in this scenario. await cdp.send( 'Page.addScriptToEvaluateOnNewDocument', { source: `Object.defineProperty(Navigator.prototype, 'onLine', { get: () => false })` }, sessionId, ) } const asked = s.stubLabels ? await stubLabelerAnswers(cdp, sessionId, s.stubLabels) : undefined if (s.stall) await stallRequests(cdp, sessionId, s.stall) await inWorker(`chrome.tabs.update(${tab.id}, { active: true })`) await cdp.send('Page.navigate', { url: popupUrl }, sessionId) // A clicking scenario usually settles pill-free first and a click raises // the rest; a scenario whose pills are already up says so with settledPills. const clicks = s.clicks ?? [] const settledPills = clicks.length ? (s.settledPills ?? 0) : s.expectPills // Every capture is of a popup that has finished: no card may still be // standing a placeholder where its lookup will go // (src/popup/cards/loading.ts). The one capture that is *of* those // placeholders waits for exactly the opposite. const ready = `(() => { const pub = document.getElementById('pub') if (!pub || document.readyState !== 'complete') return false const pills = document.querySelectorAll('#status .pill').length return pills === ${settledPills} && !pub.hidden === ${s.expectCard} && (${s.settled ?? 'true'}) })()` try { await poll(() => evaluate(cdp, sessionId, ready), `${s.name} to settle`, SETTLE_MS) } catch (err) { // Say what the popup actually looked like: which condition held out is // otherwise invisible behind one timeout message. throw new Error(`${err.message}\n popup: ${await popupShape(cdp, sessionId)}`) } for (const { sel, settles } of clicks) { await evaluate(cdp, sessionId, `(document.querySelector(${JSON.stringify(sel)}).click(), true)`) const settled = settles ?? `document.querySelectorAll('#status .pill').length === ${s.expectPills}` try { await poll( () => evaluate(cdp, sessionId, settled), `${s.name} to settle after clicking ${sel}`, SETTLE_MS, ) } catch (err) { throw new Error(`${err.message}\n popup: ${await popupShape(cdp, sessionId)}`) } } // Every capture but one is of a popup that has finished: no lookup still // out, no placeholder still standing, and every image — the publication // icon, the account avatar, the subscriber faces, all real blob fetches // off somebody's PDS — landed. The exception is the capture that is *of* // the placeholders, whose lookups are held open and never land. // // The hold is inside this: the popup goes on changing after every wait // here, and the shutter is the far side of the hold and a few more round // trips, so what the gate saw has to still be true when it fires. await settle( cdp, sessionId, s.stall ? popupStalled : popupSettled, s.hold ?? 200, s.stall ? `${s.name} to be caught mid-load` : `${s.name} to finish loading`, ) if (asked) { // Every stub labeler must have been asked, and every stubbed label must // have become a pill: a labeler that stopped defining one of these // values would otherwise quietly drop out of the shot. const missed = [...s.stubLabels.values()].filter((l) => !asked.has(l.did)) if (missed.length) { throw new Error(`${s.name}: never asked ${missed.map((l) => l.did).join(', ')}`) } const names = await evaluate( cdp, sessionId, `[...document.querySelectorAll('#labels .label-pill')].map((p) => p.textContent)`, ) if (names.length !== STUB_LABEL_COUNT) { throw new Error(`${s.name}: ${names.length} label pill(s), expected ${STUB_LABEL_COUNT}`) } console.log(`capture: ${s.name} labels ${names.join(' | ')}`) } // The header's version indicator is in every one of these shots, so a // header that stopped filling it would go out in the docs and the store // listing before anyone noticed the gap. const version = await evaluate( cdp, sessionId, `document.getElementById('version').textContent`, ) if (version !== `v${BUILT_VERSION}`) { throw new Error(`${s.name}: version reads "${version}", expected "v${BUILT_VERSION}"`) } if (s.reader) { // The preset has to be the reader in the shot: an id the catalog dropped // falls back to the first reader, and one with no publication view // renders disabled. Either would be a capture of the wrong button. const open = await evaluate( cdp, sessionId, `(() => { const btn = document.getElementById('open-default') if (!btn) return null return { icon: btn.querySelector('img.reader-icon')?.getAttribute('src') ?? null, label: btn.textContent, disabled: btn.disabled, } })()`, ) const want = `/vendored/${READER_ICONS[s.reader]}` if (!open || open.icon !== want || open.disabled) { throw new Error( `${s.name}: expected the ${s.reader} button (${want}), got ${JSON.stringify(open)}`, ) } console.log(`capture: ${s.name} opens in ${s.reader} — "${open.label}"`) } if (s.owner) { // The seeded card has to be the card that rendered. A fixture DID // resolves to nothing, so a seed the popup ignored does not fail — the // card quietly falls back to the bare DID, which is how a cache-key // change once reached the committed screenshots. // // The handle line, not the name: a blocked account's card deliberately // does not show the name it was seeded with (blockedIdentity in // src/lib/profile.ts), and the handle is what survives either way. const shown = await evaluate( cdp, sessionId, `document.getElementById('owner-handle')?.textContent`, ) if (shown !== `@${s.owner.handle}`) { throw new Error( `${s.name}: owner card shows ${JSON.stringify(shown)}, not the seeded ` + `${JSON.stringify(`@${s.owner.handle}`)} — the popup's cache layout moved ` + `(see storageKey in src/lib/cache.ts)`, ) } } const box = await evaluate( cdp, sessionId, `(() => { const r = document.body.getBoundingClientRect(); return { w: r.width, h: r.height } })()`, ) const width = Math.ceil(box.w) const height = Math.min(Math.ceil(box.h) + 1, 900) const shot = await cdp.send( 'Page.captureScreenshot', { format: 'png', clip: { x: 0, y: 0, width, height, scale: 1 } }, sessionId, ) writeFileSync(path.join(OUT, `${s.name}.png`), Buffer.from(shot.data, 'base64')) console.log( `capture: ${s.name}.png ${width}x${height} (${s.expectPills} pill(s), card=${s.expectCard})`, ) } finally { cdp.off('Fetch.requestPaused') await cdp.send('Target.closeTarget', { targetId }).catch(() => {}) await inWorker(`chrome.tabs.remove(${tab.id})`).catch(() => {}) } } // Importable for its own tests (capture-status-docs.test.mjs); only a direct // run shoots anything. if (process.argv[1] === import.meta.filename) { main().catch((err) => { console.error(err) process.exit(1) }) }