@broberg/lens-daemon 0.1.41 → 0.1.43

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.41",
3
+ "version": "0.1.43",
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.35.5",
16
16
  "turndown": "^7.2.4",
17
17
  "zod": "^3.23.8",
18
- "@broberg/lens-engine": "0.14.1",
19
- "@broberg/lens-svg": "0.1.1"
18
+ "@broberg/lens-svg": "0.1.1",
19
+ "@broberg/lens-engine": "0.14.1"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@happy-dom/global-registrator": "^20.10.6",
package/src/flow-store.ts CHANGED
@@ -20,7 +20,7 @@ export interface StoredFlowStep {
20
20
  /** F009.122 — the named browser the step ran in; absent = the default one. */
21
21
  browser?: string;
22
22
  /** F009.125 — the step ran in the extension popup. */
23
- in?: 'popup';
23
+ in?: 'popup' | 'opened';
24
24
  status: 'pass' | 'fail' | 'skip';
25
25
  testid: string | null;
26
26
  detail: string | null;
package/src/flow.ts CHANGED
@@ -167,6 +167,10 @@ export type FlowStep =
167
167
  // then put the page under test back in front so the extension sees it as the active
168
168
  // tab (measured). `path` defaults to the manifest's action.default_popup.
169
169
  | { type: 'openPopup'; path?: string; label?: string; timeout?: number }
170
+ // F009.139 — take over a window the PAGE opened (window.open / target=_blank: an
171
+ // OAuth dialog), and wait for it to close again. Steps with in:"opened" run in it.
172
+ | { type: 'waitForPopup'; label?: string; timeout?: number }
173
+ | { type: 'waitForPopupClose'; label?: string; timeout?: number }
170
174
  // F009.126 — pass when the run made a matching request (page, popup, any page in the
171
175
  // run's browser). `url` is a substring, or a regex with match:"regex" (a path like
172
176
  // "/api/clips" would otherwise read as a /pattern/flags literal). Fails listing what WAS seen.
@@ -296,7 +300,7 @@ export interface FlowStepResult {
296
300
  /** F009.122 — the named browser this step ran in. Absent = the default one. */
297
301
  browser?: string;
298
302
  /** F009.125 — present when the step ran in the extension popup. */
299
- in?: 'popup';
303
+ in?: 'popup' | 'opened';
300
304
  status: 'pass' | 'fail' | 'skip';
301
305
  testid: string | null;
302
306
  detail: string | null;
@@ -512,6 +516,8 @@ export const STEP_TYPES = new Set([
512
516
  'openPopup',
513
517
  // F009.126 — assert a request the run made
514
518
  'expectRequest',
519
+ // F009.139 — follow a window the page opened
520
+ 'waitForPopup', 'waitForPopupClose',
515
521
  ]);
516
522
 
517
523
  /** Validate + normalise an untrusted flow (HTTP/MCP boundary). Throws on the
@@ -751,6 +757,8 @@ export const STEP_FIELDS: Record<string, string[]> = {
751
757
  conditional: ['testid', 'present', 'steps'],
752
758
  loop: ['data', 'steps', 'parallel'],
753
759
  openPopup: ['path'],
760
+ waitForPopup: [],
761
+ waitForPopupClose: [],
754
762
  expectRequest: ['url', 'match', 'method', 'status'],
755
763
  };
756
764
 
@@ -815,17 +823,22 @@ function validateAndNormalizeSteps(steps: unknown[], depth = 0, inParallel = fal
815
823
  // F009.125 — `in: "popup"` runs a step in the extension popup an earlier
816
824
  // openPopup opened. Refused where it cannot mean anything, the same way
817
825
  // `browser` is: the popup lives in the main browser only.
826
+ // F009.139 — `in: "opened"` runs a step in the window the page opened, taken
827
+ // over by an earlier waitForPopup. Same refusals as in:"popup".
818
828
  if ('in' in s) {
819
829
  const where = (s as { in?: unknown }).in;
820
- if (where !== 'popup') throw new Error(`step ${index} (${s.type}): in must be "popup", got ${JSON.stringify(where)}`);
821
- if (s.type === 'loop' || s.type === 'conditional' || s.type === 'openPopup') {
822
- throw new Error(`step ${index} (${s.type}): in:"popup" goes on the steps that act in the popup, not on a ${s.type}`);
830
+ if (where !== 'popup' && where !== 'opened') throw new Error(`step ${index} (${s.type}): in must be "popup" or "opened", got ${JSON.stringify(where)}`);
831
+ if (s.type === 'loop' || s.type === 'conditional' || s.type === 'openPopup' || s.type === 'waitForPopup' || s.type === 'waitForPopupClose') {
832
+ throw new Error(`step ${index} (${s.type}): in:"${where}" goes on the steps that act in that window, not on a ${s.type}`);
823
833
  }
824
834
  const b = (s as { browser?: unknown }).browser;
825
835
  if (b !== undefined && b !== 'main') {
826
- throw new Error(`step ${index} (${s.type}): in:"popup" and browser:${JSON.stringify(b)} cannot be combined — the extension is loaded in the main browser only`);
836
+ throw new Error(`step ${index} (${s.type}): in:"${where}" and browser:${JSON.stringify(b)} cannot be combined — it lives in the main browser only`);
827
837
  }
828
- if (inParallel) throw new Error(`step ${index} (${s.type}): in:"popup" is not allowed inside a parallel loop`);
838
+ if (inParallel) throw new Error(`step ${index} (${s.type}): in:"${where}" is not allowed inside a parallel loop`);
839
+ }
840
+ if ((s.type === 'waitForPopup' || s.type === 'waitForPopupClose') && (('browser' in s && s.browser !== 'main') || inParallel)) {
841
+ throw new Error(`step ${index} (${s.type}): follows a window of the main browser only`);
829
842
  }
830
843
  if (s.type === 'openPopup') {
831
844
  const pth = (s as { path?: unknown }).path;
@@ -1165,7 +1178,8 @@ function parseStepBlock(lines: IndentLine[], start: number, minIndent: number):
1165
1178
  // F009.122 — `- in <name>: <step>` runs the step in that named browser.
1166
1179
  const inM = body.match(/^in\s+([^\s:]+)\s*:\s*(.+)$/);
1167
1180
  if (inM) {
1168
- steps.push(Object.assign(parseManuscriptStep(inM[2]!), { browser: inM[1]! }));
1181
+ // F009.139 — `- in opened: <step>` is the window the page opened, not a browser.
1182
+ steps.push(Object.assign(parseManuscriptStep(inM[2]!), inM[1] === 'opened' ? { in: 'opened' as const } : { browser: inM[1]! }));
1169
1183
  } else {
1170
1184
  steps.push(parseManuscriptStep(body));
1171
1185
  }
@@ -1233,6 +1247,7 @@ export const MANUSCRIPT_VERBS = [
1233
1247
  'expectText', 'expectVisible', 'expectEditable', 'expectAbsent', 'expectTopmost',
1234
1248
  'fillSelector', 'clickSelector',
1235
1249
  'inspect', 'assert', 'screenshot', 'capture',
1250
+ 'waitForPopup', 'waitForPopupClose',
1236
1251
  ] as const;
1237
1252
 
1238
1253
  function parseManuscriptStep(s: string): FlowStep {
@@ -1412,7 +1427,13 @@ function parseManuscriptStep(s: string): FlowStep {
1412
1427
  };
1413
1428
  }
1414
1429
  case 'inspect': {
1415
- const [, frame] = splitFrame(s);
1430
+ const [head, frame] = splitFrame(s);
1431
+ // F009.138 — `inspect html [testid]` is the flow step's html form (F389.2):
1432
+ // the built screen's outerHTML, for cardmem_check_mockup_fidelity.
1433
+ if (/^inspect\s+html\b/i.test(head)) {
1434
+ const m = head.match(/\[([^\]]+)\]/);
1435
+ return { type: 'inspect', html: true, ...(m ? { testid: m[1]!.trim() } : {}), ...(frame ? { frame } : {}) };
1436
+ }
1416
1437
  return { type: 'inspect', ...(frame ? { frame } : {}) };
1417
1438
  }
1418
1439
  case 'assert':
@@ -1420,6 +1441,9 @@ function parseManuscriptStep(s: string): FlowStep {
1420
1441
  case 'screenshot':
1421
1442
  return { type: 'screenshot', label: s.replace(/^screenshot\s*/i, '').trim() || undefined };
