@broberg/lens-daemon 0.0.0-stage → 0.1.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.
package/src/schema.ts ADDED
@@ -0,0 +1,300 @@
1
+ // lens F001.5 — the Lens tables and their row types, owned by the Lens package.
2
+ //
3
+ // Moved verbatim from cardmem apps/agent/src/state.ts (ed10516f): the lens_* DDL,
4
+ // the additive lens migrations, the F276.1 upload queue and the F094.3 baseline
5
+ // backfill. cardmem's getDb() calls ensureLensSchema(db) once the daemon runs on
6
+ // this package; until that cutover cardmem keeps its own copy, and the cutover
7
+ // deletes it (no second copy survives F001.5).
8
+
9
+ import type { Database } from 'bun:sqlite';
10
+ import { randomUUID } from 'node:crypto';
11
+ import { ensureUploadQueue } from './artifact-uploads.ts';
12
+
13
+ /** Create/upgrade every Lens table. Idempotent; call inside the daemon's schema transaction. */
14
+ export function ensureLensSchema(db: Database): void {
15
+ db.exec(`
16
+ -- F074.3 — Lens runs + baselines. Metadata lives here (daemon-local
17
+ -- SQLite); screenshot/diff BYTES live in the storage adapter (local dir
18
+ -- or Tigris). Hard-scoped to project_slug so a project only ever sees
19
+ -- its own runs. The SPA dashboard (F074.8) reads these via the daemon's
20
+ -- HTTP, the same pattern as fleet-status / audit.
21
+ CREATE TABLE IF NOT EXISTS lens_runs (
22
+ id TEXT PRIMARY KEY,
23
+ project_slug TEXT NOT NULL,
24
+ surface_key TEXT,
25
+ url TEXT NOT NULL,
26
+ mode TEXT NOT NULL,
27
+ selector TEXT,
28
+ status TEXT NOT NULL,
29
+ reason TEXT,
30
+ diff_ratio REAL,
31
+ artifact_key TEXT,
32
+ diff_artifact_key TEXT,
33
+ dom_hash TEXT,
34
+ assert_pass INTEGER,
35
+ assert_detail_json TEXT,
36
+ console_json TEXT,
37
+ network_json TEXT,
38
+ page_errors_json TEXT,
39
+ width INTEGER,
40
+ height INTEGER,
41
+ duration_ms INTEGER,
42
+ critic_json TEXT,
43
+ created_at INTEGER NOT NULL
44
+ );
45
+ CREATE INDEX IF NOT EXISTS idx_lens_runs_project ON lens_runs(project_slug, created_at DESC);
46
+
47
+ CREATE TABLE IF NOT EXISTS lens_baselines (
48
+ id TEXT PRIMARY KEY,
49
+ project_slug TEXT NOT NULL,
50
+ baseline_key TEXT NOT NULL,
51
+ artifact_key TEXT NOT NULL,
52
+ width INTEGER,
53
+ height INTEGER,
54
+ approved_from_run_id TEXT,
55
+ created_at INTEGER NOT NULL,
56
+ updated_at INTEGER NOT NULL,
57
+ UNIQUE(project_slug, baseline_key)
58
+ );
59
+
60
+ -- F094.3 — immutable baseline version history. Every approve APPENDS a row
61
+ -- here (never overwrites); lens_baselines.current_version_id points at the
62
+ -- active one, so a revert is a re-point with no data loss. The artifact_key
63
+ -- is content-addressed per version (baselines/<key>/v<n>.png).
64
+ CREATE TABLE IF NOT EXISTS lens_baseline_versions (
65
+ id TEXT PRIMARY KEY,
66
+ project_slug TEXT NOT NULL,
67
+ baseline_key TEXT NOT NULL,
68
+ version_no INTEGER NOT NULL,
69
+ artifact_key TEXT NOT NULL,
70
+ width INTEGER,
71
+ height INTEGER,
72
+ approved_from_run_id TEXT,
73
+ commit_sha TEXT,
74
+ note TEXT,
75
+ approved_at INTEGER NOT NULL,
76
+ UNIQUE(project_slug, baseline_key, version_no)
77
+ );
78
+ CREATE INDEX IF NOT EXISTS idx_lens_baseline_versions
79
+ ON lens_baseline_versions(project_slug, baseline_key, version_no DESC);
80
+
81
+ -- F082 — E2E flow runs (manuscript executions). One row per flow run; the
82
+ -- per-step report (status + screenshot artifact key per step) lives in
83
+ -- steps_json. Same project-scoping + artifact-storage pattern as lens_runs.
84
+ CREATE TABLE IF NOT EXISTS lens_flow_runs (
85
+ id TEXT PRIMARY KEY,
86
+ project_slug TEXT NOT NULL,
87
+ flow_name TEXT NOT NULL,
88
+ status TEXT NOT NULL,
89
+ failed_at INTEGER,
90
+ reason TEXT,
91
+ steps_json TEXT NOT NULL,
92
+ step_count INTEGER NOT NULL,
93
+ duration_ms INTEGER,
94
+ created_at INTEGER NOT NULL
95
+ );
96
+ CREATE INDEX IF NOT EXISTS idx_lens_flow_runs_project ON lens_flow_runs(project_slug, created_at DESC);
97
+ `);
98
+ // F094.3 — additive: pointer from a baseline to its active version row.
99
+ try {
100
+ db.exec('ALTER TABLE lens_baselines ADD COLUMN current_version_id TEXT');
101
+ } catch {
102
+ // column already exists
103
+ }
104
+ // F126.4 — additive: composition-critic findings (JSON) per run, so
105
+ // lens_list_runs / the report can show them historically.
106
+ try {
107
+ db.exec('ALTER TABLE lens_runs ADD COLUMN critic_json TEXT');
108
+ } catch {
109
+ // column already exists
110
+ }
111
+ // F255.5 — additive: artifact key of a flow run's .webm recording (null unless
112
+ // the run asked for record:true).
113
+ try {
114
+ db.exec('ALTER TABLE lens_flow_runs ADD COLUMN video_key TEXT');
115
+ } catch {
116
+ // column already exists
117
+ }
118
+ // F074.30 — additive: the build sha the run MEASURED, stamped at capture time.
119
+ // NULL means "not measured" (the target exposes no marker) and must never be
120
+ // read as "same build as the other run" — a comparison spanning a null has to
121
+ // refuse rather than reassure. Without this, a run-vs-run diff can compare a
122
+ // build with itself and answer "no regression".
123
+ for (const table of ['lens_runs', 'lens_flow_runs']) {
124
+ try {
125
+ db.exec(`ALTER TABLE ${table} ADD COLUMN build TEXT`);
126
+ } catch {
127
+ // column already exists
128
+ }
129
+ }
130
+ // F074.36 — additive: WHERE the run ended, and WHICH credential it used (or why
131
+ // it had none). Without these, a past verdict cannot be re-checked for the one
132
+ // question that invalidates it: "was that run even logged in, and was it
133
+ // looking at the page I think it was?"
134
+ // F074.99 — additive: the run-level FINDINGS a reader can act on (a password
135
+ // that came back in the URL, a page that was never listening). Both were
136
+ // produced in flow.ts and written nowhere, which is this file's own recurring
137
+ // lesson: a field produced in one place and not persisted in the other reaches
138
+ // nobody. fd-sundhed measured exactly that from a consumer's seat.
139
+ for (const col of ['final_url TEXT', 'auth_json TEXT', 'reduced_motion TEXT', 'findings_json TEXT']) {
140
+ try {
141
+ db.exec(`ALTER TABLE lens_flow_runs ADD COLUMN ${col}`);
142
+ } catch {
143
+ // column already exists
144
+ }
145
+ }
146
+ // F074.59 — reduced_motion is deliberately NULLABLE with no default. A run
147
+ // recorded before the column existed genuinely does not know which motion state
148
+ // it used, and back-filling it with 'reduce' would turn "not measured" into a
149
+ // measurement — the exact collapse this card exists to prevent one layer up.
150
+
151
+ // F272.2 — additive: WHICH STORAGE BACKEND held this run's screenshots, stamped
152
+ // at capture time and never re-derived from current config.
153
+ //
154
+ // The re-derived version is the trap. Read the daemon's CURRENT backend and
155
+ // apply it to every row, and the day LENS_TIGRIS_BUCKET is finally set, every
156
+ // historic run whose PNGs are still on this Mac starts reporting `tigris`. The
157
+ // bytes have not moved; the config changed; the citation goes green. That is
158
+ // the false-green direction, so the value is written once, at the moment the
159
+ // bytes are actually put somewhere.
160
+ //
161
+ // NULL is the same third fact as reduced_motion above: a run older than this
162
+ // column does not know where its bytes went, and `unknown` must never be
163
+ // rounded up to `durable`. An evidence citation over a null is exactly as
164
+ // unproven as one over 'local' — it just fails for a different reason.
165
+ try {
166
+ db.exec('ALTER TABLE lens_flow_runs ADD COLUMN artifact_backend TEXT');
167
+ } catch {
168
+ // column already exists
169
+ }
170
+
171
+ // F276.1 — the upload queue that makes every artifact reachable from a phone.
172
+ //
173
+ // A QUEUE RATHER THAN AN INLINE UPLOAD, for three reasons that all bite:
174
+ // · a capture must not get slower, or nobody will run one;
175
+ // · a failed upload must not fail the RUN — the local copy is still valid
176
+ // evidence, and losing a run because R2 hiccuped is a worse outcome than
177
+ // a late upload;
178
+ // · the backfill of 6588 historic files (F276.4, Christian: "ALT") is the
179
+ // same operation at a different scale, so it gets the same machinery
180
+ // instead of a second one that drifts.
181
+ //
182
+ // `artifact_key` is the primary key, so enqueueing the same key twice is a
183
+ // no-op and the backfill can be re-run without duplicating uploads. That is
184
+ // an ACCEPTANCE CRITERION on F276.4, expressed in the schema rather than in a
185
+ // guard someone can forget to call.
186
+ ensureUploadQueue(db);
187
+
188
+ // F094.3 — backfill: every pre-existing baseline becomes version 1, pointing at
189
+ // its current (stable-key) artifact, so the visual history starts from what's
190
+ // live. Runs once (gated on current_version_id IS NULL).
191
+ const legacy = db
192
+ .query('SELECT * FROM lens_baselines WHERE current_version_id IS NULL')
193
+ .all() as LensBaselineRow[];
194
+ for (const b of legacy) {
195
+ const vid = randomUUID();
196
+ db.prepare(
197
+ `INSERT INTO lens_baseline_versions
198
+ (id, project_slug, baseline_key, version_no, artifact_key, width, height,
199
+ approved_from_run_id, commit_sha, note, approved_at)
200
+ VALUES (?,?,?,1,?,?,?,?,NULL,'backfilled v1',?)`,
201
+ ).run(vid, b.project_slug, b.baseline_key, b.artifact_key, b.width, b.height, b.approved_from_run_id, b.updated_at);
202
+ db.prepare('UPDATE lens_baselines SET current_version_id = ? WHERE id = ?').run(vid, b.id);
203
+ }
204
+ }
205
+
206
+ export interface LensRunRow {
207
+ id: string;
208
+ project_slug: string;
209
+ surface_key: string | null;
210
+ url: string;
211
+ mode: string;
212
+ selector: string | null;
213
+ status: string;
214
+ reason: string | null;
215
+ diff_ratio: number | null;
216
+ artifact_key: string | null;
217
+ diff_artifact_key: string | null;
218
+ dom_hash: string | null;
219
+ assert_pass: number | null;
220
+ assert_detail_json: string | null;
221
+ console_json: string | null;
222
+ network_json: string | null;
223
+ page_errors_json: string | null;
224
+ width: number | null;
225
+ height: number | null;
226
+ duration_ms: number | null;
227
+ critic_json: string | null;
228
+ created_at: number;
229
+ /** F074.30 — the build sha this run measured, stamped at capture time. NULL =
230
+ * not measured (no marker on the target); never read it as "same build". */
231
+ build?: string | null;
232
+ }
233
+
234
+ export interface LensBaselineRow {
235
+ id: string;
236
+ project_slug: string;
237
+ baseline_key: string;
238
+ artifact_key: string;
239
+ width: number | null;
240
+ height: number | null;
241
+ approved_from_run_id: string | null;
242
+ created_at: number;
243
+ updated_at: number;
244
+ current_version_id: string | null; // F094.3 — active version pointer
245
+ }
246
+
247
+ // F094.3 — one row per approved baseline version (append-only history).
248
+ export interface LensBaselineVersionRow {
249
+ id: string;
250
+ project_slug: string;
251
+ baseline_key: string;
252
+ version_no: number;
253
+ artifact_key: string;
254
+ width: number | null;
255
+ height: number | null;
256
+ approved_from_run_id: string | null;
257
+ commit_sha: string | null;
258
+ note: string | null;
259
+ approved_at: number;
260
+ }
261
+
262
+ export interface LensFlowRunRow {
263
+ id: string;
264
+ project_slug: string;
265
+ flow_name: string;
266
+ status: string;
267
+ failed_at: number | null;
268
+ reason: string | null;
269
+ steps_json: string;
270
+ step_count: number;
271
+ duration_ms: number | null;
272
+ created_at: number;
273
+ /** F255.5 — artifact key of the run's .webm recording (null unless record:true). */
274
+ video_key?: string | null;
275
+ /** F074.30 — the build sha this run measured, stamped at capture time. NULL =
276
+ * not measured (no marker on the target); never read it as "same build". */
277
+ build?: string | null;
278
+ /** F074.36 — the URL the run ended on. Answers "which page was that verdict
279
+ * about?" for a run nobody is watching any more. */
280
+ final_url?: string | null;
281
+ /** F074.36 — JSON {source, adapter, note}: the credential the run used, or the
282
+ * reason it had none. So a past GREEN run can be asked whether it was even
283
+ * logged in. */
284
+ auth_json?: string | null;
285
+ /** F074.59 — 'reduce' | 'no-preference': the motion state the run executed under.
286
+ * NULL means NOT RECORDED (a run older than the column), which is a third fact,
287
+ * not a synonym for 'reduce'. A reader comparing two runs must refuse on a null
288
+ * rather than assume they saw the same browser. */
289
+ reduced_motion?: string | null;
290
+ /** F272.2 — 'local' | 'tigris': where this run's step screenshots were PUT, stamped
291
+ * at capture time. NULL means NOT RECORDED (a run older than the column) and is a
292
+ * third fact: `unknown`, never rounded up to durable. Read from the row, never
293
+ * re-derived from the daemon's current config — a config flip must not retroactively
294
+ * bless bytes that never moved. */
295
+ artifact_backend?: string | null;
296
+ /** F074.99 — JSON {password_in_url?, not_listening?}: the run-level findings.
297
+ * `'{}'` means the run LOOKED and found nothing; NULL means NOT RECORDED (a run
298
+ * older than the column). A reader must not read the second as the first. */
299
+ findings_json?: string | null;
300
+ }
package/src/scrape.ts ADDED
@@ -0,0 +1,116 @@
1
+ // F125.2 — Lens scrape adapter.
2
+ //
3
+ // Extract an arbitrary article URL → { title, markdown }. This is the Research
4
+ // Adapter worker's "get the body" step. It runs INSIDE Lens (the daemon owns
5
+ // the warm Playwright browser) — never a raw one-off Playwright script (F112).
6
+ //
7
+ // Strategy = exactly the trail web-clipper recipe, server-side: Playwright
8
+ // renders the page (handles JS + auth), Readability runs IN the page on a DOM
9
+ // clone (injected as a global, no jsdom needed under Bun), and Turndown turns
10
+ // the article HTML into markdown Node-side. The Medium adapter authes via a
11
+ // stored storageState (Christian's Medium session) reusing the F074 auth layer.
12
+
13
+ import { readFileSync } from 'node:fs';
14
+ import { createRequire } from 'node:module';
15
+ import TurndownService from 'turndown';
16
+ import { withPage } from './browser.ts';
17
+ import { authPrepare } from './auth/index.ts';
18
+
19
+ const require = createRequire(import.meta.url);
20
+ // Readability.js is a standalone class file (top-level `function Readability`),
21
+ // so injecting it as a <script> defines window.Readability in the page. Read it
22
+ // once at module load — node_modules is present (the daemon runs from source).
23
+ const READABILITY_SRC = readFileSync(require.resolve('@mozilla/readability/Readability.js'), 'utf8');
24
+
25
+ export interface ScrapeResult {
26
+ url: string;
27
+ title: string;
28
+ markdown: string;
29
+ byline: string | null;
30
+ length: number;
31
+ excerpt: string | null;
32
+ }
33
+
34
+ export interface ScrapeOptions {
35
+ /** F074.12 storageState path on the daemon host — the Medium session for
36
+ * paywalled posts. The path is not a secret; the gitignored file it points
37
+ * at is. Generic (non-authed) scrapes omit it. */
38
+ storageStatePath?: string;
39
+ /** Override the User-Agent — must match the cf_clearance cookie's UA on
40
+ * Cloudflare-fronted sites (Medium). */
41
+ userAgent?: string;
42
+ timeoutMs?: number;
43
+ }
44
+
45
+ export async function scrapeArticle(url: string, opts: ScrapeOptions = {}): Promise<ScrapeResult> {
46
+ let parsed: URL;
47
+ try {
48
+ parsed = new URL(url);
49
+ } catch {
50
+ throw new Error(`invalid url: ${url}`);
51
+ }
52
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
53
+ throw new Error(`unsupported protocol: ${parsed.protocol} (only http/https)`);
54
+ }
55
+
56
+ const timeout = opts.timeoutMs ?? 30_000;
57
+ const article = await withPage(
58
+ {
59
+ // Tall viewport so lazy-loaded article bodies render before extraction.
60
+ viewport: { width: 1280, height: 2200 },
61
+ // CSP-strict sites (Medium) block addScriptTag; bypass so Readability injects.
62
+ bypassCSP: true,
63
+ // Look like a real browser past anti-bot walls (Cloudflare on Medium).
64
+ stealth: true,
65
+ ...(opts.userAgent ? { userAgent: opts.userAgent } : {}),
66
+ prepareContext: opts.storageStatePath
67
+ ? authPrepare({ adapter: 'storageState', statePath: opts.storageStatePath })
68
+ : undefined,
69
+ },
70
+ async (page) => {
71
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout });
72
+ // Let late content settle, but don't fail if the page never goes idle
73
+ // (Medium/analytics keep long-lived connections open).
74
+ await page.waitForLoadState('networkidle', { timeout: 5_000 }).catch(() => {});
75
+ await page.addScriptTag({ content: READABILITY_SRC });
76
+ return page.evaluate(() => {
77
+ // Readability was injected as a page global by addScriptTag.
78
+ const R = (globalThis as unknown as { Readability?: new (doc: Document, opts?: unknown) => { parse(): { title: string; content: string; byline?: string; length?: number; excerpt?: string } | null } }).Readability;
79
+ if (!R) return null;
80
+ const clone = document.cloneNode(true) as Document;
81
+ const result = new R(clone, { keepClasses: false, charThreshold: 0 }).parse();
82
+ if (!result) return null;
83
+ return {
84
+ title: result.title,
85
+ content: result.content,
86
+ byline: result.byline ?? null,
87
+ length: result.length ?? 0,
88
+ excerpt: result.excerpt ?? null,
89
+ };
90
+ });
91
+ },
92
+ );
93
+
94
+ if (!article || !article.content) {
95
+ throw new Error(
96
+ 'extraction failed — Readability found no article content (page may require auth, be JS-gated, or not be an article)',
97
+ );
98
+ }
99
+
100
+ const turndown = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced', bulletListMarker: '-' });
101
+ turndown.addRule('strip-noise', {
102
+ filter: ['script', 'style', 'noscript', 'iframe', 'nav', 'footer', 'header'],
103
+ replacement: () => '',
104
+ });
105
+ const markdown = turndown.turndown(article.content).trim();
106
+ if (!markdown) throw new Error('extraction produced empty markdown');
107
+
108
+ return {
109
+ url,
110
+ title: article.title || parsed.hostname,
111
+ markdown,
112
+ byline: article.byline,
113
+ length: article.length,
114
+ excerpt: article.excerpt,
115
+ };
116
+ }
@@ -0,0 +1,149 @@
1
+ // F074.97 — seed localStorage / sessionStorage BEFORE the first navigation.
2
+ //
3
+ // A whole class of bug is otherwise unphotographable by our own instrument:
4
+ // anything whose input is state the browser held BEFORE the page loaded. Lens
5
+ // could drive, fill, click and measure — but every run started from a clean
6
+ // profile, so a restored tab strip, a remembered filter, an unsent draft, a
7
+ // dismissed banner, or F178.4's seven-day prune (whose entire input is a
8
+ // persisted timestamp) could only be verified by a unit fixture, while the
9
+ // report read as if a browser had confirmed it.
10
+ //
11
+ // THE ORDERING IS THE WHOLE FEATURE. Writing the value AFTER navigation is the
12
+ // trap this card exists to avoid: the app has already booted on an empty store,
13
+ // so the run photographs the DEFAULT state while reporting that it seeded the
14
+ // stale one — green, wrong, and indistinguishable from success. addInitScript
15
+ // runs at document-start in every document the context loads, which is before
16
+ // any app script.
17
+ //
18
+ // ORIGIN-GUARDED, AND THE CALLER DOES NOT NAME THE ORIGIN. An init script runs
19
+ // in EVERY document, including a cross-origin iframe and a redirect to
20
+ // checkout.stripe.com. The origin is derived from the run's own target URL, so
21
+ // a seed cannot be aimed at somebody else's site even by mistake.
22
+
23
+ import type { BrowserContext } from 'playwright';
24
+
25
+ export interface StorageSeed {
26
+ /** localStorage keys to write for the run's own origin. */
27
+ local?: Record<string, unknown>;
28
+ /** sessionStorage keys to write for the run's own origin. */
29
+ session?: Record<string, unknown>;
30
+ }
31
+
32
+ /** The shape addInitScript receives — everything already stringified, so the
33
+ * page-side function is trivial and has nothing left to decide. */
34
+ export interface StoragePayload {
35
+ origin: string;
36
+ local: Array<[string, string]>;
37
+ session: Array<[string, string]>;
38
+ }
39
+
40
+ /** localStorage stores STRINGS. A non-string value is JSON-stringified rather
41
+ * than left to `String()`, because the realistic payload (a tab list, a filter,
42
+ * a saved draft) IS json and `String({})` yields "[object Object]" — a value the
43
+ * app would fail to parse, from a seed that reported success. */
44
+ function toEntries(rec: Record<string, unknown> | undefined): Array<[string, string]> {
45
+ if (!rec) return [];
46
+ return Object.entries(rec).map(([k, v]) => [k, typeof v === 'string' ? v : JSON.stringify(v)]);
47
+ }
48
+
49
+ /** Tolerate an MCP harness that stringified the object param (the same coercion
50
+ * coerceViewport exists for). Anything unparseable is refused with a reason —
51
+ * a seed silently dropped would put us back in the false-green this card is
52
+ * about, one layer further in. */
53
+ export function coerceStorageSeed(raw: unknown): StorageSeed | undefined {
54
+ if (raw == null) return undefined;
55
+ let value: unknown = raw;
56
+ if (typeof value === 'string') {
57
+ const trimmed = value.trim();
58
+ if (!trimmed) return undefined;
59
+ try {
60
+ value = JSON.parse(trimmed) as unknown;
61
+ } catch {
62
+ throw new Error(
63
+ 'storage must be an object like {local:{key:"value"}} (or its JSON text) — ' +
64
+ `could not parse: ${trimmed.slice(0, 80)}`,
65
+ );
66
+ }
67
+ }
68
+ if (typeof value !== 'object' || Array.isArray(value)) {
69
+ throw new Error('storage must be an object like {local:{…}, session:{…}}');
70
+ }
71
+ const o = value as Record<string, unknown>;
72
+ const bad = Object.keys(o).filter((k) => k !== 'local' && k !== 'session');
73
+ if (bad.length) {
74
+ throw new Error(
75
+ `storage accepts only "local" and "session" — unknown key(s): ${bad.join(', ')}. ` +
76
+ 'Silently ignoring one would report a seeded run that never seeded anything.',
77
+ );
78
+ }
79
+ const seed: StorageSeed = {};
80
+ if (o.local != null) {
81
+ if (typeof o.local !== 'object' || Array.isArray(o.local)) throw new Error('storage.local must be an object of key → value');
82
+ seed.local = o.local as Record<string, unknown>;
83
+ }
84
+ if (o.session != null) {
85
+ if (typeof o.session !== 'object' || Array.isArray(o.session)) throw new Error('storage.session must be an object of key → value');
86
+ seed.session = o.session as Record<string, unknown>;
87
+ }
88
+ return seed;
89
+ }
90
+
91
+ /** Build the payload for a seed against a run's target URL, or null when there
92
+ * is nothing to write. Null is what keeps the NEGATIVE CONTROL honest: no
93
+ * payload → no init script → a context byte-identical to every run before this
94
+ * option existed, so no baseline in the fleet can move. */
95
+ export function storagePayload(seed: StorageSeed | undefined, targetUrl: string): StoragePayload | null {
96
+ if (!seed) return null;
97
+ const local = toEntries(seed.local);
98
+ const session = toEntries(seed.session);
99
+ if (!local.length && !session.length) return null;
100
+ let origin: string;
101
+ try {
102
+ origin = new URL(targetUrl).origin;
103
+ } catch {
104
+ throw new Error(
105
+ `storage seeding needs an absolute target url to derive its origin from — got "${targetUrl}". ` +
106
+ 'The origin is derived, never passed, so a seed cannot land on a third-party site.',
107
+ );
108
+ }
109
+ if (origin === 'null') {
110
+ throw new Error(`storage seeding cannot target an opaque origin (${targetUrl}) — it has no storage to write.`);
111
+ }
112
+ return { origin, local, session };
113
+ }
114
+
115
+ /** The page-side function, exported so the ordering can be asserted without a
116
+ * browser. It runs at document-start in every document; the origin guard is
117
+ * what stops it writing into a cross-origin iframe or a payment redirect.
118
+ * Storage access THROWS in a sandboxed/partitioned document, so each write is
119
+ * guarded — a seed that cannot land must not take the whole run with it. */
120
+ export function seedStorageInPage(payload: StoragePayload): void {
121
+ try {
122
+ if (location.origin !== payload.origin) return;
123
+ } catch {
124
+ return;
125
+ }
126
+ for (const [k, v] of payload.local) {
127
+ try {
128
+ localStorage.setItem(k, v);
129
+ } catch {
130
+ /* storage unavailable in this document — nothing to seed */
131
+ }
132
+ }
133
+ for (const [k, v] of payload.session) {
134
+ try {
135
+ sessionStorage.setItem(k, v);
136
+ } catch {
137
+ /* ditto */
138
+ }
139
+ }
140
+ }
141
+
142
+ /** Register the seed on a context. Called by withPage AFTER prepareContext, so a
143
+ * per-run key outranks the same key inside an auth storageState blob: init
144
+ * scripts run in registration order, and a per-call seed is the more specific
145
+ * instruction. */
146
+ export async function applyStorageSeed(context: BrowserContext, payload: StoragePayload | null): Promise<void> {
147
+ if (!payload) return;
148
+ await context.addInitScript(seedStorageInPage, payload);
149
+ }