@broberg/lens-daemon 0.1.15 → 0.1.18

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@broberg/lens-daemon",
3
- "version": "0.1.15",
3
+ "version": "0.1.18",
4
4
  "description": "The Lens module of the cardmem daemon (flows, critic, coverage, capture sessions, the /lens/* routes and the cardmem-lens MCP) as a package. The daemon mounts createLensRouter(deps) + createLensMcpRouter(deps). Bun runtime.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -15,8 +15,8 @@
15
15
  "sharp": "^0.33.5",
16
16
  "turndown": "^7.2.4",
17
17
  "zod": "^3.23.8",
18
- "@broberg/lens-svg": "0.1.0",
19
- "@broberg/lens-engine": "0.13.1"
18
+ "@broberg/lens-engine": "0.13.1",
19
+ "@broberg/lens-svg": "0.1.0"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@happy-dom/global-registrator": "^20.10.6",
@@ -88,16 +88,77 @@ export const SIMULATED_ACCESS_NOTE =
88
88
  * Read back and compared, so a seed that did not land fails the run here instead
89
89
  * of showing up later as a popup that is mysteriously logged out. */
90
90
  export async function seedExtensionStorage(context: BrowserContext, values: Record<string, unknown>): Promise<void> {
91
- const target = context.serviceWorkers()[0] ?? context.backgroundPages()[0];
92
- if (!target) throw new Error('storage_local: the extension has no service worker or background page to write chrome.storage.local from');
93
91
  const keys = Object.keys(values);
94
- const back = await target.evaluate(async (v: Record<string, unknown>) => {
95
- const c = (globalThis as unknown as { chrome: { storage?: { local: { set(x: object): Promise<void>; get(k: string[]): Promise<Record<string, unknown>> } } } }).chrome;
96
- if (!c.storage) return null;
97
- await c.storage.local.set(v);
98
- return c.storage.local.get(Object.keys(v));
99
- }, values);
92
+ // An MV3 service worker can be stopped and restarted by Chrome at any moment, and an
93
+ // evaluate on the stopped one never settles — observed as a 90 s hang under a loaded
94
+ // test suite, never in isolation. So every attempt has a deadline, and each retry asks
95
+ // the context for the worker that is live NOW instead of reusing the old handle.
96
+ let back: Record<string, unknown> | null | undefined;
97
+ let lastError = 'no attempt made';
98
+ for (let attempt = 1; attempt <= SEED_ATTEMPTS && back === undefined; attempt++) {
99
+ const target =
100
+ context.serviceWorkers()[0] ??
101
+ context.backgroundPages()[0] ??
102
+ (await context.waitForEvent('serviceworker', { timeout: SEED_DEADLINE_MS }).catch(() => undefined));
103
+ if (!target) {
104
+ lastError = 'no service worker or background page to write chrome.storage.local from';
105
+ continue;
106
+ }
107
+ try {
108
+ back = await deadline(
109
+ target.evaluate(async (v: Record<string, unknown>) => {
110
+ const c = (globalThis as unknown as { chrome: { storage?: { local: { set(x: object): Promise<void>; get(k: string[]): Promise<Record<string, unknown>> } } } }).chrome;
111
+ if (!c.storage) return null;
112
+ await c.storage.local.set(v);
113
+ return c.storage.local.get(Object.keys(v));
114
+ }, values),
115
+ SEED_DEADLINE_MS,
116
+ );
117
+ } catch (err) {
118
+ lastError = err instanceof Error ? err.message : String(err);
119
+ }
120
+ }
121
+ if (back === undefined) throw new Error(`storage_local: could not write chrome.storage.local after ${SEED_ATTEMPTS} attempts — ${lastError}`);
100
122
  if (!back) throw new Error('storage_local: the extension has no chrome.storage — its manifest does not request the "storage" permission, so there is nowhere to seed');
101
- const missing = keys.filter((k) => JSON.stringify(back[k]) !== JSON.stringify(values[k]));
123
+ const missing = keys.filter((k) => !sameJson(back![k], values[k]));
102
124
  if (missing.length) throw new Error(`storage_local: chrome.storage.local did not keep ${missing.join(', ')} — read back differs from what was written`);
103
125
  }