1422
1443
  // F255.7 — `waitForBuild <sha>` or bare `waitForBuild` (any change from the live build).
1444
+ // F009.139 — `- waitForPopup` / `- waitForPopupClose`.
1445
+ case 'waitforpopup': return { type: 'waitForPopup' };
1446
+ case 'waitforpopupclose': return { type: 'waitForPopupClose' };
1423
1447
  case 'waitforbuild': {
1424
1448
  const sha = s.replace(/^waitforbuild\s*/i, '').trim();
1425
1449
  return { type: 'waitForBuild', ...(sha ? { sha } : {}) };
@@ -1632,6 +1656,8 @@ function describe(step: FlowStep): string {
1632
1656
  case 'conditional': return `${step.present ? 'if' : 'unless'} [${step.testid}]`;
1633
1657
  case 'loop': return 'loop';
1634
1658
  case 'openPopup': return `openPopup ${step.path ?? '(manifest popup)'}`;
1659
+ case 'waitForPopup': return 'waitForPopup';
1660
+ case 'waitForPopupClose': return 'waitForPopupClose';
1635
1661
  case 'expectRequest': return `expectRequest ${step.method ? `${step.method.toUpperCase()} ` : ''}${step.url}${step.status ? ` → ${step.status}` : ''}`;
1636
1662
  case 'waitForBuild': return `waitForBuild${step.sha ? ` ${step.sha.slice(0, 8)}` : ' (changed)'}`;
1637
1663
  // F255.4 — show the offset in the step label so a positioned click is
@@ -2626,6 +2652,9 @@ async function execStep(page: Page, step: FlowStep, base: string, vars: Record<s
2626
2652
  throw new Error('loop steps are flattened before execution');
2627
2653
  case 'openPopup':
2628
2654
  throw new Error('openPopup steps are handled by runSeq, never execStep');
2655
+ case 'waitForPopup':
2656
+ case 'waitForPopupClose':
2657
+ throw new Error(`${step.type} steps are handled by runSeq, never execStep`);
2629
2658
  case 'expectRequest':
2630
2659
  throw new Error('expectRequest steps are handled by runSeq, never execStep');
2631
2660
  }
@@ -2968,6 +2997,7 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
2968
2997
  const onBrowser = {
2969
2998
  ...(browserName !== 'main' ? { browser: browserName } : {}),
2970
2999
  ...((step as { in?: string }).in === 'popup' ? { in: 'popup' as const } : {}),
3000
+ ...((step as { in?: string }).in === 'opened' ? { in: 'opened' as const } : {}),
2971
3001
  };
2972
3002
  let sp: Page = page;
2973
3003
  if (i < from) {
@@ -2994,9 +3024,18 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
2994
3024
  // Keep the page under test the extension's active tab while the popup is driven.
2995
3025
  await extCtx.main.bringToFront();
2996
3026
  }
3027
+ if ((step as { in?: string }).in === 'opened') {
3028
+ if (!openedWin.page) throw new Error('in:"opened" before any waitForPopup step — take over the opened window first');
3029
+ if (openedWin.page.isClosed()) throw new Error('the opened window has closed — steps after waitForPopupClose run in the opener (drop in:"opened")');
3030
+ sp = openedWin.page;
3031
+ }
2997
3032
  const out =
2998
3033
  step.type === 'openPopup'
2999
3034
  ? await openPopup(step)
3035
+ : step.type === 'waitForPopup'
3036
+ ? await waitForPopup(step)
3037
+ : step.type === 'waitForPopupClose'
3038
+ ? await waitForPopupClose(step)
3000
3039
  : step.type === 'expectRequest'
3001
3040
  ? await expectRequest(step, vars)
3002
3041
  : await execStep(sp, step, base, vars, opts.critic ?? 'off', opts.localPath, sink, filledPasswords, notListening, opts.secretRoot, mailCtx);
@@ -3165,6 +3204,39 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
3165
3204
  extCtx.popup = popup;
3166
3205
  return null;
3167
3206
  };
3207
+ // F009.139 — every window a page of the run's main browser opens, in order, from
3208
+ // the first step on: a popup can open DURING the click, before any wait runs.
3209
+ const openedWin: { seen: Page[]; taken: number; page: Page | null; arrived: (() => void) | null } = { seen: [], taken: 0, page: null, arrived: null };
3210
+ const watchOpened = (context: BrowserContext): void => {
3211
+ context.on('page', (p) => {
3212
+ void p.opener().then((op) => {
3213
+ if (!op) return; // a page Lens itself opened (openPopup, a new tab) — not the page's doing
3214
+ openedWin.seen.push(p);
3215
+ openedWin.arrived?.();
3216
+ });
3217
+ });
3218
+ };
3219
+ const waitForPopup = async (step: Extract<FlowStep, { type: 'waitForPopup' }>): Promise<null> => {
3220
+ const timeout = step.timeout ?? 15_000;
3221
+ if (openedWin.seen.length <= openedWin.taken) {
3222
+ await new Promise<void>((resolve, reject) => {
3223
+ const t = setTimeout(() => { openedWin.arrived = null; reject(new Error(`no window was opened within ${timeout} ms`)); }, timeout);
3224
+ openedWin.arrived = () => { clearTimeout(t); openedWin.arrived = null; resolve(); };
3225
+ });
3226
+ }
3227
+ const p = openedWin.seen[openedWin.seen.length - 1]!;
3228
+ openedWin.taken = openedWin.seen.length;
3229
+ openedWin.page = p;
3230
+ collectFailures(p);
3231
+ await p.waitForLoadState('domcontentloaded', { timeout });
3232
+ return null;
3233
+ };
3234
+ const waitForPopupClose = async (step: Extract<FlowStep, { type: 'waitForPopupClose' }>): Promise<null> => {
3235
+ if (!openedWin.page) throw new Error('waitForPopupClose before any waitForPopup step');
3236
+ if (openedWin.page.isClosed()) return null;
3237
+ await openedWin.page.waitForEvent('close', { timeout: step.timeout ?? 15_000 });
3238
+ return null;
3239
+ };
3168
3240
  // F009.128 — files the run downloads. A download is awaited before the context
3169
3241
  // closes; otherwise the bytes die with the browser and the report says nothing.
3170
3242
  const downloads: FlowDownload[] = [];
@@ -3314,6 +3386,7 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
3314
3386
  collectFailures(page);
3315
3387
  watchRequests(context);
3316
3388
  watchDownloads(context);
3389
+ watchOpened(context);
3317
3390
  if (ext && flow.extension && prepared) {
3318
3391
  extCtx = { context, id: ext.id, main: page, popup: null };
3319
3392
  const mint = flow.extension.storage_local_mint;
package/src/mcp.ts CHANGED
@@ -405,7 +405,7 @@ export const TOOLS = [
405
405
  {
406
406
  name: 'lens_run_manuscript',
407
407
  description:
408
- `Compile a human-readable markdown manuscript (fmc-automation style: "# flow: name", "base: <url>", then one "- <verb> …" per line) into a flow and run it E2E. VERBS (${MANUSCRIPT_VERBS.length}, this is the complete list): ${MANUSCRIPT_VERBS.join(' / ')}. Shapes: "- goto /x" · "- fill [testid] = value" · a PASSWORD without plaintext: "- fill [testid] from file <path>" (absolute, or relative to local_path) or "- fill [testid] from env <NAME>" (F009.121 — password inputs only; the value never appears in the run, the report or a screenshot) · "- click [testid]" · "- press Enter" (global) or "- press Enter [testid]" (on a focused element) · "- expectText [testid] ~ text" · "- drag [source] -> [target]" or "- drag [node] by 120,-40" (a canvas offset; both move in 10 mousemoves so React Flow / d3-drag nodes really move — "steps N" to change, F009.123) · "- upload [input-testid] = <fixture>" · an input with NO testid, also inside an open shadow root: "- uploadSelector <css> = <fixture>[, <fixture>]" (F009.136; fixtures repo-relative to local_path or data:) · a CODE FROM MAIL: "- fill [testid] from mail to jam@jams.party subject ~ Jam match /\\b\\d{6}\\b/" (F009.137 — waits ≤60 s for a mail received after the step started, read through mail-mcp; only the test mailbox webhouse.developer@gmail.com / jam@jams.party; the mail is deleted after the run, report carries mail {consumed, cleaned}; the code never appears in the report) · "- assert: <js>" · "- screenshot label" · frame-aware "- fillSelector <css> = <value> @frame <iframe>" and "- clickSelector <css> @frame <iframe>" · "- waitForUrl <substring>". TWO BROWSERS AT ONCE (F009.122): prefix a step with "in <name>:" — "- in b: goto /" · "- in b: expectText [note] == Hej" — and it runs in its own browser (own cookies + storage), open alongside the default one for the rest of the run; steps without the prefix run in the default browser. Click modifiers: "- click [testid] via enter" (F255.1, for Angular/MDC buttons a mouse click does not activate) and "- click [testid] @ 50%,28%" (F255.4, aim at a point inside a canvas/3D surface). A field that already HOLDS a value may no longer render an input — a combobox typically swaps to its selected state, so "fill" times out there while "press ArrowDown" / "press Enter" still drive it (fd-sundhed, 2026-08-21). The authoring shortcut — write prose, not JSON.`,
408
+ `Compile a human-readable markdown manuscript (fmc-automation style: "# flow: name", "base: <url>", then one "- <verb> …" per line) into a flow and run it E2E. VERBS (${MANUSCRIPT_VERBS.length}, this is the complete list): ${MANUSCRIPT_VERBS.join(' / ')}. Shapes: "- goto /x" · "- fill [testid] = value" · a PASSWORD without plaintext: "- fill [testid] from file <path>" (absolute, or relative to local_path) or "- fill [testid] from env <NAME>" (F009.121 — password inputs only; the value never appears in the run, the report or a screenshot) · "- click [testid]" · "- press Enter" (global) or "- press Enter [testid]" (on a focused element) · "- expectText [testid] ~ text" · "- drag [source] -> [target]" or "- drag [node] by 120,-40" (a canvas offset; both move in 10 mousemoves so React Flow / d3-drag nodes really move — "steps N" to change, F009.123) · "- upload [input-testid] = <fixture>" · an input with NO testid, also inside an open shadow root: "- uploadSelector <css> = <fixture>[, <fixture>]" (F009.136; fixtures repo-relative to local_path or data:) · a CODE FROM MAIL: "- fill [testid] from mail to jam@jams.party subject ~ Jam match /\\b\\d{6}\\b/" (F009.137 — waits ≤60 s for a mail received after the step started, read through mail-mcp; only the test mailbox webhouse.developer@gmail.com / jam@jams.party; the mail is deleted after the run, report carries mail {consumed, cleaned}; the code never appears in the report) · the BUILT SCREEN'S HTML after the click path: "- inspect html [testid]" (or "- inspect html" for the whole body) — the step detail is that outerHTML, for cardmem_check_mockup_fidelity's built_html (F009.138) · a WINDOW THE PAGE OPENS (window.open / target=_blank — an OAuth dialog like «Continue with Facebook/Google»): "- waitForPopup" takes it over (also one opened by the click just before), "- in opened: <step>" acts in it, "- waitForPopupClose" waits for it to close; steps without the prefix stay in the opener (F009.139) · "- assert: <js>" · "- screenshot label" · frame-aware "- fillSelector <css> = <value> @frame <iframe>" and "- clickSelector <css> @frame <iframe>" · "- waitForUrl <substring>". TWO BROWSERS AT ONCE (F009.122): prefix a step with "in <name>:" — "- in b: goto /" · "- in b: expectText [note] == Hej" — and it runs in its own browser (own cookies + storage), open alongside the default one for the rest of the run; steps without the prefix run in the default browser. Click modifiers: "- click [testid] via enter" (F255.1, for Angular/MDC buttons a mouse click does not activate) and "- click [testid] @ 50%,28%" (F255.4, aim at a point inside a canvas/3D surface). A field that already HOLDS a value may no longer render an input — a combobox typically swaps to its selected state, so "fill" times out there while "press ArrowDown" / "press Enter" still drive it (fd-sundhed, 2026-08-21). The authoring shortcut — write prose, not JSON.`,
409
409
  inputSchema: {
410
410
  type: 'object',
411
411
  required: ['project', 'manuscript'],