@broberg/lens-daemon 0.1.6 → 0.1.8

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.6",
3
+ "version": "0.1.8",
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",
@@ -16,7 +16,7 @@
16
16
  "turndown": "^7.2.4",
17
17
  "zod": "^3.23.8",
18
18
  "@broberg/lens-svg": "0.1.0",
19
- "@broberg/lens-engine": "0.10.2"
19
+ "@broberg/lens-engine": "0.11.0"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@happy-dom/global-registrator": "^20.10.6",
package/src/flow-store.ts CHANGED
@@ -17,6 +17,8 @@ export interface StoredFlowStep {
17
17
  index: number;
18
18
  type: string;
19
19
  label: string;
20
+ /** F009.122 — the named browser the step ran in; absent = the default one. */
21
+ browser?: string;
20
22
  status: 'pass' | 'fail' | 'skip';
21
23
  testid: string | null;
22
24
  detail: string | null;
@@ -82,6 +84,8 @@ export async function recordFlowRun(
82
84
  index: s.index,
83
85
  type: s.type,
84
86
  label: s.label,
87
+ // F009.122 — third-place rule: a field only flow.ts knows reaches nobody.
88
+ ...(s.browser ? { browser: s.browser } : {}),
85
89
  status: s.status,
86
90
  testid: s.testid,
87
91
  detail: s.detail,
package/src/flow.ts CHANGED
@@ -233,6 +233,8 @@ export interface FlowStepResult {
233
233
  index: number;
234
234
  type: string;
235
235
  label: string;
236
+ /** F009.122 — the named browser this step ran in. Absent = the default one. */
237
+ browser?: string;
236
238
  status: 'pass' | 'fail' | 'skip';
237
239
  testid: string | null;
238
240
  detail: string | null;
@@ -449,7 +451,12 @@ function isValidTarget(t: unknown): boolean {
449
451
  * FlowStep union above; flow-step-fields.test.ts reads the union from source and
450
452
  * fails if the two drift. An unknown field is REFUSED: `inspect` with a guessed
451
453
  * `selector` used to run a whole-page dump in silence (cardmem, flow b62ffca3). */
452
- const SHARED_STEP_FIELDS = ['type', 'label', 'timeout', 'timeout_ms'];
454
+ const SHARED_STEP_FIELDS = ['type', 'label', 'timeout', 'timeout_ms', 'browser'];
455
+
456
+ /** F009.122 — a step may name the browser it runs in. Each name is its own browser
457
+ * context (own cookies + storage), opened on first use and kept open for the rest
458
+ * of the run, so two people can be driven at the same time. `main` is the default. */
459
+ const BROWSER_NAME = /^[a-z][a-z0-9_-]{0,23}$/;
453
460
  const TARGETED = ['testid', 'target'];
454
461
  export const STEP_FIELDS: Record<string, string[]> = {
455
462
  goto: ['path'],
@@ -484,7 +491,7 @@ export const STEP_FIELDS: Record<string, string[]> = {
484
491
  loop: ['data', 'steps', 'parallel'],
485
492
  };
486
493
 
487
- function validateAndNormalizeSteps(steps: unknown[], depth = 0): void {
494
+ function validateAndNormalizeSteps(steps: unknown[], depth = 0, inParallel = false): void {
488
495
  if (depth > 3) throw new Error('flow loops nested too deep (max 3)');
489
496
  for (const [index, raw] of steps.entries()) {
490
497
  const s = raw as FlowStep & Record<string, unknown>;
@@ -518,13 +525,29 @@ function validateAndNormalizeSteps(steps: unknown[], depth = 0): void {
518
525
  if (s.type === 'loop') {
519
526
  if (!Array.isArray((s as { data?: unknown }).data)) throw new Error('loop step requires data[]');
520
527
  if (!Array.isArray((s as { steps?: unknown }).steps)) throw new Error('loop step requires steps[]');
521
- validateAndNormalizeSteps((s as { steps: unknown[] }).steps, depth + 1);
528
+ const par = (s as { parallel?: unknown }).parallel;
529
+ validateAndNormalizeSteps((s as { steps: unknown[] }).steps, depth + 1, inParallel || (typeof par === 'number' && par > 1));
522
530
  }
523
531
  // F213.3 — control-flow + composite verbs.
524
532
  if (s.type === 'conditional') {
525
533
  if (typeof (s as { present?: unknown }).present !== 'boolean') throw new Error('conditional step requires a boolean present');
526
534
  if (!Array.isArray((s as { steps?: unknown }).steps)) throw new Error('conditional step requires steps[]');
527
- validateAndNormalizeSteps((s as { steps: unknown[] }).steps, depth + 1);
535
+ validateAndNormalizeSteps((s as { steps: unknown[] }).steps, depth + 1, inParallel);
536
+ }
537
+ // F009.122 — the browser a step runs in. Refused where it cannot mean anything:
538
+ // a loop/conditional is not run in a browser (its children are), and a parallel
539
+ // loop iteration already owns a context of its own.
540
+ if ('browser' in s) {
541
+ const b = (s as { browser?: unknown }).browser;
542
+ if (typeof b !== 'string' || !BROWSER_NAME.test(b)) {
543
+ throw new Error(`step ${index} (${s.type}): browser must be a lowercase name like "anna" or "b" (${BROWSER_NAME}), got ${JSON.stringify(b)}`);
544
+ }
545
+ if (s.type === 'loop' || s.type === 'conditional') {
546
+ throw new Error(`step ${index} (${s.type}): browser goes on the steps inside a ${s.type}, not on the ${s.type} itself`);
547
+ }
548
+ if (inParallel) {
549
+ throw new Error(`step ${index} (${s.type}): browser is not allowed inside a parallel loop — each iteration already runs in its own browser`);
550
+ }
528
551
  }
529
552
  if (s.type === 'autocomplete') {
530
553
  if (typeof (s as { value?: unknown }).value !== 'string') throw new Error('autocomplete step requires a string value');
@@ -815,7 +838,13 @@ function parseStepBlock(lines: IndentLine[], start: number, minIndent: number):
815
838
  i = nextJ;
816
839
  continue;
817
840
  }
818
- steps.push(parseManuscriptStep(body));
841
+ // F009.122 — `- in <name>: <step>` runs the step in that named browser.
842
+ const inM = body.match(/^in\s+([^\s:]+)\s*:\s*(.+)$/);
843
+ if (inM) {
844
+ steps.push(Object.assign(parseManuscriptStep(inM[2]!), { browser: inM[1]! }));
845
+ } else {
846
+ steps.push(parseManuscriptStep(body));
847
+ }
819
848
  i++;
820
849
  }
821
850
  return [steps, i];
@@ -2379,12 +2408,60 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
2379
2408
  skipFill(s.steps, idx, vars, detail);
2380
2409
  idx += flattenCount(s.steps);
2381
2410
  } else {
2382
- record({ index: idx, type: s.type, label: s.label ?? describe(s), status: 'skip', testid: testidOf(s), detail, screenshot: null });
2411
+ const b = (s as { browser?: string }).browser;
2412
+ record({ index: idx, type: s.type, label: s.label ?? describe(s), ...(b ? { browser: b } : {}), status: 'skip', testid: testidOf(s), detail, screenshot: null });
2383
2413
  idx += 1;
2384
2414
  }
2385
2415
  }
2386
2416
  };
2387
2417
 
2418
+ // ── F009.122 · named browsers ────────────────────────────────────────────
2419
+ // Each name gets its own context through the SAME withPage the default browser
2420
+ // uses (same auth, storage seed, viewport, engine), held open until the run ends.
2421
+ // The steps stay sequential; the browsers are concurrent — that is what lets
2422
+ // «A types, then B sees it» be measured with B's own timeout.
2423
+ const browsers = new Map<string, { page: Promise<Page>; release: () => void; closed: Promise<unknown> }>();
2424
+ const pageFor = (name: string, main: Page): Promise<Page> => {
2425
+ if (name === 'main') return Promise.resolve(main);
2426
+ const open = browsers.get(name);
2427
+ if (open) return open.page;
2428
+ let release!: () => void;
2429
+ const held = new Promise<void>((r) => (release = r));
2430
+ let resolvePage!: (p: Page) => void;
2431
+ let rejectPage!: (e: unknown) => void;
2432
+ const pagePromise = new Promise<Page>((res, rej) => {
2433
+ resolvePage = res;
2434
+ rejectPage = rej;
2435
+ });
2436
+ const closed = withPage(
2437
+ { prepareContext: prepare, seedStorage, viewport: opts.viewport, engine: opts.engine, locale: opts.locale, timezone: opts.timezone, reducedMotion: opts.reducedMotion },
2438
+ async (p) => {
2439
+ collectFailures(p);
2440
+ resolvePage(p);
2441
+ await held;
2442
+ },
2443
+ ).catch((err) => rejectPage(err));
2444
+ browsers.set(name, { page: pagePromise, release, closed });
2445
+ return pagePromise;
2446
+ };
2447
+ const closeBrowsers = async () => {
2448
+ for (const b of browsers.values()) b.release();
2449
+ await Promise.all([...browsers.values()].map((b) => b.closed));
2450
+ browsers.clear();
2451
+ };
2452
+ // Where each browser's page landed is that browser's own fact. Swapped on every
2453
+ // change of browser, so a goto in B can neither move A's ejection baseline nor
2454
+ // reset A's navigation clock.
2455
+ let currentBrowser = 'main';
2456
+ const navStates = new Map<string, { urlAtLoad: string | null; navActionPending: boolean; onWrongPage: string | null; lastNavAt: number }>();
2457
+ const switchBrowser = (name: string) => {
2458
+ if (name === currentBrowser) return;
2459
+ navStates.set(currentBrowser, { urlAtLoad, navActionPending, onWrongPage, lastNavAt });
2460
+ const next = navStates.get(name) ?? { urlAtLoad: null, navActionPending: false, onWrongPage: null, lastNavAt: Date.now() };
2461
+ ({ urlAtLoad, navActionPending, onWrongPage, lastNavAt } = next);
2462
+ currentBrowser = name;
2463
+ };
2464
+
2388
2465
  // Run a step-list on `page` starting at `startIndex`. Returns whether a
2389
2466
  // non-loop step failed (so the caller can stop THIS sequence).
2390
2467
  async function runSeq(page: Page, steps: FlowStep[], vars: Record<string, unknown>, startIndex: number): Promise<{ failedHere: boolean }> {
@@ -2459,6 +2536,11 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
2459
2536
  const i = idx;
2460
2537
  idx += 1;
2461
2538
  const label = step.label ?? describe(step);
2539
+ // F009.122 — which browser this step runs in. `sp` is the page every line
2540
+ // below acts on; the loop's own `page` is the default browser.
2541
+ const browserName = (step as { browser?: string }).browser ?? 'main';
2542
+ const onBrowser = browserName !== 'main' ? { browser: browserName } : {};
2543
+ let sp: Page = page;
2462
2544
  if (i < from) {
2463
2545
  record({ index: i, type: step.type, label, status: 'skip', testid: testidOf(step), detail: 'resumed past', screenshot: null });
2464
2546
  continue;
@@ -2474,7 +2556,9 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
2474
2556
  // matter most, and a sink scoped to the try is unreachable from the catch.
2475
2557
  const sink: CriticSink = { critic: null };
2476
2558
  try {
2477
- const out = await execStep(page, step, base, vars, opts.critic ?? 'off', opts.localPath, sink, filledPasswords, notListening, opts.secretRoot);
2559
+ switchBrowser(browserName);
2560
+ sp = await pageFor(browserName, page);
2561
+ const out = await execStep(sp, step, base, vars, opts.critic ?? 'off', opts.localPath, sink, filledPasswords, notListening, opts.secretRoot);
2478
2562
  const obj = out !== null && !Buffer.isBuffer(out) && typeof out === 'object' ? out : null;
2479
2563
  const inspect = obj && '__inspect' in obj ? (obj as { __inspect: string }).__inspect : null;
2480
2564
  const via = obj && '__via' in obj ? (obj as { __via: string }).__via : null;
@@ -2496,7 +2580,7 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
2496
2580
  // would call that an ejection on every single run.
2497
2581
  if (formsAVerdict(step.type)) formedAVerdict = true;
2498
2582
  if (NAVIGATES_BY_DESIGN.has(step.type)) {
2499
- urlAtLoad = safeUrl(page) ?? urlAtLoad;
2583
+ urlAtLoad = safeUrl(sp) ?? urlAtLoad;
2500
2584
  // A goto is already settled — Playwright waited for it. Any OTHER
2501
2585
  // navigating action may commit after it returns, so it leaves a note
2502
2586
  // saying "one URL change after this is mine". See classifyUrlChange.
@@ -2506,7 +2590,7 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
2506
2590
  // under it, that opinion is void — and `page.url()` is a synchronous
2507
2591
  // read of state Playwright already holds, so this costs nothing and
2508
2592
  // adds no polling (the tax AC4 forbids).
2509
- const verdict = classifyUrlChange({ loaded: urlAtLoad, current: safeUrl(page) ?? urlAtLoad, navActionPending });
2593
+ const verdict = classifyUrlChange({ loaded: urlAtLoad, current: safeUrl(sp) ?? urlAtLoad, navActionPending });
2510
2594
  if (verdict.kind === 'flow-navigation') {
2511
2595
  urlAtLoad = verdict.rebaseTo;
2512
2596
  navActionPending = false;
@@ -2525,6 +2609,7 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
2525
2609
  index: i,
2526
2610
  type: step.type,
2527
2611
  label,
2612
+ ...onBrowser,
2528
2613
  status: 'pass',
2529
2614
  ...navOffset,
2530
2615
  ms_step_duration: Date.now() - stepStartedAt,
@@ -2547,7 +2632,7 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
2547
2632
  const msg = err instanceof Error ? err.message : String(err);
2548
2633
  let shot: Buffer | null = null;
2549
2634
  try {
2550
- shot = (await takeShot(page, 'viewport', null, 5000)).screenshot;
2635
+ shot = (await takeShot(sp, 'viewport', null, 5000)).screenshot;
2551
2636
  } catch {
2552
2637
  // page may be in a bad state — a missing failure shot is acceptable
2553
2638
  }
@@ -2581,6 +2666,7 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
2581
2666
  index: i,
2582
2667
  type: step.type,
2583
2668
  label,
2669
+ ...onBrowser,
2584
2670
  // Still 'fail' — a broken probe is never less than a failure.
2585
2671
  status: 'fail',
2586
2672
  // F213.11 — on the FAILING step above all: a lost write is exactly where
@@ -2688,7 +2774,15 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
2688
2774
  // cookie read failed — leave authReach null rather than invent a verdict
2689
2775
  }
2690
2776
  }
2691
- const r = await runSeq(page, flow.steps, {}, 0);
2777
+ let r: { failedHere: boolean };
2778
+ try {
2779
+ r = await runSeq(page, flow.steps, {}, 0);
2780
+ } finally {
2781
+ // F009.122 — the named browsers close with the run, before the default one,
2782
+ // and the end-of-run checks below are about the DEFAULT browser's page.
2783
+ await closeBrowsers();
2784
+ switchBrowser('main');
2785
+ }
2692
2786
  if (r.failedHere) aborted = true; // a TOP-LEVEL step failed → flow aborts
2693
2787
  // ── F213.10 · the window, and why it has to exist at all ──────────────
2694
2788
  //
package/src/mcp.ts CHANGED
@@ -321,7 +321,7 @@ export const TOOLS = [
321
321
  // A hand-written list in a tool description is the only thing most callers
322
322
  // ever read, and ours had drifted to 9 of 26: three repos reported an
323
323
  // existing verb as missing because of it.
324
- `Run an E2E manuscript flow: drive a site step-by-step in one authed page. Steps (${[...STEP_TYPES].length}): ${[...STEP_TYPES].join(' / ')}. testid-anchored steps use [data-testid]; a fill can take valueFrom:{path}|{env} instead of value to type a password without it ever appearing in the run (F009.121, password inputs only); fillSelector/clickSelector use a raw CSS selector + optional cross-origin frame chain to reach a 3rd-party iframe a testid can\'t (e.g. Stripe PCI card fields), and waitForUrl asserts the redirect back to our origin. Stops + pins a screenshot on the first failing step. Returns a per-step report with screenshot_url per step. This is full End-to-End — it can create users, submit forms, complete a Stripe test payment, etc. (set mutates:true for write flows). Scoped to the project slug.`,
324
+ `Run an E2E manuscript flow: drive a site step-by-step in one authed page — or several: any step may carry browser:"<name>" to run in its own browser (own cookies + storage), opened on first use and kept open beside the default one, so two users can be driven at once (F009.122). Steps (${[...STEP_TYPES].length}): ${[...STEP_TYPES].join(' / ')}. testid-anchored steps use [data-testid]; a fill can take valueFrom:{path}|{env} instead of value to type a password without it ever appearing in the run (F009.121, password inputs only); fillSelector/clickSelector use a raw CSS selector + optional cross-origin frame chain to reach a 3rd-party iframe a testid can\'t (e.g. Stripe PCI card fields), and waitForUrl asserts the redirect back to our origin. Stops + pins a screenshot on the first failing step. Returns a per-step report with screenshot_url per step. This is full End-to-End — it can create users, submit forms, complete a Stripe test payment, etc. (set mutates:true for write flows). Scoped to the project slug.`,
325
325
  inputSchema: {
326
326
  type: 'object',
327
327
  required: ['project', 'flow'],
@@ -373,7 +373,7 @@ export const TOOLS = [
373
373
  {
374
374
  name: 'lens_run_manuscript',
375
375
  description:
376
- `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]" · "- upload [input-testid] = <fixture>" · "- assert: <js>" · "- screenshot label" · frame-aware "- fillSelector <css> = <value> @frame <iframe>" and "- clickSelector <css> @frame <iframe>" · "- waitForUrl <substring>". 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.`,
376
+ `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]" · "- upload [input-testid] = <fixture>" · "- 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.`,
377
377
  inputSchema: {
378
378
  type: 'object',
379
379
  required: ['project', 'manuscript'],
package/src/service.ts CHANGED
@@ -682,6 +682,8 @@ export function flowRunView(row: LensFlowRunRow, port: number) {
682
682
  type: s.type,
683
683
  label: s.label,
684
684
  status: s.status,
685
+ // F009.122 — which named browser ran the step. Absent = the default one.
686
+ ...(s.browser ? { browser: s.browser } : {}),
685
687
  testid: s.testid,
686
688
  detail: s.detail,
687
689
  ...(s.fault ? { fault: s.fault } : {}),