126
+
127
+ const SEED_ATTEMPTS = 3;
128
+ const SEED_DEADLINE_MS = 5_000;
129
+
130
+ /** Reject after `ms` instead of waiting forever on a call that will never settle. */
131
+ export function deadline<T>(p: Promise<T>, ms: number): Promise<T> {
132
+ return new Promise<T>((resolve, reject) => {
133
+ const t = setTimeout(() => reject(new Error(`no answer within ${ms} ms`)), ms);
134
+ t.unref?.();
135
+ p.then(
136
+ (v) => {
137
+ clearTimeout(t);
138
+ resolve(v);
139
+ },
140
+ (e) => {
141
+ clearTimeout(t);
142
+ reject(e);
143
+ },
144
+ );
145
+ });
146
+ }
147
+
148
+ /** F009.127 — structural equality for JSON values, blind to object key ORDER.
149
+ * chrome.storage hands an object back with its keys reordered, so comparing
150
+ * JSON text refused a login object that had landed perfectly. Array order and
151
+ * every value still count. Exported for test. */
152
+ export function sameJson(a: unknown, b: unknown): boolean {
153
+ if (a === b) return true;
154
+ if (typeof a !== typeof b || a === null || b === null || typeof a !== 'object') return false;
155
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
156
+ if (Array.isArray(a)) {
157
+ const bb = b as unknown[];
158
+ return a.length === bb.length && a.every((v, i) => sameJson(v, bb[i]));
159
+ }
160
+ const ao = a as Record<string, unknown>;
161
+ const bo = b as Record<string, unknown>;
162
+ const ak = Object.keys(ao);
163
+ return ak.length === Object.keys(bo).length && ak.every((k) => Object.hasOwn(bo, k) && sameJson(ao[k], bo[k]));
164
+ }
package/src/flow-store.ts CHANGED
@@ -9,7 +9,7 @@ import { randomUUID } from 'node:crypto';
9
9
  import type { LensFlowRunRow } from './schema.ts';
10
10
  import { artifactKeys, type LensStorage } from './storage.ts';
11
11
  import { enqueueArtifacts } from './artifact-uploads.ts';
12
- import type { FlowResult, FailedResponse } from './flow.ts';
12
+ import type { FlowDownload, FlowResult, FailedResponse } from './flow.ts';
13
13
 
14
14
  /** A per-step entry as persisted (screenshot bytes already offloaded to an
15
15
  * artifact key — null when the step took no shot). */
@@ -110,6 +110,18 @@ export async function recordFlowRun(
110
110
  await storage.put(videoKey, result.video);
111
111
  }
112
112
 
113
+ // F009.128 — downloaded files go where the stills go; the row keeps only the key.
114
+ const storedDownloads: Array<Omit<FlowDownload, 'bytes'> & { key?: string }> = [];
115
+ for (const [n, d] of (result.downloads ?? []).entries()) {
116
+ const { bytes, ...meta } = d;
117
+ let key: string | undefined;
118
+ if (bytes) {
119
+ key = artifactKeys.flowDownload(projectSlug, id, n, d.name);
120
+ await storage.put(key, bytes);
121
+ }
122
+ storedDownloads.push({ ...meta, ...(key ? { key } : {}) });
123
+ }
124
+
113
125
  const row: LensFlowRunRow = {
114
126
  id,
115
127
  project_slug: projectSlug,
@@ -157,6 +169,8 @@ export async function recordFlowRun(
157
169
  ...(result.extension ? { extension: result.extension } : {}),
158
170
  ...(result.password_in_url ? { password_in_url: result.password_in_url } : {}),
159
171
  ...(result.not_listening ? { not_listening: result.not_listening } : {}),
172
+ // F009.128 — always written when the run reported it, empty included.
173
+ ...(result.downloads ? { downloads: storedDownloads } : {}),
160
174
  }),
