staysfixed 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1988 @@
1
+ /**
2
+ * Browser extensions.
3
+ *
4
+ * An extension is a real product — people ship them, sell them, and break them the same way
5
+ * everything else breaks — and until now this tool had no home for one. It needed almost no
6
+ * new machinery. The web lane already starts a browser of its own with a throwaway profile;
7
+ * an extension is loaded into exactly that browser with two command line flags, and from
8
+ * then on its pages are ordinary pages the existing web machinery can read.
9
+ *
10
+ * An extension is four different things wearing one coat, and each one is a different
11
+ * question. This lane answers three of them properly and says out loud where the fourth
12
+ * stops:
13
+ *
14
+ * THE MANIFEST Its permissions, the sites it may touch, the pages it declares. This
15
+ * is a CONTRACT with the person who installed it. A permission that
16
+ * quietly appears, or a host permission that widens from one named site
17
+ * to every site on the internet, is exactly the kind of change somebody
18
+ * must be told about — and reading it costs nothing and runs nothing, so
19
+ * it works on any machine with or without a browser.
20
+ * THE POPUP AND Ordinary web pages at a `chrome-extension://.../...` address. Opened,
21
+ * THE OPTIONS PAGE frozen and read for what the screen MEANS, exactly like any other page.
22
+ * THE CONTENT What the extension DOES to somebody else's page. The only honest way
23
+ * SCRIPT to watch that is to open the same page twice — once with the extension
24
+ * loaded and once without — and record the DIFFERENCE. That difference
25
+ * is the product. It is also the one thing that goes silently missing:
26
+ * a content script that stopped firing leaves a page that looks entirely
27
+ * normal, because it IS the normal page.
28
+ * THE BACKGROUND What it asks the network for, and what it stores. Both are covered.
29
+ * WORKER What it LOGS is not — see `theBackgroundWorker` for the measurement
30
+ * that says why, in one sentence, on every single run.
31
+ *
32
+ * WHAT IT REFUSES TO GUESS. Every address a content script is tried against is served from
33
+ * this machine, so nothing this lane does ever reaches the internet. When a project has not
34
+ * said what the real page looks like, the page served is a blank stand-in at the address the
35
+ * manifest itself names — enough to answer "did the content script fire at all", which is
36
+ * the break that actually happens, and not enough to answer "did it put the banner in the
37
+ * right place", which is said in as many words on every observation it produces.
38
+ *
39
+ * THE EXTENSION ID IS NOT A FACT ABOUT THE PRODUCT. Chrome makes the id of an unpacked
40
+ * extension out of the folder it was loaded from, so the two builds of one comparison — which
41
+ * live in two different scratch folders — get two different ids. Measured on 2026-08-31: the
42
+ * same folder gives the same id every time, a copy of it somewhere else gives a different
43
+ * one. So the id is never an address and never a value here; it is taken out of every line of
44
+ * text before anything is written down. Leaving it in would report a difference on every run,
45
+ * which is the fastest way to get a tool switched off.
46
+ */
47
+
48
+ import crypto from 'node:crypto';
49
+ import fs from 'node:fs';
50
+ import fsp from 'node:fs/promises';
51
+ import path from 'node:path';
52
+
53
+ import {
54
+ countBucket, defineAdapter, howLongItTook, joinPath, notCovered, observation, sizeBucket,
55
+ timeBucket, trimForStorage, undoOurFootprint,
56
+ } from './contract.js';
57
+ import { copyForScratch, frozenEnvironment } from './process.js';
58
+ import { spawnServer, stopServer } from './child.js';
59
+ import { applyFreeze, prepareForShutter } from '../../freeze/index.js';
60
+ import { settle } from '../../freeze/settle.js';
61
+ import {
62
+ countRoles, flattenAria, inkOf, loadPlaywright, openWindow, parseAria, short,
63
+ typesIn, whereItIs, wirePattern, withLimit,
64
+ } from './web-driver.js';
65
+
66
+ /** @typedef {import('./contract.js').Journey} Journey */
67
+ /** @typedef {import('./contract.js').Observation} Observation */
68
+ /** @typedef {import('./contract.js').Missing} Missing */
69
+ /** @typedef {import('./web-driver.js').MeaningEntry} MeaningEntry */
70
+
71
+ /** How big the window is, unless a project says otherwise. */
72
+ const VIEWPORT = { width: 1280, height: 800, deviceScaleFactor: 1 };
73
+
74
+ /**
75
+ * Where a manifest lives, in the order worth looking.
76
+ *
77
+ * The root first, because an extension nobody bundles keeps it there. Then the folders a
78
+ * bundler writes into, because the manifest that MATTERS is the one in the thing that
79
+ * actually gets loaded — a source manifest that a build step rewrites is a description of
80
+ * the product, not the product.
81
+ */
82
+ const MANIFEST_SPOTS = ['dist', 'build', 'out', 'extension', 'unpacked', 'public', 'src', 'app', 'chrome', 'addon'];
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Reading a manifest — everything here is pure, so it can be tested without a browser
86
+ // ---------------------------------------------------------------------------
87
+
88
+ /**
89
+ * Find the extension inside a project.
90
+ *
91
+ * @param {string} root
92
+ * @param {string} [told] A folder the project named, relative to the root or absolute.
93
+ * @returns {{dir: string|null, file: string|null, why: string}}
94
+ */
95
+ export function findExtension(root, told) {
96
+ if (told) {
97
+ const dir = path.isAbsolute(told) ? told : path.join(root, told);
98
+ const file = path.join(dir, 'manifest.json');
99
+ if (fs.existsSync(file)) return { dir, file, why: `The settings point at ${told}, and there is a manifest.json in it.` };
100
+ return { dir: null, file: null, why: `The settings point at ${told}, and there is no manifest.json in it. Nothing here can be loaded as an extension until there is.` };
101
+ }
102
+
103
+ /** @type {{dir: string, file: string, where: string}[]} */
104
+ const candidates = [{ dir: root, file: path.join(root, 'manifest.json'), where: 'the project folder itself' }];
105
+ for (const spot of MANIFEST_SPOTS) {
106
+ candidates.push({ dir: path.join(root, spot), file: path.join(root, spot, 'manifest.json'), where: `${spot}/` });
107
+ }
108
+
109
+ const there = candidates.filter((c) => fs.existsSync(c.file));
110
+ const readable = there.find((c) => looksLikeAnExtension(c.file));
111
+ if (readable) return { dir: readable.dir, file: readable.file, why: `There is a manifest.json in ${readable.where}.` };
112
+
113
+ // A MANIFEST THAT IS THERE AND BROKEN IS NOT A MISSING MANIFEST. Measured on 2026-08-31:
114
+ // an extension whose manifest.json had been truncated to half a line was reported as
115
+ // "there is no manifest.json anywhere this looks" — which is false, and sends whoever reads
116
+ // it looking for a folder rather than at the one broken line that is actually the problem.
117
+ // So a manifest that exists is handed back even when nothing can be read out of it, and the
118
+ // caller says exactly what is wrong with it.
119
+ if (there.length > 0) {
120
+ return { dir: there[0].dir, file: there[0].file, why: `There is a manifest.json in ${there[0].where}, and nothing could be read out of it.` };
121
+ }
122
+ return { dir: null, file: null, why: 'There is no manifest.json anywhere this looks, so there is nothing here to load as a browser extension.' };
123
+ }
124
+
125
+ /**
126
+ * Is this manifest.json a BROWSER EXTENSION's manifest?
127
+ *
128
+ * `manifest.json` is one of the most reused file names there is: a web app manifest, a
129
+ * Chrome app, a Firefox theme and half a dozen build tools all write one. Claiming a web
130
+ * app's manifest as an extension would hand every journey to an adapter that cannot walk
131
+ * it — and a journey nothing walked, reported as covered, is the one failure this tool
132
+ * exists to prevent. So the test is the one field an extension always has and a web app
133
+ * manifest never does.
134
+ *
135
+ * @param {string} file
136
+ * @returns {boolean}
137
+ */
138
+ function looksLikeAnExtension(file) {
139
+ try {
140
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
141
+ return typeof parsed?.manifest_version === 'number';
142
+ } catch {
143
+ // Unreadable or not JSON. `readManifest` says so properly; here it is simply not proof
144
+ // that this is an extension, so the search carries on looking for one that is.
145
+ return false;
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Read a manifest, and say what is wrong with it in words somebody can act on.
151
+ *
152
+ * @param {string} text
153
+ * @returns {{ok: true, manifest: Record<string, any>} | {ok: false, why: string}}
154
+ */
155
+ export function readManifest(text) {
156
+ /** @type {any} */
157
+ let parsed;
158
+ try {
159
+ parsed = JSON.parse(String(text));
160
+ } catch (error) {
161
+ const said = error instanceof Error ? error.message : String(error);
162
+ return { ok: false, why: `The manifest is not valid JSON, so the browser will refuse to load this extension at all: ${said}` };
163
+ }
164
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
165
+ return { ok: false, why: 'The manifest is valid JSON but is not an object, so there is nothing in it a browser could read.' };
166
+ }
167
+ if (typeof parsed.manifest_version !== 'number') {
168
+ return { ok: false, why: 'The manifest has no "manifest_version" in it. Every browser refuses an extension without one, so nothing here would load.' };
169
+ }
170
+ return { ok: true, manifest: parsed };
171
+ }
172
+
173
+ /**
174
+ * Chrome's own id for an extension loaded from a folder.
175
+ *
176
+ * Not a guess: this is the documented rule, and it is checked against the real thing on
177
+ * every run that has a background worker to ask. Chrome hashes the ABSOLUTE path of the
178
+ * folder with SHA-256, takes the first sixteen bytes, and turns each half-byte into a letter
179
+ * by adding it to 'a'. Which is why the id changes when the folder does.
180
+ *
181
+ * It matters because an extension with no background worker has no running thing to ask, and
182
+ * without an id there is no address at which to open its popup. Measured on 2026-08-31: an
183
+ * extension with nothing but a popup and a content script has no service worker at all, and
184
+ * the popup opened at the predicted address first time.
185
+ *
186
+ * The REAL path is hashed, not the one we were handed. On a Mac `/tmp` is a link to
187
+ * `/private/tmp` and Chrome resolves it before hashing; hashing the unresolved path gave an
188
+ * id that was wrong in exactly the case a scratch folder is used, which is every run.
189
+ *
190
+ * @param {string} dir
191
+ * @returns {string}
192
+ */
193
+ export function idForUnpacked(dir) {
194
+ let real = dir;
195
+ try {
196
+ real = fs.realpathSync(dir);
197
+ } catch {
198
+ // Not there yet, or not readable. The unresolved path is the best answer available and
199
+ // the run checks the id against the browser anyway before trusting it.
200
+ }
201
+ const bytes = process.platform === 'win32' ? Buffer.from(real, 'utf16le') : Buffer.from(real, 'utf8');
202
+ const hex = crypto.createHash('sha256').update(bytes).digest('hex').slice(0, 32);
203
+ return [...hex].map((c) => String.fromCharCode(97 + parseInt(c, 16))).join('');
204
+ }
205
+
206
+ /**
207
+ * What each permission actually lets an extension do, in plain English.
208
+ *
209
+ * The names are for programmers and several of them are alarming in ways the word does not
210
+ * show: "management" is the power to switch your other extensions off, "debugger" is the
211
+ * power to read and change any page you have open. The whole point of watching the manifest
212
+ * is that a person can be told what changed, so the report says what the word means.
213
+ *
214
+ * @type {Record<string, string>}
215
+ */
216
+ export const WHAT_A_PERMISSION_ALLOWS = Object.freeze({
217
+ activeTab: 'read and change the tab you are looking at, but only after you click the extension',
218
+ alarms: 'wake itself up on a timer',
219
+ bookmarks: 'read and change your bookmarks',
220
+ browsingData: 'delete your browsing history, cookies and cached files',
221
+ clipboardRead: 'read whatever you have copied',
222
+ clipboardWrite: 'put things on your clipboard',
223
+ contextMenus: 'add items to the right-click menu',
224
+ cookies: 'read and change the cookies that keep you signed in to sites',
225
+ debugger: 'attach to any page as a debugger, which is total control of everything you have open',
226
+ declarativeNetRequest: 'block and rewrite requests as pages load',
227
+ downloads: 'start downloads and read what you have downloaded',
228
+ geolocation: 'ask for where you are',
229
+ history: 'read and change your browsing history',
230
+ identity: 'sign you in to an account on its own behalf',
231
+ management: 'see, switch off and uninstall your other extensions',
232
+ nativeMessaging: 'talk to a program installed on this computer, outside the browser',
233
+ notifications: 'show desktop notifications',
234
+ privacy: 'change your browser privacy settings',
235
+ proxy: 'route everything you browse through a server of its choosing',
236
+ scripting: 'run its own code inside other people\'s pages',
237
+ storage: 'keep its own data in the browser',
238
+ tabs: 'read the address and title of every tab you have open',
239
+ topSites: 'read the sites you visit most',
240
+ unlimitedStorage: 'store as much as it likes',
241
+ webNavigation: 'watch every page you go to',
242
+ webRequest: 'watch every request the browser makes',
243
+ webRequestBlocking: 'stop or change requests before they go out',
244
+ });
245
+
246
+ /**
247
+ * How wide the reach of a list of host patterns is, as one plain sentence.
248
+ *
249
+ * This is the headline the whole manifest check exists for. Somebody reading a report will
250
+ * not spot that `https://mail.example.com/*` became `<all_urls>` in a list of forty lines,
251
+ * and that single change is the difference between an extension that reads one site and one
252
+ * that reads their bank. So the width is written down as a fact of its own, in words, and a
253
+ * change to it is a change to one short line at the top rather than a change buried in a list.
254
+ *
255
+ * @param {string[]} patterns
256
+ * @returns {{value: string, says: string}}
257
+ */
258
+ export function howWideTheReachIs(patterns) {
259
+ // THE STAR HAS TO BE THE WHOLE HOST. Written as `https://*` followed by anything, this
260
+ // matched `https://*.github.com/*` — a wildcard SUBDOMAIN of one named site — and reported
261
+ // an extension that reaches one site as one that reaches every site on the internet. Caught
262
+ // on 2026-08-31 by the test that asks about a subdomain wildcard. A false alarm on the
263
+ // loudest line in the whole report is the fastest way to get this tool switched off.
264
+ const all = patterns.filter((p) => p === '<all_urls>' || /^(\*|https?|file|ftp):\/\/\*(\/|$)/.test(p));
265
+ if (all.length > 0) {
266
+ return {
267
+ value: 'every site',
268
+ says: `This extension asks to reach EVERY site, through ${all.map((p) => `"${p}"`).join(' and ')}. That is the widest thing an extension can ask for: every page you open, including your bank and your email, is one it may read and change.`,
269
+ };
270
+ }
271
+ /** @type {Set<string>} */
272
+ const hosts = new Set();
273
+ for (const pattern of patterns) {
274
+ const host = hostOfPattern(pattern);
275
+ if (host) hosts.add(host);
276
+ }
277
+ if (hosts.size === 0) return { value: 'no sites', says: 'This extension asks to reach no sites of its own. It can only touch a page after you click it, if it can touch one at all.' };
278
+ const named = [...hosts].sort();
279
+ return {
280
+ value: named.length === 1 ? 'one named site' : `${named.length} named sites`,
281
+ says: `This extension asks to reach ${named.length === 1 ? 'one site' : `${named.length} sites`}: ${named.join(', ')}. Nothing else. If this ever says "every site", something has widened and somebody should be told before it ships.`,
282
+ };
283
+ }
284
+
285
+ /**
286
+ * The host out of a match pattern, or null when the pattern names every host.
287
+ *
288
+ * @param {string} pattern
289
+ * @returns {string|null}
290
+ */
291
+ function hostOfPattern(pattern) {
292
+ const text = String(pattern);
293
+ if (text === '<all_urls>') return null;
294
+ const match = /^[^:]+:\/\/([^/]*)/.exec(text);
295
+ if (!match) return null;
296
+ const host = match[1];
297
+ if (host === '' || host === '*') return null;
298
+ return host.startsWith('*.') ? host.slice(2) : host;
299
+ }
300
+
301
+ /**
302
+ * Every page the extension declares, whichever manifest version it is written in.
303
+ *
304
+ * Both spellings are read on purpose. Manifest v2 is still what a great many published
305
+ * extensions are written in, and an adapter that only understood v3 would quietly find no
306
+ * pages at all in one — which reads exactly like an extension with no pages.
307
+ *
308
+ * @param {Record<string, any>} manifest
309
+ * @returns {{what: string, file: string}[]}
310
+ */
311
+ export function declaredPages(manifest) {
312
+ /** @type {{what: string, file: string}[]} */
313
+ const pages = [];
314
+ /** @param {string} what @param {unknown} file */
315
+ const add = (what, file) => {
316
+ if (typeof file !== 'string' || file.trim() === '') return;
317
+ const clean = file.replace(/^\.?\//, '').split('#')[0].split('?')[0];
318
+ if (!/\.html?$/i.test(clean)) return;
319
+ if (pages.some((p) => p.file === clean)) return;
320
+ pages.push({ what, file: clean });
321
+ };
322
+
323
+ add('the popup', manifest.action?.default_popup);
324
+ add('the popup', manifest.browser_action?.default_popup);
325
+ add('the popup', manifest.page_action?.default_popup);
326
+ add('the options page', typeof manifest.options_ui?.page === 'string' ? manifest.options_ui.page : undefined);
327
+ add('the options page', manifest.options_page);
328
+ add('the side panel', manifest.side_panel?.default_path);
329
+ add('the sidebar', manifest.sidebar_action?.default_panel);
330
+ add('the developer-tools page', manifest.devtools_page);
331
+ for (const [what, file] of Object.entries(manifest.chrome_url_overrides ?? {})) {
332
+ add(`the page it puts in place of ${what}`, file);
333
+ }
334
+ add('the background page', manifest.background?.page);
335
+ return pages;
336
+ }
337
+
338
+ /**
339
+ * An address to try one content script against, worked out from what it says it runs on.
340
+ *
341
+ * The address is never visited for real — it is served from this machine — so what matters
342
+ * is only that it MATCHES the pattern the manifest wrote, because matching is the whole
343
+ * question. A pattern that names every site gets a stand-in address, and says so.
344
+ *
345
+ * @param {string} pattern
346
+ * @returns {{url: string, exact: boolean, why: string} | {url: null, why: string}}
347
+ */
348
+ export function addressForMatch(pattern) {
349
+ const text = String(pattern ?? '').trim();
350
+ if (text === '') return { url: null, why: 'the pattern is empty' };
351
+ if (text.startsWith('file://')) {
352
+ return { url: null, why: 'it runs on files on your own disk, and a browser only lets an extension do that after somebody ticks a box by hand, so it cannot be checked here' };
353
+ }
354
+ if (text === '<all_urls>' || text === '*://*/*' || /^(\*|https?):\/\/\*\/?\*?$/.test(text)) {
355
+ return { url: 'https://example.com/', exact: false, why: 'this content script says it runs on EVERY site, so one stand-in address is used to represent all of them' };
356
+ }
357
+ const parts = /^([^:]+):\/\/([^/]+)(\/.*)?$/.exec(text);
358
+ if (!parts) return { url: null, why: `"${text}" is not a match pattern this understands` };
359
+ const scheme = parts[1] === '*' ? 'https' : parts[1];
360
+ if (scheme !== 'http' && scheme !== 'https') return { url: null, why: `it runs on "${scheme}" addresses, which are not pages a browser can be sent to here` };
361
+ const host = parts[2].startsWith('*.') ? parts[2].slice(2) : parts[2];
362
+ if (host === '*' || host === '') return { url: 'https://example.com/', exact: false, why: 'this content script says it runs on every host, so one stand-in address is used to represent all of them' };
363
+ const where = (parts[3] ?? '/').replace(/\*+$/, '');
364
+ return { url: `${scheme}://${host}${where.startsWith('/') ? where : `/${where}`}`, exact: true, why: `this is the address the manifest itself names in "${text}"` };
365
+ }
366
+
367
+ /**
368
+ * One address per content script, with the reason it was chosen.
369
+ *
370
+ * One per SCRIPT rather than one per pattern: a script listing twelve subdomains is one
371
+ * behaviour, and twelve near-identical journeys would triple the length of the report while
372
+ * saying the same thing twelve times. The other patterns are still written down in the
373
+ * manifest contract, where a change to any of them shows up.
374
+ *
375
+ * @param {Record<string, any>} manifest
376
+ * @returns {{url: string|null, pattern: string, exact: boolean, why: string, files: string[]}[]}
377
+ */
378
+ export function contentScriptTargets(manifest) {
379
+ /** @type {{url: string|null, pattern: string, exact: boolean, why: string, files: string[]}[]} */
380
+ const out = [];
381
+ /** @type {Set<string>} */
382
+ const already = new Set();
383
+ for (const script of manifest.content_scripts ?? []) {
384
+ const patterns = /** @type {string[]} */ (Array.isArray(script?.matches) ? script.matches : []);
385
+ const files = [...(script?.js ?? []), ...(script?.css ?? [])].map((f) => String(f));
386
+ /** @type {{url: string|null, pattern: string, exact: boolean, why: string, files: string[]}|null} */
387
+ let best = null;
388
+ for (const pattern of patterns) {
389
+ const tried = addressForMatch(pattern);
390
+ const entry = { url: tried.url, pattern, exact: 'exact' in tried ? tried.exact : false, why: tried.why, files };
391
+ if (entry.url && entry.exact) { best = entry; break; }
392
+ if (!best || (entry.url && !best.url)) best = entry;
393
+ }
394
+ if (!best) {
395
+ out.push({ url: null, pattern: '(none)', exact: false, why: 'this content script does not say which pages it runs on, so there is no page to try it against', files });
396
+ continue;
397
+ }
398
+ const key = best.url ?? `no address:${best.pattern}`;
399
+ if (already.has(key)) continue;
400
+ already.add(key);
401
+ out.push(best);
402
+ }
403
+ return out;
404
+ }
405
+
406
+ /**
407
+ * The manifest as flat facts, one address each.
408
+ *
409
+ * One address per permission rather than one list, because a list compares as one value: add
410
+ * a permission and the whole list reads as changed, with the new one somewhere inside it.
411
+ * One address each means a permission that appeared is a NEW address — which is how this
412
+ * tool says "somebody added this" rather than "this list is different now".
413
+ *
414
+ * @param {Record<string, any>} manifest
415
+ * @returns {{path: string[], value: any, says: string}[]}
416
+ */
417
+ export function manifestContract(manifest) {
418
+ /** @type {{path: string[], value: any, says: string}[]} */
419
+ const facts = [];
420
+ /** @param {string[]} at @param {any} value @param {string} says */
421
+ const fact = (at, value, says) => facts.push({ path: at, value, says });
422
+
423
+ fact(['what it is called'], String(manifest.name ?? ''), `The extension is called "${manifest.name ?? ''}". This is the name in the browser's own list of what is installed, so a change to it is a change somebody will see.`);
424
+ fact(['which manifest it is written in'], Number(manifest.manifest_version), `It is written in manifest version ${manifest.manifest_version}. Going from 2 to 3 changes what the extension is allowed to do, so it is never a quiet change.`);
425
+ if (typeof manifest.description === 'string') {
426
+ fact(['what it says it does'], manifest.description, `Its description reads: "${short(manifest.description, 120)}". This is what somebody sees in the store, and it is compared as written.`);
427
+ }
428
+ // The version NUMBER is left out and its SHAPE kept instead. Every release changes the
429
+ // version, so comparing it would report a difference on every single release — a tool that
430
+ // is loudest on the days when nothing is wrong gets switched off. The shape still catches
431
+ // the real break, which is a version that stopped being a version at all.
432
+ fact(['the shape of its version'], shapeOfAVersion(String(manifest.version ?? '')), `Its version is written as ${shapeOfAVersion(String(manifest.version ?? ''))}. The number itself is deliberately not compared — it changes on every release, and a check that complains every release is a check nobody reads.`);
433
+
434
+ const permissions = [...(manifest.permissions ?? [])].map(String).sort();
435
+ for (const permission of permissions) {
436
+ const means = WHAT_A_PERMISSION_ALLOWS[permission];
437
+ fact(['what it is allowed to do', permission], true, means
438
+ ? `It asks for "${permission}", which lets it ${means}.`
439
+ : `It asks for the "${permission}" permission.`);
440
+ }
441
+ fact(['how many things it is allowed to do'], permissions.length, `It asks for ${permissions.length} permission${permissions.length === 1 ? '' : 's'}. Counted as well as listed, because the count is the line a person reads first.`);
442
+
443
+ const optional = [...(manifest.optional_permissions ?? [])].map(String).sort();
444
+ for (const permission of optional) {
445
+ const means = WHAT_A_PERMISSION_ALLOWS[permission];
446
+ fact(['what it may ask you for later', permission], true, means
447
+ ? `It may ask you for "${permission}" later, which would let it ${means}.`
448
+ : `It may ask you for the "${permission}" permission later.`);
449
+ }
450
+
451
+ const hosts = [...(manifest.host_permissions ?? []), ...(manifest.manifest_version === 2 ? (manifest.permissions ?? []).filter((/** @type {unknown} */ p) => /:\/\//.test(String(p)) || p === '<all_urls>') : [])].map(String).sort();
452
+ for (const host of hosts) {
453
+ fact(['which sites it may touch', host], true, `It asks to reach "${host}".`);
454
+ }
455
+ const reach = howWideTheReachIs(hosts);
456
+ fact(['how wide its reach is'], reach.value, reach.says);
457
+
458
+ const optionalHosts = [...(manifest.optional_host_permissions ?? [])].map(String).sort();
459
+ for (const host of optionalHosts) {
460
+ fact(['which sites it may ask for later', host], true, `It may ask to reach "${host}" later.`);
461
+ }
462
+
463
+ for (const page of declaredPages(manifest)) {
464
+ fact(['the pages it declares', page.what], page.file, `${page.what[0].toUpperCase()}${page.what.slice(1)} is ${page.file}. A page named here that is not in the built folder is an extension with a dead button.`);
465
+ }
466
+
467
+ const scripts = /** @type {any[]} */ (manifest.content_scripts ?? []);
468
+ scripts.forEach((script, index) => {
469
+ const matches = [...(script?.matches ?? [])].map(String).sort();
470
+ const at = ['what it changes on other people\'s pages', matches[0] ?? `the ${index + 1}${index === 0 ? 'st' : 'th'} one`];
471
+ fact([...at, 'runs on'], matches, `This content script runs on ${matches.length === 0 ? 'nothing it names' : matches.join(', ')}. Every address here is a page it may read and change.`);
472
+ fact([...at, 'the files it injects'], [...(script?.js ?? []), ...(script?.css ?? [])].map(String), 'The files it injects into those pages. A file that disappeared from this list is code that stopped running on somebody\'s page.');
473
+ fact([...at, 'when it runs'], String(script?.run_at ?? 'document_idle'), 'When in the page\'s life it runs. Moving this earlier or later is one of the ways a working content script starts missing the thing it was reading.');
474
+ if (script?.all_frames !== undefined) fact([...at, 'in every frame'], script.all_frames === true, `It ${script.all_frames === true ? 'does' : 'does not'} run inside embedded frames as well as the main page.`);
475
+ });
476
+ fact(['how many things it injects into other pages'], scripts.length, `It injects into other people's pages in ${scripts.length} place${scripts.length === 1 ? '' : 's'}.`);
477
+
478
+ const background = manifest.background ?? null;
479
+ fact(['does it run something in the background'], background !== null, background === null
480
+ ? 'It runs nothing in the background.'
481
+ : `It runs ${background.service_worker ? `a background worker (${background.service_worker})` : background.page ? `a background page (${background.page})` : `background scripts (${[...(background.scripts ?? [])].join(', ')})`}.`);
482
+
483
+ for (const entry of manifest.web_accessible_resources ?? []) {
484
+ const resources = typeof entry === 'string' ? [entry] : [...(entry?.resources ?? [])].map(String);
485
+ const to = typeof entry === 'string' ? ['every site'] : [...(entry?.matches ?? [])].map(String);
486
+ fact(['what other pages may load out of it', resources.sort().join(', ')], to.sort(), `Pages on ${to.join(', ')} are allowed to load ${resources.join(', ')} out of this extension. Widening who may is the same shape of change as widening a permission.`);
487
+ }
488
+
489
+ if (manifest.externally_connectable) {
490
+ const who = [...(manifest.externally_connectable.matches ?? [])].map(String).sort();
491
+ fact(['who may talk to it from outside'], who, `${who.length === 0 ? 'Nothing' : who.join(', ')} may send messages into this extension from an ordinary web page.`);
492
+ }
493
+
494
+ const csp = manifest.content_security_policy;
495
+ if (csp !== undefined) {
496
+ fact(['the rules it sets for its own pages'], typeof csp === 'string' ? csp : typesIn(csp) === 'nothing' ? 'nothing' : JSON.stringify(csp), 'The content security policy its own pages run under. Loosening this is how an extension page becomes somewhere else\'s code can run.');
497
+ }
498
+
499
+ const commands = Object.keys(manifest.commands ?? {}).sort();
500
+ for (const command of commands) {
501
+ const keys = manifest.commands[command]?.suggested_key;
502
+ fact(['the keyboard shortcuts it takes', command], typeof keys === 'string' ? keys : keys ? JSON.stringify(keys) : 'no suggested key', `It asks for a keyboard shortcut called "${command}".`);
503
+ }
504
+
505
+ const rules = /** @type {any[]} */ (manifest.declarative_net_request?.rule_resources ?? []);
506
+ for (const set of rules) {
507
+ fact(['the request rules it gives the browser', String(set?.id ?? 'a rule set')], { file: String(set?.path ?? ''), 'switched on by default': set?.enabled === true }, `A set of rules that blocks or rewrites requests as pages load, from ${set?.path}. These run without the extension being open.`);
508
+ }
509
+
510
+ return facts;
511
+ }
512
+
513
+ /**
514
+ * The shape of a version string, with the numbers taken out.
515
+ *
516
+ * @param {string} version
517
+ * @returns {string}
518
+ */
519
+ export function shapeOfAVersion(version) {
520
+ const text = String(version ?? '').trim();
521
+ if (text === '') return 'nothing at all';
522
+ if (/^\d+(\.\d+){0,3}$/.test(text)) {
523
+ const parts = text.split('.').length;
524
+ return `${parts} number${parts === 1 ? '' : 's'} separated by dots`;
525
+ }
526
+ return 'something that is not a plain dotted version number';
527
+ }
528
+
529
+ /**
530
+ * What one build did to a page, worked out by comparing the page with it and without it.
531
+ *
532
+ * Addressed by what each thing IS and what it SAYS — the address `flattenAria` builds — never
533
+ * by where it sits in the list. An extension that adds a banner at the top of a page shifts
534
+ * everything below it down by one, and addressing by position would report the entire page
535
+ * as changed every time.
536
+ *
537
+ * @param {MeaningEntry[]} without
538
+ * @param {MeaningEntry[]} with_
539
+ * @returns {{added: MeaningEntry[], removed: MeaningEntry[], changed: {at: string, was: any, now: any}[]}}
540
+ */
541
+ export function differenceMade(without, with_) {
542
+ /** @param {MeaningEntry[]} entries */
543
+ const byAddress = (entries) => {
544
+ /** @type {Map<string, MeaningEntry>} */
545
+ const map = new Map();
546
+ for (const entry of entries) map.set(entry.at.join(' > '), entry);
547
+ return map;
548
+ };
549
+ const before = byAddress(without);
550
+ const after = byAddress(with_);
551
+
552
+ /** @type {MeaningEntry[]} */
553
+ const added = [];
554
+ /** @type {MeaningEntry[]} */
555
+ const removed = [];
556
+ /** @type {{at: string, was: any, now: any}[]} */
557
+ const changed = [];
558
+
559
+ for (const [at, entry] of after) {
560
+ const was = before.get(at);
561
+ if (!was) added.push(entry);
562
+ else if (JSON.stringify(was.value) !== JSON.stringify(entry.value)) changed.push({ at, was: was.value, now: entry.value });
563
+ }
564
+ for (const [at, entry] of before) {
565
+ if (!after.has(at)) removed.push(entry);
566
+ }
567
+ added.sort((a, b) => a.at.join(' > ').localeCompare(b.at.join(' > ')));
568
+ removed.sort((a, b) => a.at.join(' > ').localeCompare(b.at.join(' > ')));
569
+ changed.sort((a, b) => a.at.localeCompare(b.at));
570
+ return { added, removed, changed };
571
+ }
572
+
573
+ /**
574
+ * Take the extension's id out of a line of text.
575
+ *
576
+ * See the note at the top of this file: the id is made out of the folder the extension was
577
+ * loaded from, so it is different for every build in every run. Left in, it would turn every
578
+ * console message and every address into a difference. Replaced, what is left is the part
579
+ * that is actually about the product.
580
+ *
581
+ * @param {string} text
582
+ * @param {string|null} id
583
+ * @returns {string}
584
+ */
585
+ export function withoutTheId(text, id) {
586
+ const said = String(text ?? '');
587
+ if (!id) return said.replace(/chrome-extension:\/\/[a-p]{32}/g, 'chrome-extension://the-extension');
588
+ return said.split(id).join('the-extension').replace(/chrome-extension:\/\/[a-p]{32}/g, 'chrome-extension://the-extension');
589
+ }
590
+
591
+ // ---------------------------------------------------------------------------
592
+ // Booting one build
593
+ // ---------------------------------------------------------------------------
594
+
595
+ /**
596
+ * What each prepared build is holding. Keyed by build id, emptied on teardown.
597
+ * @type {Map<string, {dir: string, manifest: Record<string, any>, playwright: any, config: Record<string, any>, base: string, work: string|null, footprint: {dirs: string[], ports: number[], projectRoot?: string}}>}
598
+ */
599
+ const running = new Map();
600
+
601
+ /**
602
+ * @param {Record<string, any>} config
603
+ * @returns {{width: number, height: number, deviceScaleFactor: number}}
604
+ */
605
+ function viewportFrom(config) {
606
+ return {
607
+ width: Number(config.viewport?.width ?? VIEWPORT.width),
608
+ height: Number(config.viewport?.height ?? VIEWPORT.height),
609
+ deviceScaleFactor: Number(config.viewport?.deviceScaleFactor ?? VIEWPORT.deviceScaleFactor),
610
+ };
611
+ }
612
+
613
+ /**
614
+ * The web lane's window opener, told to load one extension.
615
+ *
616
+ * `openWindow` owns every promise this tool makes about somebody's machine: a throwaway
617
+ * profile under the scratch folder, the right browser rather than the one the person uses,
618
+ * the window closed afterwards whatever happened. None of that should be written twice, and
619
+ * a second browser launcher in this repository is a second place for those promises to rot.
620
+ *
621
+ * So the browser DRIVER handed to it is wrapped rather than the launcher forked. Three
622
+ * things have to be different for an extension and nothing else does:
623
+ *
624
+ * - `--disable-extensions` has to come out. It is in the launcher's list for a good reason
625
+ * — a browser running somebody's extensions is a browser whose screen depends on
626
+ * yesterday — and it is exactly the flag that would stop this lane working.
627
+ * - `--disable-extensions-except` and `--load-extension` go in, naming the one folder. The
628
+ * first is what keeps the promise the flag it replaced was making: no extension but this
629
+ * one is loaded, ever.
630
+ * - service workers have to be allowed to run. The launcher blocks them, and in an
631
+ * extension the background worker IS a service worker, so blocking them switches off the
632
+ * whole background half of the product.
633
+ *
634
+ * When `web-driver.js` grows a way to pass extra flags through, this wrapper is three lines
635
+ * to delete. Until then it is the smaller of the two mistakes available.
636
+ *
637
+ * @param {any} chromium
638
+ * @param {string|null} extensionDir Null opens a plain browser with no extension in it.
639
+ * @returns {any}
640
+ */
641
+ export function chromiumThatLoads(chromium, extensionDir) {
642
+ if (!extensionDir) return chromium;
643
+ return {
644
+ /**
645
+ * @param {string} dir
646
+ * @param {Record<string, any>} options
647
+ */
648
+ launchPersistentContext: (dir, options) =>
649
+ chromium.launchPersistentContext(dir, {
650
+ ...options,
651
+ args: [
652
+ ...(options.args ?? []).filter((/** @type {string} */ flag) => flag !== '--disable-extensions'),
653
+ `--disable-extensions-except=${extensionDir}`,
654
+ `--load-extension=${extensionDir}`,
655
+ ],
656
+ serviceWorkers: 'allow',
657
+ }),
658
+ };
659
+ }
660
+
661
+ /**
662
+ * Open a browser with the extension in it, and find out what the browser called it.
663
+ *
664
+ * Two ways to the id, and the second checks the first. A running background worker's address
665
+ * IS the id, said by the browser itself. An extension with no background worker has nothing
666
+ * to ask, so the id is worked out from the folder — and then PROVED, by asking the browser
667
+ * for the extension's own manifest at that address. An id that cannot be proved is reported
668
+ * as an extension that did not load, never as one that loaded and did nothing.
669
+ *
670
+ * @param {object} input
671
+ * @param {any} input.playwright
672
+ * @param {string} input.scratchDir
673
+ * @param {{width: number, height: number, deviceScaleFactor: number}} input.viewport
674
+ * @param {string} input.label
675
+ * @param {string|null} input.extensionDir
676
+ * @param {'light'|'dark'} [input.colorScheme]
677
+ * @param {(context: any) => Promise<void>} [input.watch]
678
+ * Run the instant the browser exists and BEFORE anything else here touches it. It is a
679
+ * separate hook rather than something the caller does afterwards because of a measurement
680
+ * on 2026-08-31: an extension's background worker does its startup work — storing things,
681
+ * calling home — in the moment the browser comes up, and a caller that started listening
682
+ * after this function had finished proving the extension loaded had already missed it, and
683
+ * reported an extension that calls one address as an extension that calls none.
684
+ * @returns {Promise<{window: any, id: string|null, loaded: boolean, why: string}>}
685
+ */
686
+ async function openWithTheExtension(input) {
687
+ const window = await openWindow({
688
+ chromium: chromiumThatLoads(input.playwright.chromium, input.extensionDir),
689
+ executable: input.playwright.executable,
690
+ scratchDir: input.scratchDir,
691
+ viewport: input.viewport,
692
+ colorScheme: input.colorScheme ?? 'light',
693
+ label: input.label,
694
+ });
695
+ if (input.watch) await input.watch(window.context);
696
+
697
+ if (!input.extensionDir) return { window, id: null, loaded: false, why: 'This window was opened with no extension in it on purpose, to see what the page looks like without one.' };
698
+
699
+ const context = window.context;
700
+ const worker = context.serviceWorkers()[0] ?? (await context.waitForEvent('serviceworker', { timeout: 8000 }).catch(() => null));
701
+ let id = null;
702
+ try {
703
+ if (worker) id = new URL(String(worker.url())).host;
704
+ } catch {
705
+ // A worker address that will not parse tells us nothing. The folder gives an id too.
706
+ }
707
+ if (!id) id = idForUnpacked(input.extensionDir);
708
+
709
+ // The proof. A manifest that comes back is an extension the browser accepted and loaded;
710
+ // anything else means it refused it — a broken manifest, a permission it will not grant, a
711
+ // file that is not there — and every one of those must read as "not checked", not as "fine".
712
+ const probe = await context.newPage();
713
+ try {
714
+ // WHAT COMES BACK IS THE PROOF, NOT WHAT THE NAVIGATION RETURNED. Measured on
715
+ // 2026-08-31: the moment anything in this file is listening to network requests, this
716
+ // navigation hands back nothing at all where a second earlier it handed back a 200 — the
717
+ // page is there, fully loaded, and the driver simply has no response object for an
718
+ // address that never went over the network. Trusting the return value reported a working
719
+ // extension as one the browser had refused to load, and it did it only in the journey
720
+ // that watches the background worker, which is exactly the kind of bug nobody finds.
721
+ await probe.goto(`chrome-extension://${id}/manifest.json`, { timeout: 10000 });
722
+ const ok = String(await probe.content()).includes('manifest_version');
723
+ await probe.close().catch(() => {});
724
+ if (ok) return { window, id, loaded: true, why: `The browser loaded the extension and calls it ${id}.` };
725
+ return { window, id, loaded: false, why: 'The browser answered at the extension\'s own address but did not give back a manifest, so whatever is loaded there is not this extension.' };
726
+ } catch (error) {
727
+ await probe.close().catch(() => {});
728
+ return {
729
+ window,
730
+ id,
731
+ loaded: false,
732
+ why: `The browser refused to load this extension: ${error instanceof Error ? short(error.message, 160) : String(error)}. A browser refuses an extension when its manifest is broken, when it names a file that is not in the folder, or when it asks for something the browser will not give. Nothing about this extension was checked in this window.`,
733
+ };
734
+ }
735
+ }
736
+
737
+ /**
738
+ * The blank stand-in page served at an address a content script says it runs on.
739
+ *
740
+ * Deliberately plain, and deliberately the same every time and on every machine. It exists to
741
+ * answer one question — did the content script fire — and it says what it is, so nobody
742
+ * mistakes a stand-in for the real site.
743
+ *
744
+ * @param {string} url
745
+ * @returns {string}
746
+ */
747
+ export function standInPage(url) {
748
+ let host = url;
749
+ try {
750
+ host = new URL(url).host;
751
+ } catch {
752
+ // Not a parseable address. The whole thing is a fine heading.
753
+ }
754
+ return [
755
+ '<!doctype html><html lang="en"><head><meta charset="utf-8">',
756
+ `<title>${host}</title></head><body><main><h1>${host}</h1>`,
757
+ '<p>A stand-in page served from this machine. Nothing was fetched from the internet.</p>',
758
+ '</main></body></html>',
759
+ ].join('');
760
+ }
761
+
762
+ // ---------------------------------------------------------------------------
763
+ // The adapter
764
+ // ---------------------------------------------------------------------------
765
+
766
+ export const extensionAdapter = defineAdapter({
767
+ name: 'extension',
768
+ title: 'Browser extensions, loaded into a real browser',
769
+ describe:
770
+ "Reads the manifest as a contract - every permission, every site it may touch, every page and content script it declares - and then loads the extension into a throwaway browser with the clock stopped and the internet cut off. Its popup and options pages are walked like any other page, for what the screen MEANS rather than how it looks. What its content scripts DO to somebody else's page is measured by opening the same page twice, with the extension and without it, and writing down the difference. The background worker's storage and the calls it makes are recorded. It cannot read what the background worker logs, it never visits a real site, and unless a project supplies the real page the content script is tried against a blank stand-in - which answers whether the script fired, not whether it put things in the right place.",
771
+ channels: ['contract', 'meaning', 'effects', 'complaints', 'counters', 'pixels'],
772
+
773
+ /** @param {import('./contract.js').AdapterProject} project */
774
+ async detect(project) {
775
+ const config = project.config ?? {};
776
+ /** @type {Missing[]} */
777
+ const missing = [];
778
+
779
+ const found = findExtension(project.root, config.dir);
780
+ if (!found.file) {
781
+ return {
782
+ applies: false,
783
+ confidence: 0,
784
+ why: found.why,
785
+ missing: [
786
+ {
787
+ what: 'a folder with a manifest.json in it',
788
+ unlocks: 'checking this as a browser extension at all',
789
+ howToGet: 'Put {"dir": "dist"} under "extension" in the settings, pointing at the folder you would load unpacked in the browser.',
790
+ blocking: true,
791
+ },
792
+ ],
793
+ };
794
+ }
795
+
796
+ const read = readManifest(await fsp.readFile(found.file, 'utf8').catch(() => ''));
797
+ if (!read.ok) {
798
+ return {
799
+ applies: true,
800
+ confidence: 0.5,
801
+ why: `${found.why} ${read.why}`,
802
+ missing: [{ what: 'a manifest a browser would accept', unlocks: 'everything - a browser refuses to load this extension as it stands', howToGet: 'Fix the manifest.json named above.', blocking: true }],
803
+ };
804
+ }
805
+
806
+ const playwright = await loadPlaywright({ projectRoot: project.root });
807
+ if (!playwright.ok) {
808
+ missing.push({
809
+ what: playwright.state === 'no package' ? 'Playwright, the thing that drives the browser' : "Playwright's Chromium",
810
+ unlocks: 'loading the extension at all. The manifest is still read and compared without it, which is the half that catches a permission somebody widened',
811
+ howToGet: playwright.howToGet,
812
+ });
813
+ }
814
+
815
+ // A page named in the manifest that is not in the folder is an extension with a dead
816
+ // button, and it is worth saying before anything is run rather than after.
817
+ /** @type {string[]} */
818
+ const notThere = [];
819
+ for (const page of declaredPages(read.manifest)) {
820
+ if (!fs.existsSync(path.join(/** @type {string} */ (found.dir), page.file))) notThere.push(page.file);
821
+ }
822
+ for (const script of read.manifest.content_scripts ?? []) {
823
+ for (const file of [...(script?.js ?? []), ...(script?.css ?? [])]) {
824
+ if (!fs.existsSync(path.join(/** @type {string} */ (found.dir), String(file)))) notThere.push(String(file));
825
+ }
826
+ }
827
+ if (notThere.length > 0) {
828
+ missing.push({
829
+ what: `the ${notThere.length === 1 ? 'file' : `${notThere.length} files`} the manifest names that ${notThere.length === 1 ? 'is' : 'are'} not in that folder: ${[...new Set(notThere)].slice(0, 5).join(', ')}`,
830
+ unlocks: 'loading the extension at all — a browser refuses an extension whose manifest names a file that is not there',
831
+ howToGet: config.build
832
+ ? 'Run the build command in the settings and check it writes into the folder "extension.dir" points at.'
833
+ : 'Point "extension.dir" at your BUILT folder, or put {"build": "npm run build"} under "extension" so each build is built before it is loaded.',
834
+ });
835
+ }
836
+
837
+ const targets = contentScriptTargets(read.manifest);
838
+ const standIns = targets.filter((t) => t.url && !t.exact).length + targets.filter((t) => t.url && t.exact && !hasSuppliedPage(config, t.url)).length;
839
+ if (standIns > 0) {
840
+ missing.push({
841
+ what: `what the real page looks like for ${standIns === 1 ? 'the site' : `the ${standIns} sites`} the content scripts run on`,
842
+ unlocks: 'checking what the content script does to a real page instead of a blank one. Without it, what is checked is whether the script fired at all — which is the break that usually happens, but not the only one',
843
+ howToGet: `Put {"pages": [{"url": "${targets.find((t) => t.url)?.url ?? 'https://example.com/'}", "file": "test/fixtures/that-page.html"}]} under "extension" in the settings — a saved copy of the page, served from this machine.`,
844
+ });
845
+ }
846
+
847
+ const pages = declaredPages(read.manifest).length;
848
+ return {
849
+ applies: true,
850
+ confidence: 1,
851
+ why: `${found.why} It is a manifest version ${read.manifest.manifest_version} extension called "${read.manifest.name ?? 'something with no name'}", with ${pages} page${pages === 1 ? '' : 's'} of its own and ${(read.manifest.content_scripts ?? []).length} thing${(read.manifest.content_scripts ?? []).length === 1 ? '' : 's'} it injects into other people's pages. ${playwright.ok ? playwright.why : playwright.why}`,
852
+ missing,
853
+ notes: [
854
+ 'The manifest is read as a contract. A permission that appears, or a host permission that widens from one named site to every site, is reported as a change somebody has to agree to — and reading it needs no browser at all.',
855
+ "What a content script does is measured by opening the same page twice, once with the extension and once without, and comparing the difference. A content script that quietly stopped firing leaves a page that looks perfectly normal, because it is the normal page.",
856
+ 'Nothing ever reaches the internet. Every page a content script is tried against is served from this machine at the address the manifest itself names.',
857
+ 'The extension id is not compared. A browser makes it out of the folder the extension was loaded from, so the two builds of one comparison always have different ids, and comparing them would report a difference on every run.',
858
+ ],
859
+ };
860
+ },
861
+
862
+ /** @param {import('./contract.js').AdapterProject} project */
863
+ async journeys(project) {
864
+ const config = project.config ?? {};
865
+ const found = findExtension(project.root, config.dir);
866
+ if (!found.file) return [];
867
+
868
+ const read = readManifest(await fsp.readFile(found.file, 'utf8').catch(() => ''));
869
+ if (!read.ok) {
870
+ return [
871
+ {
872
+ name: 'the manifest',
873
+ describe: 'read the manifest as a contract',
874
+ source: 'code',
875
+ surface: 'extension',
876
+ from: path.relative(project.root, found.file),
877
+ channels: [],
878
+ steps: [],
879
+ skip: `${read.why} Nothing about this extension was listed, walked or counted, because a manifest is the only thing that says what an extension IS. This is a hole, not a pass.`,
880
+ },
881
+ ];
882
+ }
883
+
884
+ const manifest = read.manifest;
885
+ /** @type {Journey[]} */
886
+ const journeys = [
887
+ {
888
+ name: 'the manifest',
889
+ describe: 'read the manifest as a contract — the permissions, the sites, the pages',
890
+ source: 'code',
891
+ surface: 'extension',
892
+ from: path.relative(project.root, found.file),
893
+ channels: ['contract'],
894
+ steps: /** @type {any} */ ([{ act: 'read', kind: 'manifest', note: 'read the manifest' }]),
895
+ },
896
+ ];
897
+
898
+ for (const page of declaredPages(manifest)) {
899
+ journeys.push({
900
+ name: page.what,
901
+ describe: `open ${page.what} (${page.file}) and read what the screen says`,
902
+ source: 'code',
903
+ surface: 'extension',
904
+ from: `${path.relative(project.root, found.file)} → ${page.file}`,
905
+ channels: ['meaning', 'complaints', 'counters', 'pixels'],
906
+ steps: /** @type {any} */ ([{ act: 'open', kind: 'page', file: page.file, what: page.what, note: `open ${page.file}` }]),
907
+ });
908
+ }
909
+
910
+ for (const target of contentScriptTargets(manifest)) {
911
+ if (!target.url) {
912
+ journeys.push({
913
+ name: `what it does to the pages matching ${target.pattern}`,
914
+ describe: `what the content script does to ${target.pattern}`,
915
+ source: 'code',
916
+ surface: 'extension',
917
+ from: path.relative(project.root, found.file),
918
+ channels: [],
919
+ steps: [],
920
+ skip: `The content script that runs on "${target.pattern}" was not tried, because ${target.why}. Whatever it does to those pages is not in this check, and is not in the count of what is.`,
921
+ });
922
+ continue;
923
+ }
924
+ journeys.push({
925
+ name: `what it does to ${target.url}`,
926
+ describe: `open ${target.url} with the extension and without it, and write down the difference`,
927
+ source: 'code',
928
+ surface: 'extension',
929
+ from: path.relative(project.root, found.file),
930
+ channels: ['meaning', 'effects', 'counters'],
931
+ steps: /** @type {any} */ ([{ act: 'open', kind: 'content', url: target.url, pattern: target.pattern, exact: target.exact, why: target.why, files: target.files, note: `open ${target.url}` }]),
932
+ });
933
+ }
934
+
935
+ if (manifest.background) {
936
+ journeys.push({
937
+ name: 'the background worker',
938
+ describe: 'start the extension and watch what its background worker stores',
939
+ source: 'code',
940
+ surface: 'extension',
941
+ from: path.relative(project.root, found.file),
942
+ // Only what it actually fills. A journey that claims a channel it never collects
943
+ // makes the coverage ledger say a question was answered when nobody asked it, and
944
+ // that ledger is the one place a person goes to find out what was NOT looked at.
945
+ channels: ['effects', 'counters', 'complaints'],
946
+ steps: /** @type {any} */ ([{ act: 'open', kind: 'background', note: 'let the background worker start' }]),
947
+ });
948
+ }
949
+
950
+ return journeys;
951
+ },
952
+
953
+ /**
954
+ * Get one build ready to be loaded.
955
+ *
956
+ * Nothing is opened here. A browser is opened per journey, each with a profile of its own,
957
+ * for the same reason the web lane does it: an extension that wrote something to storage in
958
+ * one journey must not be able to change what the next journey sees.
959
+ *
960
+ * @param {import('./contract.js').Build} build
961
+ * @param {import('./contract.js').RunContext} ctx
962
+ */
963
+ async prepare(build, ctx) {
964
+ const config = ctx.config ?? {};
965
+ const base = path.join(ctx.scratchDir, `extension-${build.id.slice(0, 12).replace(/[^A-Za-z0-9_-]/g, '-')}`);
966
+ await fsp.mkdir(base, { recursive: true });
967
+
968
+ /** @param {string} why */
969
+ const notReady = (why) => ({
970
+ build,
971
+ root: base,
972
+ ready: false,
973
+ why,
974
+ dispose: async () => {
975
+ await fsp.rm(base, { recursive: true, force: true });
976
+ },
977
+ });
978
+
979
+ const playwright = await loadPlaywright({ projectRoot: build.root });
980
+
981
+ // Where the extension is read from. With a build command it is built in a scratch copy
982
+ // first, because the folder a browser loads is the folder the bundler wrote — reading
983
+ // the source folder instead would compare a description of the product rather than the
984
+ // product. Without one, the build's own folder is read and never written to.
985
+ let root = build.root;
986
+ /** @type {string|null} */
987
+ let work = null;
988
+ /** @type {string[]} */
989
+ const notes = [];
990
+ if (config.build) {
991
+ work = path.join(base, 'work');
992
+ const copy = await copyForScratch(build.root, work);
993
+ if (!copy.copied) return notReady(copy.why);
994
+ const env = frozenEnvironment({
995
+ clock: ctx.clock,
996
+ seed: ctx.seed,
997
+ home: path.join(base, 'home'),
998
+ tmp: path.join(base, 'tmp'),
999
+ extra: { NODE_ENV: config.nodeEnv ?? 'production', ...config.env },
1000
+ });
1001
+ await fsp.mkdir(path.join(base, 'home'), { recursive: true });
1002
+ await fsp.mkdir(path.join(base, 'tmp'), { recursive: true });
1003
+ /** @type {Buffer[]} */
1004
+ const said = [];
1005
+ const child = spawnServer(String(config.build), { cwd: work, env });
1006
+ child.stdout?.on('data', (c) => said.push(c));
1007
+ child.stderr?.on('data', (c) => said.push(c));
1008
+ const code = await withLimit(
1009
+ new Promise((resolve) => {
1010
+ child.on('error', () => resolve(-1));
1011
+ child.on('close', (status) => resolve(status ?? -1));
1012
+ }),
1013
+ Number(config.buildTimeoutMs ?? 300000),
1014
+ 'ran out of time',
1015
+ );
1016
+ await stopServer(child);
1017
+ if (code !== 0) {
1018
+ return notReady(
1019
+ `The command that builds this extension ("${config.build}") ${code === 'ran out of time' ? `did not finish within ${timeBucket(Number(config.buildTimeoutMs ?? 300000))}` : `failed with exit code ${code}`}, so there is nothing built to load. What it printed: ${trimForStorage(Buffer.concat(said).toString('utf8'), 1500).text || '(nothing)'}`,
1020
+ );
1021
+ }
1022
+ root = work;
1023
+ notes.push(`It was built first with "${config.build}".`);
1024
+ }
1025
+
1026
+ const found = findExtension(root, config.dir);
1027
+ if (!found.file || !found.dir) return notReady(found.why);
1028
+ const read = readManifest(await fsp.readFile(found.file, 'utf8').catch(() => ''));
1029
+ if (!read.ok) return notReady(read.why);
1030
+
1031
+ running.set(build.id, {
1032
+ dir: found.dir,
1033
+ manifest: read.manifest,
1034
+ playwright,
1035
+ config,
1036
+ base,
1037
+ work,
1038
+ footprint: { dirs: [base, root].filter(Boolean), ports: [], projectRoot: build.root },
1039
+ });
1040
+
1041
+ return {
1042
+ build,
1043
+ root: found.dir,
1044
+ ready: true,
1045
+ why: `${found.why}${notes.length > 0 ? ` ${notes.join(' ')}` : ''} ${playwright.ok ? 'It will be loaded into a browser with a throwaway profile, and no other extension will be loaded with it.' : `${playwright.why} The manifest is still read and compared; nothing that needs a browser is.`}`,
1046
+ facts: { dir: found.dir, manifestVersion: Number(read.manifest.manifest_version) },
1047
+ dispose: async () => {
1048
+ running.delete(build.id);
1049
+ await fsp.rm(base, { recursive: true, force: true });
1050
+ },
1051
+ };
1052
+ },
1053
+
1054
+ /**
1055
+ * Walk one journey against one prepared build.
1056
+ *
1057
+ * @param {Journey} journey
1058
+ * @param {import('./contract.js').PreparedBuild} build
1059
+ * @param {import('./contract.js').RunContext} ctx
1060
+ * @returns {Promise<Observation[]>}
1061
+ */
1062
+ async run(journey, build, ctx) {
1063
+ const held = running.get(build.build.id);
1064
+ if (!build.ready || !held) {
1065
+ return [
1066
+ notCovered({
1067
+ channel: 'contract',
1068
+ path: joinPath('extension', journey.name, 'looked at at all'),
1069
+ reason: /playwright|chromium|browser/i.test(build.why) ? 'missing tool' : 'crashed',
1070
+ says: `"${journey.describe}" was not looked at: ${build.why}`,
1071
+ }),
1072
+ ];
1073
+ }
1074
+
1075
+ const step = /** @type {Record<string, any>} */ ((journey.steps ?? [])[0] ?? {});
1076
+ const kind = String(step.kind ?? '');
1077
+
1078
+ // The manifest is read, never run, so it works on a machine with no browser on it at
1079
+ // all — which is most build servers. It is deliberately the first thing here.
1080
+ if (kind === 'manifest') return theManifest(journey, held);
1081
+
1082
+ if (!held.playwright.ok) {
1083
+ return [
1084
+ notCovered({
1085
+ channel: kind === 'content' ? 'meaning' : 'meaning',
1086
+ path: joinPath('extension', journey.name, 'opened at all'),
1087
+ reason: 'missing tool',
1088
+ says: `"${journey.describe}" needs a browser and there is not one this can open. ${held.playwright.why}${held.playwright.howToGet ? ` Run: ${held.playwright.howToGet}` : ''} The manifest was still read and compared.`,
1089
+ }),
1090
+ ];
1091
+ }
1092
+
1093
+ if (kind === 'page') return await onePage(journey, step, held, ctx, build.build.id);
1094
+ if (kind === 'content') return await whatItDoesToAPage(journey, step, held, ctx);
1095
+ if (kind === 'background') return await theBackgroundWorker(journey, held, ctx);
1096
+
1097
+ return [
1098
+ notCovered({
1099
+ channel: 'contract',
1100
+ path: joinPath('extension', journey.name, 'looked at at all'),
1101
+ reason: 'not supported here',
1102
+ says: `"${journey.name}" asks for something this lane does not know how to do ("${kind}"), so nothing about it was checked.`,
1103
+ }),
1104
+ ];
1105
+ },
1106
+
1107
+ async teardown() {
1108
+ running.clear();
1109
+ },
1110
+ });
1111
+
1112
+ // ---------------------------------------------------------------------------
1113
+ // The manifest — the contract
1114
+ // ---------------------------------------------------------------------------
1115
+
1116
+ /**
1117
+ * @param {Journey} journey
1118
+ * @param {NonNullable<ReturnType<typeof running.get>>} held
1119
+ * @returns {Observation[]}
1120
+ */
1121
+ function theManifest(journey, held) {
1122
+ /** @type {Observation[]} */
1123
+ const out = [];
1124
+ for (const fact of manifestContract(held.manifest)) {
1125
+ out.push(
1126
+ observation({
1127
+ channel: 'contract',
1128
+ path: joinPath('contract', 'the manifest', ...fact.path),
1129
+ value: fact.value,
1130
+ says: fact.says,
1131
+ journey: journey.name,
1132
+ surface: 'extension',
1133
+ }),
1134
+ );
1135
+ }
1136
+ return out;
1137
+ }
1138
+
1139
+ // ---------------------------------------------------------------------------
1140
+ // The popup and the options page — ordinary pages at an unusual address
1141
+ // ---------------------------------------------------------------------------
1142
+
1143
+ /**
1144
+ * Open one of the extension's own pages and write down what it means.
1145
+ *
1146
+ * @param {Journey} journey
1147
+ * @param {Record<string, any>} step
1148
+ * @param {NonNullable<ReturnType<typeof running.get>>} held
1149
+ * @param {import('./contract.js').RunContext} ctx
1150
+ * @param {string} buildId
1151
+ * @returns {Promise<Observation[]>}
1152
+ */
1153
+ async function onePage(journey, step, held, ctx, buildId) {
1154
+ /** @type {Observation[]} */
1155
+ const out = [];
1156
+ const file = String(step.file);
1157
+ const viewport = viewportFrom(held.config);
1158
+ const opened = await openWithTheExtension({
1159
+ playwright: held.playwright,
1160
+ scratchDir: ctx.scratchDir,
1161
+ viewport,
1162
+ label: journey.name,
1163
+ extensionDir: held.dir,
1164
+ colorScheme: held.config.colorScheme ?? 'light',
1165
+ });
1166
+
1167
+ try {
1168
+ if (!opened.loaded) {
1169
+ return [
1170
+ notCovered({
1171
+ channel: 'meaning',
1172
+ path: joinPath('screen', journey.name, 'opened at all'),
1173
+ reason: 'crashed',
1174
+ says: `${journey.name} was not opened. ${opened.why}`,
1175
+ }),
1176
+ ];
1177
+ }
1178
+
1179
+ const id = /** @type {string} */ (opened.id);
1180
+ const handle = opened.window.handle;
1181
+ const address = `chrome-extension://${id}/${file}`;
1182
+ handle.baseUrl = `chrome-extension://${id}`;
1183
+
1184
+ // The freeze goes on before the page is fetched, exactly as it does for a web page. The
1185
+ // one difference is the allow list: an extension page loads its own scripts and styles
1186
+ // out of the extension, and "block everything that is not this app's own origin" does not
1187
+ // know that a chrome-extension address is this app. Without this line the popup opens
1188
+ // with none of its own code in it, which reads as a popup that lost all its buttons.
1189
+ const frozen = await applyFreeze(
1190
+ handle,
1191
+ {
1192
+ clock: ctx.clock,
1193
+ seed: ctx.seed,
1194
+ timezone: held.config.timezone ?? 'UTC',
1195
+ locale: held.config.locale ?? 'en-US',
1196
+ network: 'block-external',
1197
+ networkAllow: ['chrome-extension://**', ...(held.config.allowHosts ?? [])],
1198
+ hideScrollbars: true,
1199
+ hideCaret: true,
1200
+ },
1201
+ { fixturesDir: ctx.evidenceDir, screenName: journey.name, deviceScaleFactor: viewport.deviceScaleFactor },
1202
+ );
1203
+
1204
+ const started = Date.now();
1205
+ const page = opened.window.page;
1206
+ try {
1207
+ await page.goto(address, { timeout: Number(held.config.timeoutMs ?? 30000), waitUntil: 'load' });
1208
+ } catch (error) {
1209
+ out.push(
1210
+ notCovered({
1211
+ channel: 'meaning',
1212
+ path: joinPath('screen', journey.name, 'opened at all'),
1213
+ reason: 'crashed',
1214
+ says: `${journey.name} is named in the manifest as ${file}, and the browser would not open it: ${error instanceof Error ? short(withoutTheId(error.message, id), 200) : String(error)}. A page the manifest names that will not open is a button in this extension that does nothing.`,
1215
+ }),
1216
+ );
1217
+ await withLimit(frozen.release(), 10000, undefined);
1218
+ return out;
1219
+ }
1220
+
1221
+ out.push(...(await readTheScreen({ window: opened.window, journey, checkpoint: 'end', ctx, config: held.config, footprint: held.footprint, id, buildId, at: `${file}` })));
1222
+ out.push(...complaintsFrom(journey, handle.consoleErrors(), id));
1223
+ out.push(
1224
+ howLongItTook({
1225
+ channel: 'counters',
1226
+ path: joinPath('count', journey.name, 'how long it took to open'),
1227
+ ms: Date.now() - started,
1228
+ what: `Opening ${journey.name}`,
1229
+ andAlso: 'This does not count starting the browser or loading the extension, which is our time and not the product\'s.',
1230
+ journey: journey.name,
1231
+ }),
1232
+ );
1233
+ await withLimit(frozen.release(), 10000, undefined);
1234
+ } finally {
1235
+ await opened.window.close();
1236
+ }
1237
+ return out;
1238
+ }
1239
+
1240
+ // ---------------------------------------------------------------------------
1241
+ // The content script — the difference IS the product
1242
+ // ---------------------------------------------------------------------------
1243
+
1244
+ /**
1245
+ * Has the project supplied a real page for this address?
1246
+ *
1247
+ * @param {Record<string, any>} config
1248
+ * @param {string} url
1249
+ * @returns {boolean}
1250
+ */
1251
+ function hasSuppliedPage(config, url) {
1252
+ return Boolean(suppliedPageFor(config, url));
1253
+ }
1254
+
1255
+ /**
1256
+ * The page a project supplied for one address, if it supplied one.
1257
+ *
1258
+ * Matched on the exact address first and the site second, because a project that saves one
1259
+ * copy of a site's page means it for that site, and asking somebody to write out every
1260
+ * address is asking them not to bother.
1261
+ *
1262
+ * @param {Record<string, any>} config
1263
+ * @param {string} url
1264
+ * @returns {{url: string, file?: string, html?: string}|null}
1265
+ */
1266
+ export function suppliedPageFor(config, url) {
1267
+ const pages = /** @type {any[]} */ (config.pages ?? []);
1268
+ const exact = pages.find((p) => String(p?.url ?? '') === url);
1269
+ if (exact) return exact;
1270
+ let origin = '';
1271
+ try {
1272
+ origin = new URL(url).origin;
1273
+ } catch {
1274
+ return null;
1275
+ }
1276
+ return pages.find((p) => {
1277
+ try {
1278
+ return new URL(String(p?.url ?? '')).origin === origin;
1279
+ } catch {
1280
+ return false;
1281
+ }
1282
+ }) ?? null;
1283
+ }
1284
+
1285
+ /**
1286
+ * Open one page twice — with the extension and without it — and write down the difference.
1287
+ *
1288
+ * THE TWO WINDOWS ARE NEVER OPEN AT ONCE, for the same reason the two builds never are. Two
1289
+ * browsers on one machine compete for memory and for the processor, and a page that rendered
1290
+ * late because the other browser was busy looks exactly like a content script that stopped
1291
+ * firing. The plain window goes first and is shut before the second is opened.
1292
+ *
1293
+ * The page itself is served from this machine at the address the manifest names, so the
1294
+ * address matches what the content script says it runs on and nothing leaves the machine.
1295
+ * Measured on 2026-08-31: with the freeze layer told to allow that one address and the page
1296
+ * answered locally, the content script fired and the banner it adds was in the meaning tree.
1297
+ *
1298
+ * @param {Journey} journey
1299
+ * @param {Record<string, any>} step
1300
+ * @param {NonNullable<ReturnType<typeof running.get>>} held
1301
+ * @param {import('./contract.js').RunContext} ctx
1302
+ * @returns {Promise<Observation[]>}
1303
+ */
1304
+ async function whatItDoesToAPage(journey, step, held, ctx) {
1305
+ /** @type {Observation[]} */
1306
+ const out = [];
1307
+ const url = String(step.url);
1308
+ const viewport = viewportFrom(held.config);
1309
+ const head = ['extension', journey.name];
1310
+
1311
+ const supplied = suppliedPageFor(held.config, url);
1312
+ /** @type {string} */
1313
+ let html;
1314
+ /** @type {string} */
1315
+ let whatThePageIs;
1316
+ if (supplied?.html !== undefined) {
1317
+ html = String(supplied.html);
1318
+ whatThePageIs = 'the page the project supplied in its settings';
1319
+ } else if (supplied?.file !== undefined) {
1320
+ const file = path.isAbsolute(String(supplied.file)) ? String(supplied.file) : path.join(held.dir, '..', String(supplied.file));
1321
+ const read = await fsp.readFile(file, 'utf8').catch(() => null);
1322
+ if (read === null) {
1323
+ html = standInPage(url);
1324
+ whatThePageIs = `a blank stand-in, because the page the settings point at (${supplied.file}) could not be read`;
1325
+ } else {
1326
+ html = read;
1327
+ whatThePageIs = `the saved copy of the page at ${supplied.file}`;
1328
+ }
1329
+ } else {
1330
+ html = standInPage(url);
1331
+ whatThePageIs = 'a blank stand-in page served from this machine';
1332
+ }
1333
+
1334
+ const standIn = whatThePageIs.startsWith('a blank stand-in');
1335
+
1336
+ /**
1337
+ * Open one window, put the page in front of it, and read what the screen means.
1338
+ *
1339
+ * @param {string|null} extensionDir
1340
+ * @param {string} label
1341
+ * @returns {Promise<{entries: MeaningEntry[], asked: {method: string, pattern: string}[], loaded: boolean, why: string, id: string|null}>}
1342
+ */
1343
+ const look = async (extensionDir, label) => {
1344
+ const opened = await openWithTheExtension({
1345
+ playwright: held.playwright,
1346
+ scratchDir: ctx.scratchDir,
1347
+ viewport,
1348
+ label: `${journey.name} ${label}`,
1349
+ extensionDir,
1350
+ colorScheme: held.config.colorScheme ?? 'light',
1351
+ });
1352
+ /** @type {{method: string, pattern: string}[]} */
1353
+ const asked = [];
1354
+ try {
1355
+ if (extensionDir && !opened.loaded) return { entries: [], asked, loaded: false, why: opened.why, id: opened.id };
1356
+
1357
+ const handle = opened.window.handle;
1358
+ handle.baseUrl = url;
1359
+ const frozen = await applyFreeze(
1360
+ handle,
1361
+ {
1362
+ clock: ctx.clock,
1363
+ seed: ctx.seed,
1364
+ timezone: held.config.timezone ?? 'UTC',
1365
+ locale: held.config.locale ?? 'en-US',
1366
+ network: 'block-external',
1367
+ // The freeze layer would refuse this address as somebody else's server, which it
1368
+ // is. It is allowed through so that our OWN answer below can be the thing that
1369
+ // serves it — the request is answered inside this process and never goes out.
1370
+ networkAllow: [url, `${url}**`, 'chrome-extension://**', ...(held.config.allowHosts ?? [])],
1371
+ hideScrollbars: true,
1372
+ hideCaret: true,
1373
+ },
1374
+ { fixturesDir: ctx.evidenceDir, screenName: journey.name, deviceScaleFactor: viewport.deviceScaleFactor },
1375
+ );
1376
+
1377
+ // One answer for everything that goes over the network, given here rather than fetched.
1378
+ // The page the content script runs on is served; everything else the page or the script
1379
+ // then asks for is written down and refused, because a content script that phones home
1380
+ // would otherwise make this check depend on somebody else's server being awake.
1381
+ //
1382
+ // ONLY http AND https ARE MATCHED, and that is not tidiness. An extension loads its own
1383
+ // scripts, styles and worker from `chrome-extension://` addresses, and those are not
1384
+ // network requests anything here can hand back. Measured on 2026-08-31: catching them
1385
+ // and calling `route.continue()` left the extension's background worker unable to
1386
+ // answer at all, so what it had in storage came back as "it fell over" on an extension
1387
+ // that was working perfectly. Never intercepting them fixes it outright.
1388
+ await opened.window.context.route(/^https?:\/\//, async (/** @type {any} */ route) => {
1389
+ const request = route.request();
1390
+ const asking = String(request.url());
1391
+ if (asking === url || asking === `${url}/`) {
1392
+ await route.fulfill({ status: 200, contentType: 'text/html; charset=utf-8', body: html }).catch(() => {});
1393
+ return;
1394
+ }
1395
+ asked.push({ method: String(request.method()).toUpperCase(), pattern: wirePattern(asking, null) });
1396
+ await route.abort().catch(() => {});
1397
+ });
1398
+
1399
+ await opened.window.page.goto(url, { timeout: Number(held.config.timeoutMs ?? 30000), waitUntil: 'load' }).catch(() => {});
1400
+ // A content script that runs at `document_idle` has not run when `load` fires. Waiting
1401
+ // for the page to stop moving is what makes this fair to a script that runs late — and
1402
+ // reading the tree too early is how a tool reports a content script as missing when it
1403
+ // was simply not there yet.
1404
+ await settle(handle, { frames: 2, intervalMs: 150, timeoutMs: Number(held.config.settleTimeoutMs ?? 8000), capture: () => handle.shoot() }).catch(() => {});
1405
+
1406
+ /** @type {MeaningEntry[]} */
1407
+ let entries = [];
1408
+ try {
1409
+ entries = flattenAria(parseAria(await opened.window.page.locator('body').ariaSnapshot()));
1410
+ } catch {
1411
+ // Reported by the caller, which knows which of the two windows this was.
1412
+ }
1413
+ await withLimit(frozen.release(), 10000, undefined);
1414
+ return { entries, asked, loaded: true, why: opened.why, id: opened.id };
1415
+ } finally {
1416
+ await opened.window.close();
1417
+ }
1418
+ };
1419
+
1420
+ const plain = await look(null, 'without the extension');
1421
+ const withIt = await look(held.dir, 'with the extension');
1422
+
1423
+ if (!withIt.loaded) {
1424
+ return [
1425
+ notCovered({
1426
+ channel: 'meaning',
1427
+ path: joinPath(...head, 'what it changes'),
1428
+ reason: 'crashed',
1429
+ says: `What this extension does to ${url} was not checked. ${withIt.why} Nothing is being claimed about that page either way — in particular, this is NOT a report that the extension leaves the page alone.`,
1430
+ }),
1431
+ ];
1432
+ }
1433
+
1434
+ if (plain.entries.length === 0 && withIt.entries.length === 0) {
1435
+ return [
1436
+ notCovered({
1437
+ channel: 'meaning',
1438
+ path: joinPath(...head, 'what it changes'),
1439
+ reason: 'crashed',
1440
+ says: `Neither the page with the extension nor the page without it could be read at ${url}, so there is no difference to report and nothing about this content script was checked.`,
1441
+ }),
1442
+ ];
1443
+ }
1444
+
1445
+ const difference = differenceMade(plain.entries, withIt.entries);
1446
+ const touched = difference.added.length > 0 || difference.removed.length > 0 || difference.changed.length > 0;
1447
+
1448
+ // THE HEADLINE. Everything else in this journey is detail; this one line is what flips
1449
+ // when a content script stops firing, and it flips whatever the page it was firing on
1450
+ // happened to contain.
1451
+ out.push(
1452
+ observation({
1453
+ channel: 'meaning',
1454
+ path: joinPath(...head, 'does it change this page at all'),
1455
+ value: touched,
1456
+ says: touched
1457
+ ? `With the extension loaded, ${url} is different from the same page without it. That difference is what this extension does, and it is written out below.`
1458
+ : `With the extension loaded, ${url} is EXACTLY the same page as without it. The manifest says a content script runs there. If this used to be true and is now false, the content script has stopped firing — and a page whose content script stopped firing looks completely normal, because it is the normal page.`,
1459
+ journey: journey.name,
1460
+ surface: 'extension',
1461
+ }),
1462
+ );
1463
+
1464
+ out.push(
1465
+ observation({
1466
+ channel: 'meaning',
1467
+ path: joinPath(...head, 'what the page it was tried against was'),
1468
+ value: standIn ? 'a blank stand-in' : 'a saved copy of the real page',
1469
+ says: standIn
1470
+ ? `The page at ${url} was ${whatThePageIs}, not the real site — nothing here ever reaches the internet. That is enough to answer whether the content script fired and what it puts on a page; it is NOT enough to answer whether it put things in the right place on the real site, and that part is not checked. ${step.exact === false ? `The manifest says this script runs on every site, so this one address stands in for all of them.` : ''}`.trim()
1471
+ : `The page at ${url} was ${whatThePageIs}, served from this machine. Nothing reached the internet.`,
1472
+ journey: journey.name,
1473
+ surface: 'extension',
1474
+ }),
1475
+ );
1476
+
1477
+ for (const entry of difference.added) {
1478
+ out.push(
1479
+ observation({
1480
+ channel: 'meaning',
1481
+ path: joinPath(...head, 'what it adds to the page', ...entry.at),
1482
+ value: typeof entry.value === 'string' ? undoOurFootprint(withoutTheId(entry.value, withIt.id), held.footprint) : entry.value,
1483
+ says: `The extension puts this on the page: ${entry.describe}`,
1484
+ journey: journey.name,
1485
+ surface: 'extension',
1486
+ }),
1487
+ );
1488
+ }
1489
+ for (const entry of difference.removed) {
1490
+ out.push(
1491
+ observation({
1492
+ channel: 'meaning',
1493
+ path: joinPath(...head, 'what it takes off the page', ...entry.at),
1494
+ value: typeof entry.value === 'string' ? undoOurFootprint(withoutTheId(entry.value, withIt.id), held.footprint) : entry.value,
1495
+ says: `The extension takes this off the page: ${entry.describe}. An extension that removes something from somebody else's page is doing the most invasive thing an extension can do, so it is written down on its own.`,
1496
+ journey: journey.name,
1497
+ surface: 'extension',
1498
+ }),
1499
+ );
1500
+ }
1501
+ for (const change of difference.changed) {
1502
+ out.push(
1503
+ observation({
1504
+ channel: 'meaning',
1505
+ path: joinPath(...head, 'what it rewrites on the page', change.at),
1506
+ value: { was: change.was, now: change.now },
1507
+ says: `The extension changes what "${change.at}" says on this page.`,
1508
+ journey: journey.name,
1509
+ surface: 'extension',
1510
+ }),
1511
+ );
1512
+ }
1513
+
1514
+ out.push(
1515
+ observation({
1516
+ channel: 'counters',
1517
+ path: joinPath('count', journey.name, 'things it adds to the page'),
1518
+ value: countBucket(difference.added.length),
1519
+ says: `The extension adds ${difference.added.length} thing${difference.added.length === 1 ? '' : 's'} a person could act on to ${url}. Small counts are exact, because one going to none IS the finding.`,
1520
+ journey: journey.name,
1521
+ surface: 'extension',
1522
+ }),
1523
+ );
1524
+ out.push(
1525
+ observation({
1526
+ channel: 'counters',
1527
+ path: joinPath('count', journey.name, 'things on the page before it touched it'),
1528
+ value: countBucket(plain.entries.length),
1529
+ says: `Without the extension there were ${plain.entries.length} things on that page. This is the control: if it moves, the page being tried against changed, and nothing below should be blamed on the extension.`,
1530
+ journey: journey.name,
1531
+ surface: 'extension',
1532
+ }),
1533
+ );
1534
+
1535
+ /** @type {Map<string, number>} */
1536
+ const grouped = new Map();
1537
+ for (const call of withIt.asked) {
1538
+ const key = `${call.method} ${call.pattern}`;
1539
+ grouped.set(key, (grouped.get(key) ?? 0) + 1);
1540
+ }
1541
+ for (const call of plain.asked) {
1542
+ // Anything the plain page asked for is the PAGE's own traffic, not the extension's, and
1543
+ // blaming the extension for it would be wrong on every run.
1544
+ grouped.delete(`${call.method} ${call.pattern}`);
1545
+ }
1546
+ for (const [asked, times] of [...grouped.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
1547
+ out.push(
1548
+ observation({
1549
+ channel: 'effects',
1550
+ path: joinPath('net', journey.name, asked),
1551
+ value: { 'asked for': countBucket(times) },
1552
+ says: `On that page, and only because the extension was loaded, something asked for ${asked}. It was refused — nothing here reaches the internet — but that it was asked for at all is recorded, because a call that used to go out and no longer does is one of the most common ways a thing keeps looking right while having stopped working.`,
1553
+ journey: journey.name,
1554
+ surface: 'extension',
1555
+ }),
1556
+ );
1557
+ }
1558
+
1559
+ return out;
1560
+ }
1561
+
1562
+ // ---------------------------------------------------------------------------
1563
+ // The background worker
1564
+ // ---------------------------------------------------------------------------
1565
+
1566
+ /**
1567
+ * Start the extension and watch what its background worker does.
1568
+ *
1569
+ * WHAT IS COVERED: whether it is running at all, what it puts in storage, and what it asks
1570
+ * the network for.
1571
+ *
1572
+ * WHAT IS NOT, and this is said on every run rather than left for somebody to discover: what
1573
+ * it LOGS. Measured on 2026-08-31 — the browser driver will not open a debugging session onto
1574
+ * a service worker ("expected Page or Frame"), and by the time this lane has a handle on the
1575
+ * extension at all, the worker has already started and already logged whatever it logs when
1576
+ * it starts, which is most of it. Attaching later and reporting only the tail would be worse
1577
+ * than reporting nothing: it would look like the whole log.
1578
+ *
1579
+ * WHAT IS STORED IS RECORDED BY SHAPE, NOT BY VALUE. Measured the same day: a worker that
1580
+ * writes `installedAt: Date.now()` writes a different number every single run, because the
1581
+ * frozen clock is installed on the PAGE and a service worker is not a page. Comparing the
1582
+ * values would report a difference on every run of an extension that has never changed.
1583
+ * The keys and the types are the promise; today's numbers are not.
1584
+ *
1585
+ * @param {Journey} journey
1586
+ * @param {NonNullable<ReturnType<typeof running.get>>} held
1587
+ * @param {import('./contract.js').RunContext} ctx
1588
+ * @returns {Promise<Observation[]>}
1589
+ */
1590
+ async function theBackgroundWorker(journey, held, ctx) {
1591
+ /** @type {Observation[]} */
1592
+ const out = [];
1593
+ const viewport = viewportFrom(held.config);
1594
+ const head = ['extension', journey.name];
1595
+
1596
+ /** Every background worker that appeared while this window was open. @type {string[]} */
1597
+ const everStarted = [];
1598
+
1599
+ const opened = await openWithTheExtension({
1600
+ playwright: held.playwright,
1601
+ scratchDir: ctx.scratchDir,
1602
+ viewport,
1603
+ label: journey.name,
1604
+ extensionDir: held.dir,
1605
+ watch: async (context) => {
1606
+ // A background worker that has gone to sleep is a HEALTHY background worker — the
1607
+ // whole design of a manifest v3 worker is that it starts, does its work and stops. So
1608
+ // what is written down is whether one ever started, not whether one happens to be
1609
+ // awake at the moment somebody looked. Asking the second question would report a
1610
+ // perfectly working extension as broken, on a timing coin toss.
1611
+ context.on('serviceworker', (/** @type {any} */ worker) => everStarted.push(String(worker.url())));
1612
+ // THE WIRE IS CUT, and nothing is claimed about what went down it. This lane does not
1613
+ // freeze this window — there is no page here to freeze — so this is what stops a
1614
+ // background worker phoning home from a check. Every http and https request is refused
1615
+ // and none of them is reported; see the note where that hole is written down. Only
1616
+ // http and https are matched, never `chrome-extension://` — see the note on the same
1617
+ // call in `whatItDoesToAPage` for what intercepting the extension's own files did.
1618
+ await context.route(/^https?:\/\//, async (/** @type {any} */ route) => {
1619
+ await route.abort().catch(() => {});
1620
+ });
1621
+ },
1622
+ });
1623
+
1624
+ try {
1625
+ if (!opened.loaded) {
1626
+ return [
1627
+ notCovered({
1628
+ channel: 'effects',
1629
+ path: joinPath(...head, 'started at all'),
1630
+ reason: 'crashed',
1631
+ says: `The background worker was not watched. ${opened.why}`,
1632
+ }),
1633
+ ];
1634
+ }
1635
+
1636
+ const context = opened.window.context;
1637
+
1638
+ // Give it a moment of its own. A worker's install work is asynchronous, and reading
1639
+ // storage the instant the extension loads reads it before the worker has written
1640
+ // anything — which looks exactly like a worker that stopped writing.
1641
+ await new Promise((done) => {
1642
+ const timer = setTimeout(done, Number(held.config.backgroundSettleMs ?? 2000));
1643
+ if (typeof timer.unref === 'function') timer.unref();
1644
+ });
1645
+
1646
+ const started = everStarted.length > 0 || context.serviceWorkers().length > 0;
1647
+ const wanted = Boolean(held.manifest.background?.service_worker);
1648
+ out.push(
1649
+ observation({
1650
+ channel: 'effects',
1651
+ path: joinPath(...head, 'did it start'),
1652
+ value: started,
1653
+ says: started
1654
+ ? 'The background worker started. This is the half of an extension nobody can see, and an extension whose background stopped starting looks completely normal until the thing it was doing quietly stops happening. Whether it is awake right now is deliberately not recorded: a manifest v3 worker is MEANT to stop when it has nothing to do.'
1655
+ : wanted
1656
+ ? 'The manifest declares a background worker and NO background worker ever started. Everything this extension does in the background is not happening.'
1657
+ : 'This extension declares its background the old way (a page or a set of scripts rather than a worker), and nothing this lane can ask for started. What its background does is not covered here.',
1658
+ journey: journey.name,
1659
+ surface: 'extension',
1660
+ }),
1661
+ );
1662
+
1663
+ if (started) {
1664
+ // WHAT IS IN STORAGE IS ASKED OF A PAGE, NOT OF THE WORKER. Measured on 2026-08-31, and
1665
+ // it cost an hour: a manifest v3 worker goes to sleep within seconds of finishing its
1666
+ // work, and asking a sleeping worker ANYTHING — even two plus two — does not fail, it
1667
+ // hangs until something else happens to wake it. Six questions in a row timed out
1668
+ // against an extension that was working perfectly, and the check reported that its
1669
+ // storage "fell over". So the question is put to a document at the extension's own
1670
+ // address instead. It has exactly the same access to the extension's storage, it
1671
+ // answers in about twenty-five milliseconds, and the document used is the extension's
1672
+ // own manifest — which runs none of the extension's code, so asking cannot change what
1673
+ // is being measured.
1674
+ /** @type {any} */
1675
+ let stored = null;
1676
+ const asker = await context.newPage();
1677
+ try {
1678
+ await asker.goto(`chrome-extension://${opened.id}/manifest.json`, { timeout: 10000 });
1679
+ stored = await withLimit(
1680
+ asker.evaluate(async () => {
1681
+ const browser = /** @type {any} */ (globalThis).chrome;
1682
+ if (!browser?.storage) return null;
1683
+ const local = browser.storage.local ? await browser.storage.local.get(null).catch(() => ({})) : {};
1684
+ const sync = browser.storage.sync ? await browser.storage.sync.get(null).catch(() => ({})) : {};
1685
+ return { local, sync };
1686
+ }),
1687
+ 10000,
1688
+ null,
1689
+ );
1690
+ } catch {
1691
+ // Reported below as a hole rather than as an empty store, because "it stores nothing"
1692
+ // and "we could not ask" must never be allowed to look alike.
1693
+ } finally {
1694
+ await asker.close().catch(() => {});
1695
+ }
1696
+
1697
+ if (stored === null) {
1698
+ out.push(
1699
+ notCovered({
1700
+ channel: 'effects',
1701
+ path: joinPath(...head, 'what it keeps in storage'),
1702
+ reason: 'crashed',
1703
+ says: 'Nothing here could be told what this extension has in storage — either it does not ask for the storage permission at all, or the browser would not answer. What it keeps is not in this check, and this is NOT a report that it keeps nothing.',
1704
+ }),
1705
+ );
1706
+ } else {
1707
+ for (const where of /** @type {const} */ (['local', 'sync'])) {
1708
+ const kept = /** @type {Record<string, any>} */ (stored[where] ?? {});
1709
+ const keys = Object.keys(kept).sort();
1710
+ for (const key of keys) {
1711
+ out.push(
1712
+ observation({
1713
+ channel: 'effects',
1714
+ path: joinPath(...head, `what it keeps in ${where === 'local' ? 'storage on this machine' : 'storage that follows you between machines'}`, key),
1715
+ value: typesIn(kept[key]),
1716
+ says: `It keeps something called "${key}", and this is the shape of it. The shape is compared and the value is not: a worker that writes the time it was installed writes a different number every run, and comparing that would report a difference every single time.`,
1717
+ journey: journey.name,
1718
+ surface: 'extension',
1719
+ }),
1720
+ );
1721
+ }
1722
+ out.push(
1723
+ observation({
1724
+ channel: 'counters',
1725
+ path: joinPath('count', journey.name, `things it keeps in ${where} storage`),
1726
+ value: countBucket(keys.length),
1727
+ says: `It keeps ${keys.length} thing${keys.length === 1 ? '' : 's'} in ${where === 'local' ? 'storage on this machine' : 'storage that follows you between machines'}.`,
1728
+ journey: journey.name,
1729
+ surface: 'extension',
1730
+ }),
1731
+ );
1732
+ }
1733
+ }
1734
+ }
1735
+
1736
+ // WHAT IT ASKS THE NETWORK FOR IS NOT COVERED, and this is the measurement that decided
1737
+ // it, on 2026-08-31. A background worker does its calling-home in the instant the browser
1738
+ // starts, which is BEFORE anything here exists to listen with: the extension is loaded as
1739
+ // part of starting the browser, and the earliest moment this lane can attach is after the
1740
+ // browser has finished starting. Watching from that moment caught the extension's one
1741
+ // outgoing call on one run and missed it on the next, on nothing but timing — and a
1742
+ // channel that reports a call on Monday and no call on Tuesday, about a product that did
1743
+ // not change, is worse than no channel at all. It would have been a false alarm every
1744
+ // other run, and a false alarm is what gets this tool switched off.
1745
+ //
1746
+ // What a CONTENT SCRIPT asks for IS covered, in the journey that opens a page: there the
1747
+ // listening is in place before the page is opened, so nought really means nought.
1748
+ out.push(
1749
+ notCovered({
1750
+ channel: 'effects',
1751
+ path: joinPath('net', journey.name, 'what it asks the network for'),
1752
+ reason: 'not supported here',
1753
+ says:
1754
+ 'What this extension asks the network for in the background is not checked. It does that in the instant the browser starts it, before anything here can be listening, and a count that catches it on one run and misses it on the next would report a difference about timing rather than about the product. Nothing left this machine either way — every request was refused at the wire. This is a hole, and it is NOT a report that the extension calls nothing.',
1755
+ }),
1756
+ );
1757
+
1758
+ // Said every run, on purpose. A hole nobody is told about is a hole that reads as a pass.
1759
+ out.push(
1760
+ notCovered({
1761
+ channel: 'complaints',
1762
+ path: joinPath('log', journey.name, 'what the background worker logged'),
1763
+ reason: 'not supported here',
1764
+ says:
1765
+ 'What the background worker writes to its own log is not read. The browser driver will not open a debugging session onto a service worker, and by the time anything here can hold on to the extension the worker has already started and already said most of what it says. Reading only what came after that would look like the whole log, which is worse than reading none of it. Errors on the extension\'s own PAGES are read normally.',
1766
+ }),
1767
+ );
1768
+ } finally {
1769
+ await opened.window.close();
1770
+ }
1771
+ return out;
1772
+ }
1773
+
1774
+ // ---------------------------------------------------------------------------
1775
+ // Reading one screen
1776
+ // ---------------------------------------------------------------------------
1777
+
1778
+ /**
1779
+ * Hold still, then write down everything the screen means, plus one picture as evidence.
1780
+ *
1781
+ * The same shape as the web lane's checkpoint, and for the same reasons: settle before
1782
+ * reading, because a control that was simply not painted yet reads as a control that
1783
+ * disappeared; and the picture is evidence for a finding another channel already made,
1784
+ * never the accusation itself.
1785
+ *
1786
+ * @param {object} input
1787
+ * @param {any} input.window
1788
+ * @param {Journey} input.journey
1789
+ * @param {string} input.checkpoint
1790
+ * @param {import('./contract.js').RunContext} input.ctx
1791
+ * @param {Record<string, any>} input.config
1792
+ * @param {{dirs: string[], ports: number[], projectRoot?: string}} input.footprint
1793
+ * @param {string} input.id
1794
+ * @param {string} input.buildId
1795
+ * @param {string} input.at
1796
+ * @returns {Promise<Observation[]>}
1797
+ */
1798
+ async function readTheScreen(input) {
1799
+ const { journey, checkpoint, footprint, id } = input;
1800
+ const handle = input.window.handle;
1801
+ const page = input.window.page;
1802
+ /** @type {Observation[]} */
1803
+ const out = [];
1804
+ const head = ['screen', journey.name, checkpoint];
1805
+
1806
+ await prepareForShutter(handle, { fonts: true, timeoutMs: Number(input.config.settleTimeoutMs ?? 10000) });
1807
+
1808
+ /** @type {Buffer|null} */
1809
+ let png = null;
1810
+ try {
1811
+ const stilled = await settle(handle, {
1812
+ frames: 2,
1813
+ intervalMs: 120,
1814
+ timeoutMs: Number(input.config.settleTimeoutMs ?? 8000),
1815
+ capture: () => handle.shoot(),
1816
+ });
1817
+ png = stilled.png;
1818
+ } catch {
1819
+ // A page that will not hold still is still worth reading. The picture is evidence; the
1820
+ // meaning is the check.
1821
+ }
1822
+
1823
+ // The address WITHOUT the id in it. `chrome-extension://gikmlpj.../popup.html` would be a
1824
+ // different value for every build in every run; `popup.html` is the fact worth keeping.
1825
+ out.push(
1826
+ observation({
1827
+ channel: 'meaning',
1828
+ path: joinPath(...head, 'which of its pages this is'),
1829
+ value: withoutTheId(whereItIs(String(page.url()), `chrome-extension://${id}`), id),
1830
+ says: `This is the extension's own page at ${input.at}. The browser's id for the extension is deliberately left out — a browser makes that id out of the folder the extension was loaded from, so it is different for every build and comparing it would report a difference on every run.`,
1831
+ journey: journey.name,
1832
+ surface: 'extension',
1833
+ }),
1834
+ );
1835
+
1836
+ const title = await page.title().catch(() => '');
1837
+ out.push(
1838
+ observation({
1839
+ channel: 'meaning',
1840
+ path: joinPath(...head, 'what the page is called'),
1841
+ value: withoutTheId(String(title), id),
1842
+ says: `The page is called "${title}".`,
1843
+ journey: journey.name,
1844
+ surface: 'extension',
1845
+ }),
1846
+ );
1847
+
1848
+ /** @type {MeaningEntry[]} */
1849
+ let entries = [];
1850
+ try {
1851
+ entries = flattenAria(parseAria(await page.locator('body').ariaSnapshot()));
1852
+ } catch (error) {
1853
+ out.push(
1854
+ notCovered({
1855
+ channel: 'meaning',
1856
+ path: joinPath(...head, 'what the screen says'),
1857
+ reason: 'crashed',
1858
+ says: `The screen could not be read: ${error instanceof Error ? error.message : String(error)}. Nothing about this page is being claimed either way.`,
1859
+ }),
1860
+ );
1861
+ }
1862
+
1863
+ for (const entry of entries) {
1864
+ const where = joinPath(...head, 'tree', ...entry.at);
1865
+ out.push(
1866
+ observation({
1867
+ channel: 'meaning',
1868
+ path: where,
1869
+ value: typeof entry.value === 'string' ? undoOurFootprint(withoutTheId(entry.value, id), footprint) : entry.value,
1870
+ says: entry.describe,
1871
+ journey: journey.name,
1872
+ surface: 'extension',
1873
+ }),
1874
+ );
1875
+ for (const [state, value] of Object.entries(entry.states)) {
1876
+ out.push(
1877
+ observation({
1878
+ channel: 'meaning',
1879
+ path: `${where}.${state}`,
1880
+ value,
1881
+ says: `${entry.name ? `"${short(entry.name)}"` : `The ${entry.role}`} is ${state}${value === true ? '' : ` ${value}`}.`,
1882
+ journey: journey.name,
1883
+ surface: 'extension',
1884
+ }),
1885
+ );
1886
+ }
1887
+ }
1888
+
1889
+ for (const [role, howMany] of countRoles(entries)) {
1890
+ out.push(
1891
+ observation({
1892
+ channel: 'counters',
1893
+ path: joinPath('count', journey.name, checkpoint, role),
1894
+ value: countBucket(howMany),
1895
+ says: `There ${howMany === 1 ? 'was 1' : `were ${howMany}`} ${role}${howMany === 1 ? '' : 's'} on this page. Small counts are exact, because three going to two IS the finding.`,
1896
+ journey: journey.name,
1897
+ surface: 'extension',
1898
+ }),
1899
+ );
1900
+ }
1901
+ out.push(
1902
+ observation({
1903
+ channel: 'counters',
1904
+ path: joinPath('count', journey.name, checkpoint, 'everything on the page'),
1905
+ value: countBucket(entries.length),
1906
+ says: `${entries.length} things on this page had a role and a name a person could act on.`,
1907
+ journey: journey.name,
1908
+ surface: 'extension',
1909
+ }),
1910
+ );
1911
+
1912
+ if (png) {
1913
+ const file = path.join(input.ctx.evidenceDir, `${fileSafe(`${input.buildId}-${journey.name}-${checkpoint}`)}.png`);
1914
+ await fsp.writeFile(file, png).catch(() => {});
1915
+ const ink = inkOf(png);
1916
+ out.push(
1917
+ observation({
1918
+ channel: 'pixels',
1919
+ path: joinPath('picture', journey.name, checkpoint),
1920
+ value: { wide: ink.wide, tall: ink.tall, 'how full the screen is': ink.ink },
1921
+ says: `The page was ${ink.wide} by ${ink.tall} and ${ink.ink}. The picture itself is kept as evidence and is never compared — only whether anything was drawn at all, which is the one thing no other channel can see. It is ${sizeBucket(png.length)}.`,
1922
+ evidence: file,
1923
+ journey: journey.name,
1924
+ surface: 'extension',
1925
+ }),
1926
+ );
1927
+ }
1928
+
1929
+ return out;
1930
+ }
1931
+
1932
+ /**
1933
+ * What the page complained about, with the extension's id taken out.
1934
+ *
1935
+ * @param {Journey} journey
1936
+ * @param {string[]} messages
1937
+ * @param {string|null} id
1938
+ * @returns {Observation[]}
1939
+ */
1940
+ function complaintsFrom(journey, messages, id) {
1941
+ /** @type {Map<string, {text: string, times: number}>} */
1942
+ const grouped = new Map();
1943
+ for (const message of messages) {
1944
+ const clean = withoutTheId(message, id);
1945
+ const key = short(
1946
+ clean
1947
+ .replace(/https?:\/\/[^\s)'"]+/g, 'an address')
1948
+ .replace(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, 'an id')
1949
+ .replace(/\b\d+\b/g, 'a number')
1950
+ .replace(/\s+/g, ' ')
1951
+ .trim(),
1952
+ 70,
1953
+ ) || 'something it would not say';
1954
+ const found = grouped.get(key);
1955
+ if (found) found.times += 1;
1956
+ else grouped.set(key, { text: clean, times: 1 });
1957
+ }
1958
+ return [...grouped.entries()]
1959
+ .sort((a, b) => a[0].localeCompare(b[0]))
1960
+ .map(([key, held]) =>
1961
+ observation({
1962
+ channel: 'complaints',
1963
+ path: joinPath('log', journey.name, key),
1964
+ value: held.text,
1965
+ says: `This page of the extension complained: ${short(held.text, 160)}`,
1966
+ journey: journey.name,
1967
+ surface: 'extension',
1968
+ }),
1969
+ );
1970
+ }
1971
+
1972
+ /**
1973
+ * A name a picture can be saved under. Cut with a fingerprint on the end, never cut alone:
1974
+ * two names that agree for eighty characters would be saved over each other, and the picture
1975
+ * offered as evidence for one finding would be a photograph of a different screen.
1976
+ *
1977
+ * @param {string} name
1978
+ * @returns {string}
1979
+ */
1980
+ function fileSafe(name) {
1981
+ const clean = String(name).replace(/[^A-Za-z0-9._-]+/g, '-');
1982
+ if (clean === '') return 'checkpoint';
1983
+ if (clean.length <= 80) return clean;
1984
+ const mark = crypto.createHash('sha256').update(clean).digest('hex').slice(0, 8);
1985
+ return `${clean.slice(0, 71)}-${mark}`;
1986
+ }
1987
+
1988
+ export default extensionAdapter;