// 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)
})
}