@broberg/lens-daemon 0.1.42 → 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.42",
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 {
@@ -1426,6 +1441,9 @@ function parseManuscriptStep(s: string): FlowStep {
1426
1441
  case 'screenshot':
1427
1442
  return { type: 'screenshot', label: s.replace(/^screenshot\s*/i, '').trim() || undefined };
1428
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' };
1429
1447
  case 'waitforbuild': {
1430
1448
  const sha = s.replace(/^waitforbuild\s*/i, '').trim();
1431
1449
  return { type: 'waitForBuild', ...(sha ? { sha } : {}) };
@@ -1638,6 +1656,8 @@ function describe(step: FlowStep): string {
1638
1656
  case 'conditional': return `${step.present ? 'if' : 'unless'} [${step.testid}]`;
1639
1657
  case 'loop': return 'loop';
1640
1658
  case 'openPopup': return `openPopup ${step.path ?? '(manifest popup)'}`;
1659
+ case 'waitForPopup': return 'waitForPopup';
1660
+ case 'waitForPopupClose': return 'waitForPopupClose';
1641
1661
  case 'expectRequest': return `expectRequest ${step.method ? `${step.method.toUpperCase()} ` : ''}${step.url}${step.status ? ` → ${step.status}` : ''}`;
1642
1662
  case 'waitForBuild': return `waitForBuild${step.sha ? ` ${step.sha.slice(0, 8)}` : ' (changed)'}`;
1643
1663
  // F255.4 — show the offset in the step label so a positioned click is
@@ -2632,6 +2652,9 @@ async function execStep(page: Page, step: FlowStep, base: string, vars: Record<s
2632
2652
  throw new Error('loop steps are flattened before execution');
2633
2653
  case 'openPopup':
2634
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`);
2635
2658
  case 'expectRequest':
2636
2659
  throw new Error('expectRequest steps are handled by runSeq, never execStep');
2637
2660
  }
@@ -2974,6 +2997,7 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
2974
2997
  const onBrowser = {
2975
2998
  ...(browserName !== 'main' ? { browser: browserName } : {}),
2976
2999
  ...((step as { in?: string }).in === 'popup' ? { in: 'popup' as const } : {}),
3000
+ ...((step as { in?: string }).in === 'opened' ? { in: 'opened' as const } : {}),
2977
3001
  };
2978
3002
  let sp: Page = page;
2979
3003
  if (i < from) {
@@ -3000,9 +3024,18 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
3000
3024
  // Keep the page under test the extension's active tab while the popup is driven.
3001
3025
  await extCtx.main.bringToFront();
3002
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
+ }
3003
3032
  const out =
3004
3033
  step.type === 'openPopup'
3005
3034
  ? await openPopup(step)
3035
+ : step.type === 'waitForPopup'
3036
+ ? await waitForPopup(step)
3037
+ : step.type === 'waitForPopupClose'
3038
+ ? await waitForPopupClose(step)
3006
3039
  : step.type === 'expectRequest'
3007
3040
  ? await expectRequest(step, vars)
3008
3041
  : await execStep(sp, step, base, vars, opts.critic ?? 'off', opts.localPath, sink, filledPasswords, notListening, opts.secretRoot, mailCtx);
@@ -3171,6 +3204,39 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
3171
3204
  extCtx.popup = popup;
3172
3205
  return null;
3173
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
+ };
3174
3240
  // F009.128 — files the run downloads. A download is awaited before the context
3175
3241
  // closes; otherwise the bytes die with the browser and the report says nothing.
3176
3242
  const downloads: FlowDownload[] = [];
@@ -3320,6 +3386,7 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
3320
3386
  collectFailures(page);
3321
3387
  watchRequests(context);
3322
3388
  watchDownloads(context);
3389
+ watchOpened(context);
3323
3390
  if (ext && flow.extension && prepared) {
3324
3391
  extCtx = { context, id: ext.id, main: page, popup: null };
3325
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) · 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) · "- 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'],