@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/README.md +14 -2
- package/package.json +52 -3
- package/src/artifact-backfill.ts +253 -0
- package/src/artifact-uploads.ts +370 -0
- package/src/auth/index.ts +294 -0
- package/src/browser.ts +440 -0
- package/src/build-marker.ts +197 -0
- package/src/capture-session.ts +873 -0
- package/src/capture.ts +348 -0
- package/src/critic-dom.ts +951 -0
- package/src/critic-vision.ts +205 -0
- package/src/critic.ts +73 -0
- package/src/daemon-routes.ts +333 -0
- package/src/durability.ts +18 -0
- package/src/ensure-chromium.ts +118 -0
- package/src/export.ts +256 -0
- package/src/fill.ts +149 -0
- package/src/flow-store.ts +207 -0
- package/src/flow.ts +2869 -0
- package/src/frames.ts +195 -0
- package/src/gate.ts +123 -0
- package/src/http.ts +175 -0
- package/src/index.ts +24 -0
- package/src/manifest.ts +741 -0
- package/src/mcp.ts +562 -0
- package/src/motion-probe.ts +653 -0
- package/src/password-in-url.ts +106 -0
- package/src/pdf-text.ts +514 -0
- package/src/pixelmatch.d.ts +21 -0
- package/src/preflight.ts +76 -0
- package/src/readiness.ts +282 -0
- package/src/record-run.ts +427 -0
- package/src/record.ts +387 -0
- package/src/sandbox.ts +22 -0
- package/src/schema.ts +300 -0
- package/src/scrape.ts +116 -0
- package/src/seed-storage.ts +149 -0
- package/src/service.ts +1483 -0
- package/src/storage.ts +159 -0
- package/src/store.ts +267 -0
- package/src/swallowed-errors.ts +252 -0
- package/src/validate-vision.ts +97 -0
- package/src/verify.ts +411 -0
- package/src/video.ts +211 -0
package/src/browser.ts
ADDED
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
// F074.1 — Lens browser manager.
|
|
2
|
+
//
|
|
3
|
+
// Owns a single WARM headless Chromium for the daemon. The browser is
|
|
4
|
+
// launched lazily on first capture and reused across captures (launch is
|
|
5
|
+
// ~300ms; reusing it makes a capture-catalogue batch fast). An idle timer
|
|
6
|
+
// closes it after ~5 min of inactivity so a quiet daemon doesn't hold a
|
|
7
|
+
// Chromium process forever.
|
|
8
|
+
//
|
|
9
|
+
// Each capture gets its OWN browser context (isolated cookies/storage) so
|
|
10
|
+
// auth (F074.4) and concurrent captures never bleed into each other. The
|
|
11
|
+
// context + page are always closed in a finally, even on capture error.
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
chromium,
|
|
15
|
+
webkit,
|
|
16
|
+
firefox,
|
|
17
|
+
type Browser,
|
|
18
|
+
type BrowserContext,
|
|
19
|
+
type BrowserType,
|
|
20
|
+
type Page,
|
|
21
|
+
} from 'playwright';
|
|
22
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
23
|
+
import { tmpdir } from 'node:os';
|
|
24
|
+
import { join } from 'node:path';
|
|
25
|
+
import { READINESS_INIT_SCRIPT } from './readiness.ts';
|
|
26
|
+
import { applyStorageSeed, type StoragePayload } from './seed-storage.ts';
|
|
27
|
+
|
|
28
|
+
const IDLE_TIMEOUT_MS = 5 * 60 * 1000;
|
|
29
|
+
|
|
30
|
+
// F098.9 — Lens can drive any of the three engines. Chromium is the default;
|
|
31
|
+
// webkit/firefox let Lens catch Safari-only / cross-engine bugs (backdrop-filter
|
|
32
|
+
// containing-block, -webkit quirks, 100vh/dvh, sticky) that chromium renders
|
|
33
|
+
// fine — the class of bug chromium-only Lens would falsely stamp GREEN.
|
|
34
|
+
export type LensEngine = 'chromium' | 'webkit' | 'firefox';
|
|
35
|
+
const ENGINES: Record<LensEngine, BrowserType> = { chromium, webkit, firefox };
|
|
36
|
+
|
|
37
|
+
// One warm browser PER engine, launched lazily + reused. Keyed by engine so a
|
|
38
|
+
// webkit capture doesn't relaunch chromium and vice-versa.
|
|
39
|
+
const browsers = new Map<LensEngine, Promise<Browser>>();
|
|
40
|
+
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* F346.46 — CHROMIUM GETS A SYNTHETIC MICROPHONE AND CAMERA.
|
|
44
|
+
*
|
|
45
|
+
* Without these, `getUserMedia` in a Lens run HANGS: headless Chromium has no
|
|
46
|
+
* capture device and no human to answer the permission prompt, so the promise
|
|
47
|
+
* never settles and the flow dies on a timeout that says nothing about why.
|
|
48
|
+
* Measured 16 Sep 2026 — an assert calling getUserMedia({audio:true}) timed the
|
|
49
|
+
* whole manuscript out.
|
|
50
|
+
*
|
|
51
|
+
* That made a whole class of feature UNTESTABLE by our own tool: anything that
|
|
52
|
+
* records. Scope's recorder is exactly that, and it shipped a defect (nothing
|
|
53
|
+
* was ever captured, and nothing said so) which an E2E run would have caught on
|
|
54
|
+
* the first try.
|
|
55
|
+
*
|
|
56
|
+
* --use-fake-device-for-media-stream a synthetic mic + camera, so tracks exist
|
|
57
|
+
* --use-fake-ui-for-media-stream auto-grant, so nothing waits on a human
|
|
58
|
+
* --autoplay-policy=no-user-gesture-required a captured stream can play back
|
|
59
|
+
*
|
|
60
|
+
* THE FAKE MIC EMITS A TONE, NOT SILENCE — Chromium's synthetic audio device is
|
|
61
|
+
* a beep. That matters for what a recording test can prove: the CHAIN (bytes
|
|
62
|
+
* produced, buffered, uploaded, stored) is real and fully exercised; the WORDS
|
|
63
|
+
* are not, because a tone transcribes to nothing. A run must therefore assert on
|
|
64
|
+
* chunk counts and bytes, never on transcript text, or it asserts something the
|
|
65
|
+
* fixture cannot deliver.
|
|
66
|
+
*
|
|
67
|
+
* Chromium only: webkit/firefox reject unknown args, and a rejected arg takes
|
|
68
|
+
* the whole launch down.
|
|
69
|
+
*/
|
|
70
|
+
const CHROMIUM_ARGS = [
|
|
71
|
+
// Hardening for constrained, loopback-bound hosts.
|
|
72
|
+
'--disable-dev-shm-usage',
|
|
73
|
+
'--no-sandbox',
|
|
74
|
+
// Media capture, so a recording surface can actually be driven.
|
|
75
|
+
'--use-fake-device-for-media-stream',
|
|
76
|
+
'--use-fake-ui-for-media-stream',
|
|
77
|
+
'--autoplay-policy=no-user-gesture-required',
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
async function launch(engine: LensEngine): Promise<Browser> {
|
|
81
|
+
const args = engine === 'chromium' ? CHROMIUM_ARGS : [];
|
|
82
|
+
return ENGINES[engine].launch({ headless: true, args });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Get the warm browser for an engine, launching it on first use. Re-launches
|
|
86
|
+
* if a previous instance crashed / disconnected. */
|
|
87
|
+
export async function getBrowser(engine: LensEngine = 'chromium'): Promise<Browser> {
|
|
88
|
+
let p = browsers.get(engine);
|
|
89
|
+
if (!p) {
|
|
90
|
+
p = launch(engine).catch((err) => {
|
|
91
|
+
browsers.delete(engine);
|
|
92
|
+
throw err;
|
|
93
|
+
});
|
|
94
|
+
browsers.set(engine, p);
|
|
95
|
+
}
|
|
96
|
+
const browser = await p;
|
|
97
|
+
if (!browser.isConnected()) {
|
|
98
|
+
browsers.delete(engine);
|
|
99
|
+
return getBrowser(engine);
|
|
100
|
+
}
|
|
101
|
+
return browser;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Wedge watchdog (lens-gap, 2026-06-13 incident): a Chromium that is alive but
|
|
105
|
+
// unresponsive (isConnected() true, every CDP call hangs) used to hang EVERY
|
|
106
|
+
// withPage call until a daemon restart — the idle timer never fired because
|
|
107
|
+
// each retry re-armed it on entry. Guard the engine-touching awaits with a
|
|
108
|
+
// deadline; on timeout, discard the wedged browser and retry once fresh.
|
|
109
|
+
const WEDGE_TIMEOUT_MS = 20_000;
|
|
110
|
+
|
|
111
|
+
function withDeadline<T>(p: Promise<T>, ms: number, what: string): Promise<T> {
|
|
112
|
+
return new Promise<T>((resolve, reject) => {
|
|
113
|
+
const t = setTimeout(
|
|
114
|
+
() => reject(new Error(`lens browser wedged: ${what} exceeded ${ms}ms`)),
|
|
115
|
+
ms,
|
|
116
|
+
);
|
|
117
|
+
t.unref?.();
|
|
118
|
+
p.then(
|
|
119
|
+
(v) => {
|
|
120
|
+
clearTimeout(t);
|
|
121
|
+
resolve(v);
|
|
122
|
+
},
|
|
123
|
+
(e) => {
|
|
124
|
+
clearTimeout(t);
|
|
125
|
+
reject(e);
|
|
126
|
+
},
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Drop the cached browser immediately and force-close it in the background
|
|
132
|
+
* (close on a wedged process may itself hang — never await it). */
|
|
133
|
+
function discardBrowser(engine: LensEngine): void {
|
|
134
|
+
const p = browsers.get(engine);
|
|
135
|
+
browsers.delete(engine);
|
|
136
|
+
if (p) void p.then((b) => b.close()).catch(() => {});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function armIdleTimer(): void {
|
|
140
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
141
|
+
idleTimer = setTimeout(() => {
|
|
142
|
+
void closeBrowser();
|
|
143
|
+
}, IDLE_TIMEOUT_MS);
|
|
144
|
+
// Don't keep the daemon process alive just for the idle timer.
|
|
145
|
+
idleTimer.unref?.();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Close the warm browser + cancel the idle timer. Called by the idle
|
|
149
|
+
* timeout and on daemon shutdown. Safe to call when nothing is open. */
|
|
150
|
+
export async function closeBrowser(): Promise<void> {
|
|
151
|
+
if (idleTimer) {
|
|
152
|
+
clearTimeout(idleTimer);
|
|
153
|
+
idleTimer = null;
|
|
154
|
+
}
|
|
155
|
+
const all = [...browsers.values()];
|
|
156
|
+
browsers.clear();
|
|
157
|
+
await Promise.all(
|
|
158
|
+
all.map(async (p) => {
|
|
159
|
+
try {
|
|
160
|
+
const browser = await p;
|
|
161
|
+
if (browser.isConnected()) await browser.close();
|
|
162
|
+
} catch {
|
|
163
|
+
// already gone — nothing to close
|
|
164
|
+
}
|
|
165
|
+
}),
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export interface WithPageOptions {
|
|
170
|
+
/** F098.9 — which engine to drive (default chromium). */
|
|
171
|
+
engine?: LensEngine;
|
|
172
|
+
viewport?: { width: number; height: number };
|
|
173
|
+
deviceScaleFactor?: number;
|
|
174
|
+
/** F125.2 — disable the page's Content-Security-Policy so injected scripts
|
|
175
|
+
* (Readability via addScriptTag) run on CSP-strict sites (Medium etc.).
|
|
176
|
+
* Scrape-only; Lens captures leave it false. */
|
|
177
|
+
bypassCSP?: boolean;
|
|
178
|
+
/** F125.2 — override the browser User-Agent. Must match the UA the captured
|
|
179
|
+
* cf_clearance cookie was minted with, or Cloudflare re-challenges. */
|
|
180
|
+
userAgent?: string;
|
|
181
|
+
/** F074.57 — BCP-47 locale for the browser context, e.g. "da-DK". Decides
|
|
182
|
+
* navigator.language, Accept-Language, and every Intl/toLocaleString result.
|
|
183
|
+
* UNSET means unset: no key reaches Playwright, so existing baselines cannot
|
|
184
|
+
* move for anyone who has not asked for this. */
|
|
185
|
+
locale?: string;
|
|
186
|
+
/** F074.57 — IANA timezone, e.g. "Europe/Copenhagen". Measured 2026-08-23: with
|
|
187
|
+
* this unset the context inherits the HOST machine's zone, so the same page
|
|
188
|
+
* renders different dates on the Mac and on Cloud Lens. Same unset rule. */
|
|
189
|
+
timezone?: string;
|
|
190
|
+
/** F074.59 — what this context reports for `prefers-reduced-motion`.
|
|
191
|
+
*
|
|
192
|
+
* DEFAULT 'reduce', which is exactly what every run did before this option
|
|
193
|
+
* existed (see the comment on reducedMotion in contextOptions for WHY that
|
|
194
|
+
* default is right and must stay). 'no-preference' is the opt-in that lets a
|
|
195
|
+
* run SEE motion — the animation blind spot F074.59 exists to close.
|
|
196
|
+
*
|
|
197
|
+
* Unlike locale/timezone this is NOT spread-if-present: "unset" here has a
|
|
198
|
+
* concrete meaning ('reduce'), not "pass nothing", because passing nothing
|
|
199
|
+
* would hand the fleet a different browser than it has been photographing. */
|
|
200
|
+
reducedMotion?: 'reduce' | 'no-preference';
|
|
201
|
+
/** F125.2 — mask the navigator.webdriver automation flag so anti-bot walls
|
|
202
|
+
* (Cloudflare) treat the page like a real browser. Scrape-only. */
|
|
203
|
+
stealth?: boolean;
|
|
204
|
+
/** F074.4 hook: run against the fresh context BEFORE the page is created
|
|
205
|
+
* (e.g. inject auth cookies / storageState). */
|
|
206
|
+
prepareContext?: (context: BrowserContext) => Promise<void>;
|
|
207
|
+
/** F074.97 — localStorage/sessionStorage to write BEFORE the first navigation,
|
|
208
|
+
* so the app's own first render already sees it. Built by storagePayload()
|
|
209
|
+
* from the run's target url, which is where the origin guard comes from.
|
|
210
|
+
* NULL/absent registers NO init script at all — that is the negative control
|
|
211
|
+
* that keeps every existing baseline in the fleet byte-identical. */
|
|
212
|
+
seedStorage?: StoragePayload | null;
|
|
213
|
+
/** F144.19 — record the whole session to a .webm in this dir. Playwright
|
|
214
|
+
* writes the file when the context closes; read the path via page.video()?.path()
|
|
215
|
+
* AFTER withPage resolves. Used for live-liveness PROOF captures (an LSD that
|
|
216
|
+
* updates in real time can't be shown by a single screenshot). */
|
|
217
|
+
recordVideoDir?: string;
|
|
218
|
+
/** F074.69 — load an UNPACKED browser extension and drive its own pages.
|
|
219
|
+
*
|
|
220
|
+
* Without this, no extension in the fleet has any visual gate: an extension
|
|
221
|
+
* popup lives at chrome-extension://<id>/popup.html, and that id only exists
|
|
222
|
+
* in a browser launched WITH the extension. Serving dist/popup.html over http
|
|
223
|
+
* is not a substitute — the popup calls chrome.storage on mount, throws with
|
|
224
|
+
* no chrome API, and photographs blank or as a stub that LOOKS like the
|
|
225
|
+
* product. That screenshot on a store listing would be fabricated.
|
|
226
|
+
*
|
|
227
|
+
* This is a SECOND launch path, not a flag on the warm browser: extensions
|
|
228
|
+
* need launchPersistentContext with a real user-data dir on disk. */
|
|
229
|
+
extension?: { path: string };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Exported for test: F074.57's central promise to everyone who did NOT ask for
|
|
233
|
+
* a locale is that an unset one passes NOTHING to Playwright. That is a claim
|
|
234
|
+
* about this object, so it is asserted on this object. */
|
|
235
|
+
export function contextOptions(opts: WithPageOptions) {
|
|
236
|
+
return {
|
|
237
|
+
bypassCSP: opts.bypassCSP ?? false,
|
|
238
|
+
...(opts.userAgent ? { userAgent: opts.userAgent } : {}),
|
|
239
|
+
// F074.57 — spread-if-present, never `locale: opts.locale ?? undefined`. An
|
|
240
|
+
// explicit `undefined` key is still a key, and the point of "unset" here is
|
|
241
|
+
// that Playwright receives NOTHING and every existing baseline stays valid.
|
|
242
|
+
...(opts.locale ? { locale: opts.locale } : {}),
|
|
243
|
+
...(opts.timezone ? { timezoneId: opts.timezone } : {}),
|
|
244
|
+
viewport: opts.viewport ?? { width: 1280, height: 800 },
|
|
245
|
+
// Default scale factor 1 keeps screenshots deterministic across machines
|
|
246
|
+
// (a retina 2x shot diffs noisily against a 1x baseline).
|
|
247
|
+
deviceScaleFactor: opts.deviceScaleFactor ?? 1,
|
|
248
|
+
// Lens captures localhost dev-servers, which commonly serve HTTPS with a
|
|
249
|
+
// self-signed / mkcert cert (cms-admin :3010 must be HTTPS). Trust them —
|
|
250
|
+
// this is a local dev tool, not a public crawler.
|
|
251
|
+
ignoreHTTPSErrors: true,
|
|
252
|
+
// F098.8 — emulate prefers-reduced-motion: reduce. Two wins: (1) an app that
|
|
253
|
+
// gates a perpetual requestAnimationFrame loop (trail's constellation canvas)
|
|
254
|
+
// on this media query stops animating, freeing the headless main thread so
|
|
255
|
+
// the initial render + auth microtask (/api/auth/me) resolve instead of
|
|
256
|
+
// starving past the wait timeout → blank capture; (2) motion-stable baselines
|
|
257
|
+
// (a mid-animation frame otherwise yields a false pixel-diff). Apps SHOULD
|
|
258
|
+
// honour prefers-reduced-motion anyway — this is correct a11y, not a hack.
|
|
259
|
+
// F074.59 — per-run, defaulting to the F098.8 behaviour above. A caller that
|
|
260
|
+
// asks for 'no-preference' gets a browser that can actually SEE an animation;
|
|
261
|
+
// everyone else gets the byte-identical context they had yesterday, which is
|
|
262
|
+
// the promise that lets this ship without touching a single baseline.
|
|
263
|
+
reducedMotion: opts.reducedMotion ?? ('reduce' as const),
|
|
264
|
+
// F126.11 — grant clipboard access so a "copy" button can be proven on its
|
|
265
|
+
// SUCCESS path, not only on its failure path.
|
|
266
|
+
//
|
|
267
|
+
// xrt81 (#21085) closed a card with this stated openly rather than ticked:
|
|
268
|
+
// pressing their copy button in the automated browser produced "Couldn't
|
|
269
|
+
// copy — select the address and copy it yourself". The message is good and
|
|
270
|
+
// the fallback works, but the green confirmation had never been seen. Every
|
|
271
|
+
// copy button in the fleet was in that position — verifiable only when it
|
|
272
|
+
// fails.
|
|
273
|
+
//
|
|
274
|
+
// That is the day's theme arriving from the opposite end. Everywhere else we
|
|
275
|
+
// found green without coverage; here the coverage was impossible, so the card
|
|
276
|
+
// had to say so. It should not have to: Chromium simply denies the permission
|
|
277
|
+
// by default and Playwright can grant it per context.
|
|
278
|
+
//
|
|
279
|
+
// Chromium-only — webkit and firefox reject unknown permission names and
|
|
280
|
+
// would fail every capture on those engines. Wrapped so an engine that does
|
|
281
|
+
// not know the name is unaffected rather than broken.
|
|
282
|
+
...(opts.engine === undefined || opts.engine === 'chromium'
|
|
283
|
+
? {
|
|
284
|
+
// F346.46 — MICROPHONE AND CAMERA GRANTED TOO. The launch flags give
|
|
285
|
+
// Chromium a synthetic device; this grants the PAGE permission to open
|
|
286
|
+
// it. Both halves are needed: without the flag there is no device and
|
|
287
|
+
// getUserMedia rejects; without the grant it waits for a human who is
|
|
288
|
+
// not there, and the flow dies on a timeout that names nothing.
|
|
289
|
+
permissions: ['clipboard-read', 'clipboard-write', 'microphone', 'camera'],
|
|
290
|
+
}
|
|
291
|
+
: {}),
|
|
292
|
+
// F144.19 — optional session video recording (live-liveness proof).
|
|
293
|
+
...(opts.recordVideoDir
|
|
294
|
+
? { recordVideo: { dir: opts.recordVideoDir, size: opts.viewport ?? { width: 1280, height: 800 } } }
|
|
295
|
+
: {}),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Run `fn` with a fresh isolated context + page, then tear both down.
|
|
300
|
+
* Resets the idle timer on entry so an active daemon keeps its browser warm. */
|
|
301
|
+
/** F074.69 — the id Chromium generated for the loaded extension, handed to the
|
|
302
|
+
* caller. It is derived at LOAD time, so it is read back from the extension's
|
|
303
|
+
* service-worker URL rather than guessed — a manuscript cannot hardcode it. */
|
|
304
|
+
export interface LoadedExtension {
|
|
305
|
+
id: string;
|
|
306
|
+
path: string;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** F074.69 — MEASURED before the design depended on the answer (probe, 2026-08-29):
|
|
310
|
+
*
|
|
311
|
+
* bundled chromium, headless serviceWorker=false id=null NOT LOADED
|
|
312
|
+
* bundled chromium, --headless=new serviceWorker=false id=null NOT LOADED
|
|
313
|
+
* bundled chromium, headed serviceWorker=true id=cacc… works
|
|
314
|
+
* channel:'chromium', headless serviceWorker=true id=cacc… works
|
|
315
|
+
*
|
|
316
|
+
* So the answer to the plan's open question — "does it need a head?" — is NO,
|
|
317
|
+
* but only off the CHANNEL build. Playwright's bundled headless is the
|
|
318
|
+
* chromium-headless-shell, which has no extension support at all; the full
|
|
319
|
+
* Chromium binary does, in the new headless mode. That distinction is the whole
|
|
320
|
+
* reason this can run on a headless host (and therefore on Cloud Lens) instead
|
|
321
|
+
* of needing a screen. */
|
|
322
|
+
const EXTENSION_CHANNEL = 'chromium';
|
|
323
|
+
|
|
324
|
+
async function launchWithExtension(
|
|
325
|
+
opts: WithPageOptions,
|
|
326
|
+
extPath: string,
|
|
327
|
+
): Promise<{ context: BrowserContext; extension: LoadedExtension; userDataDir: string }> {
|
|
328
|
+
const userDataDir = await mkdtemp(join(tmpdir(), 'lens-ext-'));
|
|
329
|
+
const context = await chromium.launchPersistentContext(userDataDir, {
|
|
330
|
+
...contextOptions(opts),
|
|
331
|
+
headless: true,
|
|
332
|
+
channel: EXTENSION_CHANNEL,
|
|
333
|
+
args: [
|
|
334
|
+
`--disable-extensions-except=${extPath}`,
|
|
335
|
+
`--load-extension=${extPath}`,
|
|
336
|
+
'--disable-dev-shm-usage',
|
|
337
|
+
'--no-sandbox',
|
|
338
|
+
],
|
|
339
|
+
});
|
|
340
|
+
// MV3 registers a service worker; MV2 a background page. Wait for whichever
|
|
341
|
+
// appears — the id lives in its URL host either way.
|
|
342
|
+
let worker = context.serviceWorkers()[0];
|
|
343
|
+
if (!worker) {
|
|
344
|
+
worker = await context
|
|
345
|
+
.waitForEvent('serviceworker', { timeout: 15_000 })
|
|
346
|
+
.catch(() => undefined as never);
|
|
347
|
+
}
|
|
348
|
+
const bg = worker?.url() ?? context.backgroundPages()[0]?.url();
|
|
349
|
+
if (!bg) {
|
|
350
|
+
await context.close().catch(() => {});
|
|
351
|
+
await rm(userDataDir, { recursive: true, force: true }).catch(() => {});
|
|
352
|
+
throw new Error(
|
|
353
|
+
`extension at ${extPath} loaded no service worker or background page within 15s — ` +
|
|
354
|
+
'its id cannot be resolved, so nothing can be captured from it. Check that the path is ' +
|
|
355
|
+
'an UNPACKED build directory containing manifest.json (not a .zip, not the source dir).',
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
return { context, extension: { id: new URL(bg).host, path: extPath }, userDataDir };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export async function withPage<T>(
|
|
362
|
+
opts: WithPageOptions,
|
|
363
|
+
fn: (page: Page, context: BrowserContext, extension?: LoadedExtension) => Promise<T>,
|
|
364
|
+
): Promise<T> {
|
|
365
|
+
armIdleTimer();
|
|
366
|
+
const engine = opts.engine ?? 'chromium';
|
|
367
|
+
|
|
368
|
+
// F074.69 — the extension path is its own launch, its own lifecycle, its own
|
|
369
|
+
// cleanup. It deliberately does NOT reuse the warm browser.
|
|
370
|
+
if (opts.extension) {
|
|
371
|
+
if (engine !== 'chromium') {
|
|
372
|
+
// Refused with a reason rather than failing oddly further in — the same
|
|
373
|
+
// posture lens_export takes for PDF. The planned Safari extension is a
|
|
374
|
+
// different problem and is not covered by pretending this works.
|
|
375
|
+
throw new Error(
|
|
376
|
+
`extensions are chromium-only; engine "${engine}" cannot load one. ` +
|
|
377
|
+
'Drop the engine option or pass engine:"chromium".',
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
const { context, extension, userDataDir } = await launchWithExtension(opts, opts.extension.path);
|
|
381
|
+
try {
|
|
382
|
+
await context.addInitScript(READINESS_INIT_SCRIPT);
|
|
383
|
+
if (opts.prepareContext) await opts.prepareContext(context);
|
|
384
|
+
// F074.97 — AFTER prepareContext on purpose: init scripts run in
|
|
385
|
+
// registration order, so a per-run seed outranks the same key inside an
|
|
386
|
+
// auth storageState blob. A per-call instruction is the more specific one.
|
|
387
|
+
await applyStorageSeed(context, opts.seedStorage ?? null);
|
|
388
|
+
// A persistent context opens with a page already; reuse it rather than
|
|
389
|
+
// leaving a blank one behind.
|
|
390
|
+
const page = context.pages()[0] ?? (await context.newPage());
|
|
391
|
+
if (opts.viewport) await page.setViewportSize(opts.viewport);
|
|
392
|
+
return await fn(page, context, extension);
|
|
393
|
+
} finally {
|
|
394
|
+
await withDeadline(context.close(), 5_000, 'extension context close').catch(() => {});
|
|
395
|
+
// The user-data dir is ours and nobody else's; leaving it leaks a real
|
|
396
|
+
// directory per capture.
|
|
397
|
+
await rm(userDataDir, { recursive: true, force: true }).catch(() => {});
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const makeContext = async (): Promise<BrowserContext> => {
|
|
402
|
+
const browser = await withDeadline(getBrowser(engine), WEDGE_TIMEOUT_MS, 'browser launch');
|
|
403
|
+
return withDeadline(
|
|
404
|
+
browser.newContext(contextOptions(opts)),
|
|
405
|
+
WEDGE_TIMEOUT_MS,
|
|
406
|
+
'newContext',
|
|
407
|
+
);
|
|
408
|
+
};
|
|
409
|
+
let context: BrowserContext;
|
|
410
|
+
try {
|
|
411
|
+
context = await makeContext();
|
|
412
|
+
} catch (err) {
|
|
413
|
+
if (!(err instanceof Error && err.message.includes('lens browser wedged'))) throw err;
|
|
414
|
+
discardBrowser(engine);
|
|
415
|
+
// One retry on a fresh launch; a second wedge propagates to the caller.
|
|
416
|
+
context = await makeContext();
|
|
417
|
+
}
|
|
418
|
+
try {
|
|
419
|
+
if (opts.stealth) {
|
|
420
|
+
// Mask the most-checked automation signal before any page script runs.
|
|
421
|
+
await context.addInitScript(() => {
|
|
422
|
+
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
// F213.8 — observe listener registration before ANY page script runs, so a
|
|
426
|
+
// flow can later ask whether the page is actually listening rather than
|
|
427
|
+
// whether it merely painted. Unconditional on purpose: it must be installed
|
|
428
|
+
// before we know whether this run will ask, and it costs one wrapped
|
|
429
|
+
// function call per addEventListener. It only records; it never blocks.
|
|
430
|
+
await context.addInitScript(READINESS_INIT_SCRIPT);
|
|
431
|
+
if (opts.prepareContext) await opts.prepareContext(context);
|
|
432
|
+
// F074.97 — see the extension branch above: seed AFTER auth, before any page.
|
|
433
|
+
await applyStorageSeed(context, opts.seedStorage ?? null);
|
|
434
|
+
const page = await context.newPage();
|
|
435
|
+
return await fn(page, context);
|
|
436
|
+
} finally {
|
|
437
|
+
// close() on a wedged browser can hang too — bounded, then best-effort.
|
|
438
|
+
await withDeadline(context.close(), 5_000, 'context close').catch(() => {});
|
|
439
|
+
}
|
|
440
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// F255.7 — wait for the deployed build to actually be serving before a flow verifies it.
|
|
2
|
+
//
|
|
3
|
+
// The failure this exists to kill: GitHub Actions reports a deploy as `success` while the
|
|
4
|
+
// machines are still rolling, Lens runs against the PREVIOUS build, and everything passes.
|
|
5
|
+
// That green run is worse than a red one — it verifies real behaviour, just of the code you
|
|
6
|
+
// replaced, so nothing in the output looks suspicious (fd-sundhed, 2026-07-30).
|
|
7
|
+
//
|
|
8
|
+
// The contract: every cardmem-enrolled app reports the git sha of the RUNNING build in
|
|
9
|
+
// `build` at GET /api/health. A convention, not per-flow config — per-flow means each repo
|
|
10
|
+
// has to remember to wire it, and the repo that forgets is exactly the one that gets the
|
|
11
|
+
// false green. A contract you can skip only protects the people who did not need it.
|
|
12
|
+
//
|
|
13
|
+
// Four rules this module exists to enforce, three of them learned the hard way:
|
|
14
|
+
// 1. Wait for a KNOWN sha when the caller has one. "Wait until it CHANGES" hangs when the
|
|
15
|
+
// flow starts mid-rollout with the new value already live — which is precisely when
|
|
16
|
+
// someone is impatient and runs the verification early.
|
|
17
|
+
// 2. Require N CONSECUTIVE matching polls. A fleet mid-roll answers old/new/old, so a
|
|
18
|
+
// single hit on the one machine that finished is not proof the rollout is done.
|
|
19
|
+
// 3. Fetch from HERE, never through the page. A service worker serves a cached build, so
|
|
20
|
+
// a page-side poll happily reports the new marker on a page running the old code.
|
|
21
|
+
// 4. A missing marker FAILS. Skipping the wait would restore the exact false confidence
|
|
22
|
+
// the contract removes — only now with a green tick on it.
|
|
23
|
+
|
|
24
|
+
/** Default path of the marker. The contract path, not a per-flow knob. */
|
|
25
|
+
export const BUILD_MARKER_PATH = '/api/health';
|
|
26
|
+
|
|
27
|
+
/** Shortest sha prefix accepted as `expected` — git's own default abbreviation. */
|
|
28
|
+
export const MIN_SHA_PREFIX = 7;
|
|
29
|
+
const HEX = /^[0-9a-f]+$/;
|
|
30
|
+
|
|
31
|
+
export interface WaitForBuildOptions {
|
|
32
|
+
/** The sha to wait FOR. Omit to fall back to "changed from the value at start". */
|
|
33
|
+
expected?: string | null;
|
|
34
|
+
/** Give up after this long (default 180s — a Fly rolling deploy of a few machines). */
|
|
35
|
+
timeoutMs?: number;
|
|
36
|
+
/** Gap between polls (default 2s). */
|
|
37
|
+
intervalMs?: number;
|
|
38
|
+
/** Consecutive matching reads required before we call it live (default 3). */
|
|
39
|
+
consecutive?: number;
|
|
40
|
+
/** Injected for tests. */
|
|
41
|
+
fetchImpl?: typeof fetch;
|
|
42
|
+
now?: () => number;
|
|
43
|
+
sleep?: (ms: number) => Promise<void>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface WaitForBuildResult {
|
|
47
|
+
build: string;
|
|
48
|
+
polls: number;
|
|
49
|
+
waited_ms: number;
|
|
50
|
+
/** How the target was decided — useful in the step detail. */
|
|
51
|
+
mode: 'expected-sha' | 'changed-from-start';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class BuildMarkerError extends Error {}
|
|
55
|
+
|
|
56
|
+
/** Read the marker once, straight from the runner. Never through the page: see rule 3. */
|
|
57
|
+
export async function readBuildMarker(
|
|
58
|
+
baseUrl: string,
|
|
59
|
+
attempt: number,
|
|
60
|
+
fetchImpl: typeof fetch = fetch,
|
|
61
|
+
): Promise<string> {
|
|
62
|
+
const url = new URL(BUILD_MARKER_PATH, baseUrl);
|
|
63
|
+
// Cache-bust on BOTH axes: a query the CDN has never seen, plus no-store, so neither an
|
|
64
|
+
// edge cache nor the local HTTP cache can hand back a stale marker.
|
|
65
|
+
url.searchParams.set('_lens_build', String(attempt));
|
|
66
|
+
let res: Response;
|
|
67
|
+
try {
|
|
68
|
+
res = await fetchImpl(url.toString(), {
|
|
69
|
+
cache: 'no-store',
|
|
70
|
+
headers: { 'Cache-Control': 'no-cache', Pragma: 'no-cache', Accept: 'application/json' },
|
|
71
|
+
});
|
|
72
|
+
} catch (err) {
|
|
73
|
+
throw new BuildMarkerError(`build marker unreachable at ${url.pathname}: ${(err as Error).message}`);
|
|
74
|
+
}
|
|
75
|
+
if (!res.ok) {
|
|
76
|
+
throw new BuildMarkerError(`build marker returned HTTP ${res.status} at ${url.pathname}`);
|
|
77
|
+
}
|
|
78
|
+
let body: unknown;
|
|
79
|
+
try {
|
|
80
|
+
body = await res.json();
|
|
81
|
+
} catch {
|
|
82
|
+
// The classic shape of this failure: /api/* falls through to the SPA and answers HTML.
|
|
83
|
+
throw new BuildMarkerError(
|
|
84
|
+
`build marker at ${url.pathname} did not return JSON — the route probably falls through to the SPA`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
const build = (body as { build?: unknown })?.build;
|
|
88
|
+
if (typeof build !== 'string' || build.trim() === '') {
|
|
89
|
+
throw new BuildMarkerError(
|
|
90
|
+
`no build marker: ${url.pathname} has no "build" field, so a new build cannot be told from an old one. ` +
|
|
91
|
+
'Add build = the running git sha (F255.7 contract) before gating a run on it.',
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
return build.trim();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Block until the app is demonstrably serving the build we are about to verify. */
|
|
98
|
+
export async function waitForBuild(baseUrl: string, opts: WaitForBuildOptions = {}): Promise<WaitForBuildResult> {
|
|
99
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
100
|
+
const now = opts.now ?? Date.now;
|
|
101
|
+
const sleep = opts.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
|
|
102
|
+
const timeoutMs = opts.timeoutMs ?? 180_000;
|
|
103
|
+
const intervalMs = opts.intervalMs ?? 2_000;
|
|
104
|
+
const needed = Math.max(1, opts.consecutive ?? 3);
|
|
105
|
+
const expected = opts.expected?.trim().toLowerCase() || null;
|
|
106
|
+
// F255.7.1 — a short sha is what agents actually have (`git log --oneline`, handoffs), so
|
|
107
|
+
// a hex expectation matches as a PREFIX of the served sha. Below git's 7-char default a
|
|
108
|
+
// prefix stops being a fingerprint, so it is refused before we wait 180s to say so.
|
|
109
|
+
const expectedHex = expected !== null && HEX.test(expected);
|
|
110
|
+
if (expectedHex && expected.length < MIN_SHA_PREFIX) {
|
|
111
|
+
throw new BuildMarkerError(
|
|
112
|
+
`expected sha "${expected}" is too short — pass at least ${MIN_SHA_PREFIX} hex characters (or the full sha).`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const startedAt = now();
|
|
117
|
+
let polls = 0;
|
|
118
|
+
|
|
119
|
+
// The first read establishes the baseline for the changed-from-start mode. A missing
|
|
120
|
+
// marker throws here, before any waiting — the loud failure of rule 4.
|
|
121
|
+
polls += 1;
|
|
122
|
+
const first = await readBuildMarker(baseUrl, polls, fetchImpl);
|
|
123
|
+
const matches = (v: string) => {
|
|
124
|
+
if (!expected) return v !== first;
|
|
125
|
+
const served = v.toLowerCase();
|
|
126
|
+
// A non-hex marker (a version string, a date) keeps exact equality: prefix-matching free
|
|
127
|
+
// text would turn "v1" into a match for "v1.2" — a false green.
|
|
128
|
+
return expectedHex && HEX.test(served) ? served.startsWith(expected) : served === expected;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
// Rule 1: with an explicit sha that is ALREADY live we must not sit waiting for a change
|
|
132
|
+
// that will never come — so `first` is allowed to count toward the streak.
|
|
133
|
+
let streak = matches(first) ? 1 : 0;
|
|
134
|
+
let last = first;
|
|
135
|
+
|
|
136
|
+
while (streak < needed) {
|
|
137
|
+
if (now() - startedAt >= timeoutMs) {
|
|
138
|
+
const target = expected ? `sha ${expected}` : `any build other than ${first}`;
|
|
139
|
+
throw new BuildMarkerError(
|
|
140
|
+
`timed out after ${Math.round((now() - startedAt) / 1000)}s waiting for ${target} — ` +
|
|
141
|
+
`still serving ${last} after ${polls} polls. The deploy may have failed, or the app is not reporting the new sha.`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
await sleep(intervalMs);
|
|
145
|
+
polls += 1;
|
|
146
|
+
last = await readBuildMarker(baseUrl, polls, fetchImpl);
|
|
147
|
+
// Rule 2: a non-matching read RESETS the streak. Mid-rollout the fleet answers
|
|
148
|
+
// old/new/old, and only an unbroken run of matches means every machine has flipped.
|
|
149
|
+
streak = matches(last) ? streak + 1 : 0;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
build: last,
|
|
154
|
+
polls,
|
|
155
|
+
waited_ms: now() - startedAt,
|
|
156
|
+
mode: expected ? 'expected-sha' : 'changed-from-start',
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** How long a best-effort marker read may take. Short on purpose: this runs on
|
|
161
|
+
* EVERY capture, and a target with no marker (a plain dev server) must cost
|
|
162
|
+
* almost nothing. It is a measurement we would like, never one we require. */
|
|
163
|
+
export const OBSERVE_TIMEOUT_MS = 2_000;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* F074.30 — read the build a run is about to look at, best-effort.
|
|
167
|
+
*
|
|
168
|
+
* Returns the sha, or NULL when the target exposes no marker, is unreachable, or
|
|
169
|
+
* is too slow. NULL means "not measured" and must never be read as "same build
|
|
170
|
+
* as the other run": a comparison spanning a null has to refuse rather than
|
|
171
|
+
* reassure. That is the whole reason this returns null instead of a placeholder
|
|
172
|
+
* — an empty string or a "unknown" literal would compare equal to itself and
|
|
173
|
+
* manufacture exactly the false "no regression" this exists to prevent.
|
|
174
|
+
*
|
|
175
|
+
* Unlike readBuildMarker it NEVER throws. A capture that would otherwise pass
|
|
176
|
+
* must not fail because an optional measurement was unavailable.
|
|
177
|
+
*/
|
|
178
|
+
export async function observeBuild(
|
|
179
|
+
baseUrl: string,
|
|
180
|
+
opts: { fetchImpl?: typeof fetch; timeoutMs?: number } = {},
|
|
181
|
+
): Promise<string | null> {
|
|
182
|
+
const timeoutMs = opts.timeoutMs ?? OBSERVE_TIMEOUT_MS;
|
|
183
|
+
const ctrl = new AbortController();
|
|
184
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
185
|
+
const base = opts.fetchImpl ?? fetch;
|
|
186
|
+
// Cast: readBuildMarker only ever CALLS this, so Bun's extra `fetch.preconnect`
|
|
187
|
+
// property on the nominal type is irrelevant here.
|
|
188
|
+
const withSignal = ((input: Parameters<typeof fetch>[0], init?: RequestInit) =>
|
|
189
|
+
base(input, { ...init, signal: ctrl.signal })) as unknown as typeof fetch;
|
|
190
|
+
try {
|
|
191
|
+
return await readBuildMarker(baseUrl, 0, withSignal);
|
|
192
|
+
} catch {
|
|
193
|
+
return null;
|
|
194
|
+
} finally {
|
|
195
|
+
clearTimeout(timer);
|
|
196
|
+
}
|
|
197
|
+
}
|