161
175
  };
162
176
 
package/src/flow.ts CHANGED
@@ -24,7 +24,7 @@ import {
24
24
  type Target,
25
25
  } from '@broberg/lens-engine';
26
26
  import { withPage, type LensEngine } from './browser.ts';
27
- import { defaultPopupOf, prepareExtension, seedExtensionStorage, SIMULATED_ACCESS_NOTE } from './extension-flow.ts';
27
+ import { deadline, defaultPopupOf, prepareExtension, seedExtensionStorage, SIMULATED_ACCESS_NOTE } from './extension-flow.ts';
28
28
  import { authPrepare, type AuthConfig, type AuthEnv } from './auth/index.ts';
29
29
  import { takeShot, settleForShot } from './capture.ts';
30
30
  import { runCritic, topFindingMessage, type CriticMode, type CriticReport } from './critic.ts';
@@ -359,7 +359,36 @@ export class InstrumentError extends Error {
359
359
  }
360
360
  }
361
361
 
362
+ /** F009.128 — one file the run's browser downloaded. `bytes` is dropped once the
363
+ * store has written it; a reader gets `url` instead. */
364
+ export interface FlowDownload {
365
+ name: string;
366
+ size: number;
367
+ /** Which page triggered it: the page under test, the extension popup, or another. */
368
+ page: 'page' | 'popup' | 'other';
369
+ /** Index of the step that was running when it started; null before the first. */
370
+ step: number | null;
371
+ bytes?: Buffer;
372
+ error?: string;
373
+ /** Not kept because a limit was hit (MAX_DOWNLOADS / MAX_DOWNLOAD_BYTES). */
374
+ skipped?: 'cap';
375
+ }
376
+ export const MAX_DOWNLOADS = 10;
377
+ export const MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024;
378
+ const DOWNLOAD_SETTLE_MS = 5_000;
379
+ const DOWNLOAD_FINISH_MS = 30_000;
380
+
381
+ /** F009.128 — wait for the run's downloads, bounded. Whatever has not finished by
382
+ * then is marked as such, never dropped and never waited on forever. Exported for test. */
383
+ export async function finishDownloads(pending: Promise<void>[], downloads: FlowDownload[], ms: number): Promise<void> {
384
+ await deadline(Promise.allSettled(pending), ms).catch(() => {
385
+ for (const d of downloads) if (!d.bytes && !d.error && !d.skipped) d.error = `did not finish within ${ms} ms`;
386
+ });
387
+ }
388
+
362
389
  export interface FlowResult {
390
+ /** F009.128 — files the run downloaded. Always present on a finished run. */
391
+ downloads?: FlowDownload[];
363
392
  /** F009.125 — the extension this run loaded: its id (read at load time), the
364
393
  * build path the caller named, and whether host access was SIMULATED on a copy. */
365
394
  extension?: { id: string; path: string; host_access: 'as-built' | 'simulated'; note?: string } | null;
@@ -511,6 +540,8 @@ function requestRegex(u: string): RegExp {
511
540
  export interface SeenRequest {
512
541
  method: string;
513
542
  url: string;
543
+ /** Playwright's resource type; 'document' is a navigation (and what a link download is). */
544
+ type?: string;
514
545
  /** null while in flight, or when it failed before a response. */
515
546
  status: number | null;
516
547
  failed?: string;
@@ -1562,6 +1593,14 @@ export async function clickAndActivate(
1562
1593
  if (t === 'xhr' || t === 'fetch' || t === 'document') requests += 1;
1563
1594
  };
1564
1595
  page.on('request', onRequest);
1596
+ // F009.128 — a download IS an effect. A button whose only job is a download
1597
+ // (a blob handed to <a download>) touches no DOM and makes no request, so it
1598
+ // read as a silent no-op and got pressed twice: two files for one click.
1599
+ let downloaded = false;
1600
+ const onDownload = () => {
1601
+ downloaded = true;
1602
+ };
1603
+ page.on('download', onDownload);
1565
1604
  const urlBefore = page.url();
1566
1605
  await page
1567
1606
  .evaluate(() => {
@@ -1606,7 +1645,7 @@ export async function clickAndActivate(
1606
1645
  const mutations = await page
1607
1646
  .evaluate(() => (window as unknown as { __lensMut?: number }).__lensMut ?? 0)
1608
1647
  .catch(() => 1); // page navigated away → that IS an effect
1609
- if (mutations > 0 || requests > 0 || page.url() !== urlBefore) return false;
1648
+ if (mutations > 0 || requests > 0 || downloaded || page.url() !== urlBefore) return false;
1610
1649
  const focusMoved = await page
1611
1650
  .evaluate(
1612
1651
  ([before, clicked]) => {
@@ -1629,6 +1668,7 @@ export async function clickAndActivate(
1629
1668
  return true;
1630
1669
  } finally {
1631
1670
  page.off('request', onRequest);
1671
+ page.off('download', onDownload);
1632
1672
  await focusBefore.dispose().catch(() => {});
1633
1673
  await target?.dispose().catch(() => {});
1634
1674
  await page
@@ -2741,6 +2781,7 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
2741
2781
  // F213.11 — read BEFORE the step runs, so the number is "how long after the
2742
2782
  // page loaded did this start", never "how long did it take".
2743
2783
  const stepStartedAt = Date.now();
2784
+ currentStep = i;
2744
2785
  const msAfterNav = stepStartedAt - lastNavAt;
2745
2786
  // F213.13 — a goto's reading is measured from the PREVIOUS navigation, so it
2746
2787
  // is not the same quantity as its siblings and must not sit in their column.
@@ -2928,12 +2969,58 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
2928
2969
  extCtx.popup = popup;
2929
2970
  return null;
2930
2971
  };
2972
+ // F009.128 — files the run downloads. A download is awaited before the context
2973
+ // closes; otherwise the bytes die with the browser and the report says nothing.
2974
+ const downloads: FlowDownload[] = [];
2975
+ const pendingDownloads: Promise<void>[] = [];
2976
+ let currentStep: number | null = null;
2977
+ const watchDownloads = (context: BrowserContext): void => {
2978
+ const hooked = new WeakSet<Page>();
2979
+ const hook = (p: Page) => {
2980
+ if (hooked.has(p)) return;
2981
+ hooked.add(p);
2982
+ p.on('download', (d) => {
2983
+ const entry: FlowDownload = {
2984
+ name: d.suggestedFilename(),
2985
+ size: 0,
2986
+ page: p === extCtx?.popup ? 'popup' : p === extCtx?.main || p === videoPage ? 'page' : 'other',
2987
+ step: currentStep,
2988
+ };
2989
+ downloads.push(entry);
2990
+ if (downloads.length > MAX_DOWNLOADS) {
2991
+ entry.skipped = 'cap';
2992
+ pendingDownloads.push(d.cancel().catch(() => {}));
2993
+ return;
2994
+ }
2995
+ pendingDownloads.push(
2996
+ (async () => {
2997
+ const failure = await d.failure();
2998
+ // `??=`: once the run gave up waiting, the context closing cancels the
2999
+ // download — and 'canceled' would hide that it simply never finished.
3000
+ if (failure) {
3001
+ entry.error ??= failure;
3002
+ return;
3003
+ }
3004
+ const path = await d.path();
3005
+ const bytes = await readFile(path);
3006
+ entry.size = bytes.length;
3007
+ if (bytes.length > MAX_DOWNLOAD_BYTES) entry.skipped = 'cap';
3008
+ else entry.bytes = bytes;
3009
+ })().catch((err) => {
3010
+ entry.error ??= err instanceof Error ? err.message : String(err);
3011
+ }),
3012
+ );
3013
+ });
3014
+ };
3015
+ context.pages().forEach(hook);
3016
+ context.on('page', hook);
3017
+ };
2931
3018
  // F009.126 — every request the run's main browser makes, from the first step on.
2932
3019
  const seenRequests: SeenRequest[] = [];
2933
3020
  const watchRequests = (context: BrowserContext): void => {
2934
3021
  const byReq = new Map<object, SeenRequest>();
2935
3022
  context.on('request', (req) => {
2936
- const r: SeenRequest = { method: req.method(), url: req.url(), status: null };
3023
+ const r: SeenRequest = { method: req.method(), url: req.url(), status: null, type: req.resourceType() };
2937
3024
  byReq.set(req, r);
2938
3025
  seenRequests.push(r);
2939
3026
  });
@@ -3017,6 +3104,7 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
3017
3104
  videoPage = page;
3018
3105
  collectFailures(page);
3019
3106
  watchRequests(context);
3107
+ watchDownloads(context);
3020
3108
  if (ext && flow.extension && prepared) {
3021
3109
  extCtx = { context, id: ext.id, main: page, popup: null };
3022
3110
  if (flow.extension.storage_local) await seedExtensionStorage(context, flow.extension.storage_local);
@@ -3118,6 +3206,22 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
3118
3206
  } catch {
3119
3207
  // page already closed — final_url stays null (not measured)
3120
3208
  }
3209
+ // F009.128 — before the context (and the files) go away. A link download is a
3210
+ // navigation whose 'download' event fires only once its headers arrive, which can
3211
+ // be AFTER the last step returned; so wait for navigations still in flight (bounded)
3212
+ // and only then for the downloads they turned into. Runs with nothing in flight
3213
+ // pay nothing.
3214
+ // A navigation Chromium turns into a download is ABORTED as a request
3215
+ // (net::ERR_ABORTED) and only then raises 'download' — so count those too.
3216
+ const settleUntil = Date.now() + DOWNLOAD_SETTLE_MS;
3217
+ const navInFlight = () => seenRequests.some((r) => r.type === 'document' && r.status === null && !r.failed);
3218
+ const becameDownload = () => seenRequests.filter((r) => r.type === 'document' && (r.failed ?? '').includes('ERR_ABORTED')).length;
3219
+ while (Date.now() < settleUntil && (navInFlight() || downloads.length < becameDownload())) {
3220
+ await new Promise((res) => setTimeout(res, 100));
3221
+ }
3222
+ // Bounded: a download that never finishes must not hang the run. Whatever is
3223
+ // still unfinished is reported as such, never dropped.
3224
+ await finishDownloads(pendingDownloads, downloads, DOWNLOAD_FINISH_MS);
3121
3225
  },
3122
3226
  );
3123
3227
  } catch (err) {
@@ -3125,7 +3229,7 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
3125
3229
  // Keep the recording even on a crash — watching HOW it broke is the whole point.
3126
3230
  const video = await readVideo();
3127
3231
  await prepared?.cleanup();
3128
- return { ...(extensionInfo ? { extension: extensionInfo } : {}), name: flow.name, status: 'error', steps: results.sort((a, b) => a.index - b.index), failed_at: firstFailedAt, reason: leadWithRedirect(reason, firstRedirect), duration_ms: Date.now() - startedAt, video, build: observedBuild, final_url: finalUrl, redirected: firstRedirect, password_in_url: passwordInUrl(finalUrl, filledPasswords), not_listening: notListening[0] ?? null, reduced_motion: opts.reducedMotion ?? 'reduce' };
3232
+ return { downloads, ...(extensionInfo ? { extension: extensionInfo } : {}), name: flow.name, status: 'error', steps: results.sort((a, b) => a.index - b.index), failed_at: firstFailedAt, reason: leadWithRedirect(reason, firstRedirect), duration_ms: Date.now() - startedAt, video, build: observedBuild, final_url: finalUrl, redirected: firstRedirect, password_in_url: passwordInUrl(finalUrl, filledPasswords), not_listening: notListening[0] ?? null, reduced_motion: opts.reducedMotion ?? 'reduce' };
3129
3233
  }
3130
3234
 
3131
3235
  const video = await readVideo();
@@ -3179,6 +3283,7 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
3179
3283
  // context can never disagree about it.
3180
3284
  reduced_motion: opts.reducedMotion ?? 'reduce',
3181
3285
  ...(extensionInfo ? { extension: extensionInfo } : {}),
3286
+ downloads,
3182
3287
  };
3183
3288
  }
3184
3289
 
package/src/service.ts CHANGED
@@ -666,11 +666,16 @@ function parseAuthJson(raw: string | null | undefined): { source: string; adapte
666
666
  /** NULL = the row predates the column, so we have no answer; `{}` = the run
667
667
  * looked and found nothing. Malformed JSON is reported as unknown rather than
668
668
  * as "clean": a parse failure must never read as an all-clear. */
669
- function parseFindingsJson(raw: string | null | undefined): Record<string, unknown> | null {
669
+ function parseFindingsJson(raw: string | null | undefined, port?: number): Record<string, unknown> | null {
670
670
  if (raw == null) return null;
671
671
  try {
672
672
  const v = JSON.parse(raw);
673
- return v && typeof v === 'object' ? (v as Record<string, unknown>) : null;
673
+ if (!v || typeof v !== 'object') return null;
674
+ // F009.128 — a stored download carries its storage key; a reader gets a url.
675
+ if (port !== undefined && Array.isArray(v.downloads)) {
676
+ v.downloads = v.downloads.map(({ key, ...d }: { key?: string }) => (key ? { ...d, url: artifactUrl(port, key) } : d));
677
+ }
678
+ return v as Record<string, unknown>;
674
679
  } catch {
675
680
  return null;
676
681
  }
@@ -748,7 +753,7 @@ export function flowRunView(row: LensFlowRunRow, port: number) {
748
753
  // and lens_list_flow_runs for the string and found neither — "the instrument
749
754
  // measures right and nobody is told", which is the same shape as the probe
750
755
  // that sat unwired to `fill` for a month.
751
- findings: parseFindingsJson(row.findings_json),
756
+ findings: parseFindingsJson(row.findings_json, port),
752
757
  steps,
753
758
  // F255.5 — present only when the run was recorded. Same artifact mechanism as
754
759
  // the step screenshots, so a consumer fetches it exactly the same way.
@@ -869,7 +874,7 @@ export function toCloudFlowResult(result: EngineFlowResult, row: LensFlowRunRow,
869
874
  // (a CI runner with no daemon) is the consumer LEAST able to go and look
870
875
  // somewhere else, so leaving them off this shape would be the gap at its
871
876
  // worst.
872
- findings: parseFindingsJson(row.findings_json),
877
+ findings: parseFindingsJson(row.findings_json, port),
873
878
  steps: result.steps.map((s) => ({
874
879
  index: s.index,
875
880
  action: s.action,
package/src/storage.ts CHANGED
@@ -118,6 +118,9 @@ export const artifactKeys = {
118
118
  `${project}/flows/${flowRunId}/step-${stepIndex}.png`,
119
119
  // F255.5 — the whole driven run as a video, alongside its step stills.
120
120
  flowVideo: (project: string, flowRunId: string) => `${project}/flows/${flowRunId}/run.webm`,
121
+ // F009.128 — a file the run downloaded. Numbered so two files with one name both survive.
122
+ flowDownload: (project: string, flowRunId: string, n: number, name: string) =>
123
+ `${project}/flows/${flowRunId}/download-${n}-${name.replace(/[^\w.-]+/g, '_').slice(0, 120)}`,
121
124
  // F074.63 — a vector export (PDF). Its own prefix, not runs/: an export is not
122
125
  // a capture, carries no baseline and must never be diffed against one.
123
126
  exportDoc: (project: string, exportId: string, ext: string) =>