@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/capture.ts ADDED
@@ -0,0 +1,348 @@
1
+ // F074.1 — Lens capture core.
2
+ //
3
+ // Stateless toward the project: give it a URL + a command, get back a
4
+ // screenshot (+ metadata). Three modes:
5
+ // - viewport : the visible 1280×800 (default) window
6
+ // - fullPage : the entire scrollable page
7
+ // - element : crop to a single [data-testid] (the preferred, stable mode)
8
+ //
9
+ // Element mode takes a FULL-PAGE screenshot then Sharp-crops to the element's
10
+ // bounding box with Math.round on the float coords. This avoids the cms
11
+ // pitfall where getBoundingClientRect() floats handed straight to an image
12
+ // crop throw "extract_area: bad extract area" — and avoids Playwright's
13
+ // locator.screenshot() re-scroll jitter that makes element shots non-
14
+ // deterministic for pixel-diffing.
15
+ //
16
+ // F074.2 extracts navigateAndAct() + takeShot() so verify.ts can reuse the
17
+ // exact same page lifecycle (one page visit → screenshot AND assert AND
18
+ // console/network) without duplicating navigation logic.
19
+
20
+ import sharp from 'sharp';
21
+ import type { Page } from 'playwright';
22
+ import { withPage, type WithPageOptions, type LensEngine } from './browser.ts';
23
+ import { authPrepare, type AuthConfig } from './auth/index.ts';
24
+ import { runCritic, type CriticMode, type CriticReport } from './critic.ts';
25
+ import type { VisionModelKey } from './critic-vision.ts';
26
+ import { fillAndVerify } from './fill.ts';
27
+ import { storagePayload, type StorageSeed } from './seed-storage.ts';
28
+
29
+ export type CaptureMode = 'viewport' | 'fullPage' | 'element';
30
+
31
+ export interface CaptureAction {
32
+ type: 'click' | 'fill' | 'scrollTo' | 'waitFor';
33
+ /** CSS selector — a [data-testid="…"] is preferred (the strict contract). */
34
+ selector?: string;
35
+ /** Value for `fill`. */
36
+ value?: string;
37
+ /** Per-action timeout (ms). Default 5000. */
38
+ timeout?: number;
39
+ }
40
+
41
+ export interface CaptureOptions {
42
+ url: string;
43
+ mode?: CaptureMode;
44
+ /** Required for `element` mode. Prefer `[data-testid="…"]`. */
45
+ selector?: string;
46
+ /** F098.9 — engine to drive (chromium default; webkit/firefox for cross-engine bugs). */
47
+ engine?: LensEngine;
48
+ viewport?: { width: number; height: number };
49
+ deviceScaleFactor?: number;
50
+ /** F098.6 — navigation settle strategy. Default 'domcontentloaded': fires once
51
+ * the HTML is parsed, BEFORE sub-resources — so a live-data / streaming /
52
+ * canvas SPA where the `load` event never fires (a never-finishing request,
53
+ * long-poll, video, rAF-driven asset) can't hang `goto` to its timeout.
54
+ * Content readiness is handled by the explicit `waitFor` gate below. A static
55
+ * surface that genuinely needs the full load can override with 'load'. */
56
+ waitUntil?: 'load' | 'domcontentloaded' | 'commit';
57
+ /** Wait gate after navigation + actions: a selector to wait for, or ms to sleep. */
58
+ waitFor?: string | number;
59
+ /** Pre-capture interactions (click/fill/scrollTo/waitFor), run in order. */
60
+ actions?: CaptureAction[];
61
+ /** F074.69 — load an unpacked browser extension and capture its OWN pages.
62
+ * Write the url as `extension://popup.html`; the generated id is substituted
63
+ * after the browser reports it, because a caller cannot know it in advance. */
64
+ extension?: { path: string };
65
+ /** Navigation + overall timeout (ms). Default 30000. */
66
+ timeoutMs?: number;
67
+ /** F074.4 hook — inject auth into the context before the page loads.
68
+ * Lower-level than `auth`; set directly only in-process. */
69
+ prepareContext?: WithPageOptions['prepareContext'];
70
+ /** F074.4 — declarative auth pre-step (storageState | cookieJwt). The
71
+ * secret is resolved from the daemon env by name, never carried here. */
72
+ auth?: AuthConfig;
73
+ /** F074.97 — localStorage/sessionStorage written BEFORE the first navigation,
74
+ * so a bug that only exists in SAVED CLIENT STATE (a restored tab strip, a
75
+ * remembered filter, a dismissed banner, a seven-day-old timestamp) can be
76
+ * photographed instead of only unit-fixtured. Scoped to this url's own origin;
77
+ * omitted registers no init script at all. */
78
+ storage?: StorageSeed;
79
+ /** F126.4 — composition critic to run on the captured surface. 'off' (default)
80
+ * = none; 'dom' = free geometry; 'vision' = metered model; 'both'. Geometry is
81
+ * always safe to run; vision is gated by the caller (the F095 gate reads the
82
+ * project's composition_critic.vision setting). */
83
+ critic?: CriticMode;
84
+ /** F126.4 — vision model for the critic's vision pass ('haiku' default | 'sonnet').
85
+ * Only used when critic includes vision; the gate sets it from the project's
86
+ * composition_critic.vision_model setting. */
87
+ criticVisionModel?: VisionModelKey;
88
+ }
89
+
90
+ /** Pick the context-preparation callback: an explicit prepareContext wins,
91
+ * else the declarative `auth` adapter, else none. */
92
+ export function resolvePrepareContext(opts: {
93
+ prepareContext?: WithPageOptions['prepareContext'];
94
+ auth?: AuthConfig;
95
+ }): WithPageOptions['prepareContext'] {
96
+ return opts.prepareContext ?? authPrepare(opts.auth);
97
+ }
98
+
99
+ export interface Shot {
100
+ screenshot: Buffer;
101
+ width: number;
102
+ height: number;
103
+ /** Element box (element mode only), rounded to the integer crop applied. */
104
+ elementBox: { x: number; y: number; width: number; height: number } | null;
105
+ }
106
+
107
+ export interface CaptureResult extends Shot {
108
+ url: string;
109
+ mode: CaptureMode;
110
+ selector: string | null;
111
+ title: string;
112
+ /** Final URL after any redirects. */
113
+ finalUrl: string;
114
+ /** Uncaught page errors observed during the capture (reported, non-fatal). */
115
+ pageErrors: string[];
116
+ durationMs: number;
117
+ /** F126.4 — composition-critic findings, when a critic was requested. */
118
+ critic: CriticReport | null;
119
+ /** F074.69 — the loaded extension and the id Chromium generated for it, so a
120
+ * caller can cite the surface it photographed. Null on an ordinary capture. */
121
+ extension: { id: string; path: string } | null;
122
+ }
123
+
124
+ export async function runActions(page: Page, actions: CaptureAction[]): Promise<void> {
125
+ for (const a of actions) {
126
+ const timeout = a.timeout ?? 5000;
127
+ switch (a.type) {
128
+ case 'click':
129
+ if (!a.selector) throw new Error('action "click" requires a selector');
130
+ await page.locator(a.selector).first().click({ timeout });
131
+ break;
132
+ case 'fill':
133
+ if (!a.selector) throw new Error('action "fill" requires a selector');
134
+ // F213.7 — the card named capture-session; this is the THIRD executor of the
135
+ // same verb and would have kept the defect alive on the capture path.
136
+ await fillAndVerify(page.locator(a.selector).first(), a.value ?? '', timeout);
137
+ break;
138
+ case 'scrollTo':
139
+ if (!a.selector) throw new Error('action "scrollTo" requires a selector');
140
+ await page.locator(a.selector).first().scrollIntoViewIfNeeded({ timeout });
141
+ break;
142
+ case 'waitFor':
143
+ if (!a.selector) throw new Error('action "waitFor" requires a selector');
144
+ await page.locator(a.selector).first().waitFor({ state: 'visible', timeout });
145
+ break;
146
+ default:
147
+ throw new Error(`unknown action type: ${(a as CaptureAction).type}`);
148
+ }
149
+ }
150
+ }
151
+
152
+ /** Navigate + run pre-capture actions + apply the wait gate. Shared by
153
+ * capture() and verify() so the page lifecycle is identical. */
154
+ export async function navigateAndAct(page: Page, opts: CaptureOptions): Promise<void> {
155
+ const timeoutMs = opts.timeoutMs ?? 30_000;
156
+ // F098.6 — default 'domcontentloaded', NOT 'load' (and never 'networkidle').
157
+ // 'networkidle' never settles on an SPA with a persistent SSE/websocket
158
+ // (cardmem's authed routes open EventSource). 'load' ALSO hangs on live-data
159
+ // pages where a sub-resource never finishes (media archive, video, long-poll,
160
+ // canvas rAF) — it blocked the daemon fleet-wide (upmetrics/trail, 2026-06-05).
161
+ // 'domcontentloaded' fires once the HTML is parsed; the explicit `waitFor`
162
+ // gate below + document.fonts.ready handle content readiness. Override
163
+ // per-surface with waitUntil:'load' for a static page that needs it.
164
+ await page.goto(opts.url, { waitUntil: opts.waitUntil ?? 'domcontentloaded', timeout: timeoutMs });
165
+ if (opts.actions?.length) await runActions(page, opts.actions);
166
+ if (typeof opts.waitFor === 'number') {
167
+ await page.waitForTimeout(opts.waitFor);
168
+ } else if (typeof opts.waitFor === 'string') {
169
+ // A numeric string ("800") is an ms timeout that the MCP boundary stringified,
170
+ // NOT a CSS selector — an all-digits string is never a valid selector and
171
+ // would throw "parsing css selector 800". Treat it as page.waitForTimeout.
172
+ const trimmed = opts.waitFor.trim();
173
+ if (/^\d+$/.test(trimmed)) {
174
+ await page.waitForTimeout(Number(trimmed));
175
+ } else {
176
+ await page.locator(opts.waitFor).first().waitFor({ state: 'visible', timeout: timeoutMs });
177
+ }
178
+ }
179
+ await settleForShot(page);
180
+ }
181
+
182
+ /** F094.7 + F255.3 — everything that must finish before a frame is worth photographing.
183
+ * Extracted so FLOW screenshots run it too: they used to call takeShot() directly, so a
184
+ * step fired right after a click photographed the page mid-transition. On the F249
185
+ * mockups list that meant a just-ticked checkbox (transition: background 0.12s) was
186
+ * captured still white while the DOM said checked — a picture that made a working
187
+ * feature look broken, which is worse than no picture at all. */
188
+ export async function settleForShot(page: Page): Promise<void> {
189
+ // Wait for web fonts to finish loading before any screenshot, so the baseline and a
190
+ // later capture always render text with the SAME font. A FOUT (fallback font rendered
191
+ // at capture time) otherwise yields a large, false text-diff on identical content.
192
+ await page.evaluate(async () => {
193
+ await document.fonts.ready;
194
+ // Let CSS transitions/animations SETTLE before the shot. A drawer with a
195
+ // 240ms slide-in would otherwise be captured mid-slide (translateX
196
+ // off-screen) → a false "closed" screenshot (components, 2026-06-10). Wait
197
+ // for FINITE animations to finish, but cap the wait so an INFINITE one
198
+ // (spinner, looping rAF-less CSS) can't hang the capture, then a couple
199
+ // rAF frames so the final committed frame is painted.
200
+ const anims = (document.getAnimations?.() ?? []).filter((a) => {
201
+ const iters = a.effect?.getComputedTiming?.().iterations;
202
+ return iters !== Infinity;
203
+ });
204
+ await Promise.race([
205
+ Promise.all(anims.map((a) => a.finished.catch(() => {}))),
206
+ new Promise((r) => setTimeout(r, 600)),
207
+ ]);
208
+ await new Promise<void>((r) => requestAnimationFrame(() => requestAnimationFrame(() => r())));
209
+ }).catch(() => {});
210
+ await page.waitForTimeout(120);
211
+ }
212
+
213
+ /** F255.2 — the element crop, as pure arithmetic so it can be tested without a browser.
214
+ * `box` is the element's rect in DOCUMENT css-pixels plus the page's devicePixelRatio;
215
+ * `img` is the full-page shot's real pixel size. The shot is in DEVICE pixels, so a
216
+ * scale:2 capture needs the css rect multiplied by 2 before cropping. Everything is
217
+ * clamped inside the image so a rect that ends past the bottom edge still yields a valid
218
+ * (if shorter) crop instead of throwing inside Sharp. */
219
+ export function cropRect(
220
+ box: { x: number; y: number; width: number; height: number; dpr?: number },
221
+ img: { width?: number; height?: number },
222
+ ): { left: number; top: number; width: number; height: number } {
223
+ const dpr = box.dpr && box.dpr > 0 ? box.dpr : 1;
224
+ const imgW = img.width ?? 0;
225
+ const imgH = img.height ?? 0;
226
+ const left = Math.max(0, Math.min(Math.round(box.x * dpr), Math.max(0, imgW - 1)));
227
+ const top = Math.max(0, Math.min(Math.round(box.y * dpr), Math.max(0, imgH - 1)));
228
+ const width = Math.max(1, Math.min(Math.round(box.width * dpr), imgW - left));
229
+ const height = Math.max(1, Math.min(Math.round(box.height * dpr), imgH - top));
230
+ return { left, top, width, height };
231
+ }
232
+
233
+ /** Take the screenshot per mode. Element mode = full-page shot Sharp-cropped
234
+ * to the rounded bounding box (the cms float-coord pitfall fix). */
235
+ export async function takeShot(
236
+ page: Page,
237
+ mode: CaptureMode,
238
+ selector: string | null,
239
+ timeoutMs: number,
240
+ ): Promise<Shot> {
241
+ if (mode === 'element') {
242
+ if (!selector) throw new Error('element mode requires a selector (prefer [data-testid="…"])');
243
+ const locator = page.locator(selector).first();
244
+ await locator.waitFor({ state: 'visible', timeout: timeoutMs });
245
+ await locator.scrollIntoViewIfNeeded({ timeout: timeoutMs });
246
+ // F255.2 — read the rect in DOCUMENT coordinates, not viewport coordinates.
247
+ // The shot below is fullPage (document space) while Playwright's boundingBox()
248
+ // is relative to the VIEWPORT, so cropping the one with the other photographed
249
+ // whatever happened to be scrolled into that spot: three far-apart elements on
250
+ // a long page all came back as md5-identical pictures of the top of the page
251
+ // (fd-sundhed, 2026-07-18). Adding the scroll offset makes the two spaces agree.
252
+ // devicePixelRatio is read in the same call because a scale:2 capture renders a
253
+ // 2× PNG while the rect stays CSS pixels — the crop must be scaled to match.
254
+ const box = await locator.evaluate((el) => {
255
+ const r = el.getBoundingClientRect();
256
+ return {
257
+ x: r.left + window.scrollX,
258
+ y: r.top + window.scrollY,
259
+ width: r.width,
260
+ height: r.height,
261
+ dpr: window.devicePixelRatio || 1,
262
+ };
263
+ });
264
+ if (!box || box.width === 0 || box.height === 0) {
265
+ throw new Error(`element not visible / has no box: ${selector}`);
266
+ }
267
+
268
+ const full = await page.screenshot({ fullPage: true });
269
+ const crop = cropRect(box, await sharp(full).metadata());
270
+ const screenshot = await sharp(full).extract(crop).png().toBuffer();
271
+ return {
272
+ screenshot,
273
+ width: crop.width,
274
+ height: crop.height,
275
+ elementBox: { x: crop.left, y: crop.top, width: crop.width, height: crop.height },
276
+ };
277
+ }
278
+
279
+ const screenshot = await page.screenshot({ fullPage: mode === 'fullPage' });
280
+ const meta = await sharp(screenshot).metadata();
281
+ return { screenshot, width: meta.width ?? 0, height: meta.height ?? 0, elementBox: null };
282
+ }
283
+
284
+ /** F074.69 — rewrite the `extension://` shorthand once the id is known.
285
+ *
286
+ * Exported for test, and deliberately strict about the failure: asking for an
287
+ * extension url WITHOUT loading an extension is a mistake that would otherwise
288
+ * produce a navigation error deep inside Chromium and read as a flaky capture. */
289
+ export function resolveExtensionUrl(url: string, id: string | undefined): string {
290
+ if (!url.startsWith('extension://')) return url;
291
+ if (!id) {
292
+ throw new Error(
293
+ 'an extension:// url needs an extension to resolve against — pass extension:{path:"…"} ' +
294
+ 'so Lens can load it and read back the id Chromium generates.',
295
+ );
296
+ }
297
+ return `chrome-extension://${id}/${url.slice('extension://'.length)}`;
298
+ }
299
+
300
+ export async function capture(opts: CaptureOptions): Promise<CaptureResult> {
301
+ const startedAt = Date.now();
302
+ const mode: CaptureMode = opts.mode ?? 'viewport';
303
+ const timeoutMs = opts.timeoutMs ?? 30_000;
304
+ if (mode === 'element' && !opts.selector) {
305
+ throw new Error('element mode requires a selector (prefer [data-testid="…"])');
306
+ }
307
+
308
+ return withPage(
309
+ {
310
+ engine: opts.engine,
311
+ viewport: opts.viewport,
312
+ deviceScaleFactor: opts.deviceScaleFactor,
313
+ prepareContext: resolvePrepareContext(opts),
314
+ seedStorage: storagePayload(opts.storage, opts.url),
315
+ extension: opts.extension,
316
+ },
317
+ async (page, _context, extension) => {
318
+ const pageErrors: string[] = [];
319
+ page.on('pageerror', (err) => pageErrors.push(err.message));
320
+
321
+ // F074.69 — `extension://popup.html` becomes `chrome-extension://<id>/popup.html`
322
+ // only once the browser has told us the id. Substituting here rather than
323
+ // asking the caller for it is the point: the id is generated at load time,
324
+ // so a manuscript that hardcoded one would work until it did not.
325
+ await navigateAndAct(page, { ...opts, url: resolveExtensionUrl(opts.url, extension?.id) });
326
+ const title = await page.title();
327
+ const finalUrl = page.url();
328
+ const shot = await takeShot(page, mode, opts.selector ?? null, timeoutMs);
329
+ const critic =
330
+ opts.critic && opts.critic !== 'off'
331
+ ? await runCritic(page, shot.screenshot, opts.critic, opts.criticVisionModel)
332
+ : null;
333
+
334
+ return {
335
+ url: opts.url,
336
+ mode,
337
+ selector: opts.selector ?? null,
338
+ ...shot,
339
+ title,
340
+ finalUrl,
341
+ pageErrors,
342
+ durationMs: Date.now() - startedAt,
343
+ critic,
344
+ extension: extension ?? null,
345
+ };
346
+ },
347
+ );
348
+ }