@broberg/lens-daemon 0.1.8 → 0.1.10

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.8",
3
+ "version": "0.1.10",
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.11.0"
18
+ "@broberg/lens-engine": "0.12.0",
19
+ "@broberg/lens-svg": "0.1.0"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@happy-dom/global-registrator": "^20.10.6",
package/src/flow.ts CHANGED
@@ -50,6 +50,9 @@ export type ActivateMode = 'auto' | 'click' | 'enter';
50
50
  * env var. The value is read at run time and never echoed. */
51
51
  export type ValueFrom = { path: string } | { env: string };
52
52
 
53
+ /** F009.123 — a drag by an offset, in css pixels from the source's centre. */
54
+ export type DragOffset = { x: number; y: number };
55
+
53
56
  export type FlowStep =
54
57
  // F074.51 — `timeout` was absent here while every other verb carried it, so a
55
58
  // goto's 30s was unoverridable. Found by the typecheck of this card's own
@@ -127,7 +130,11 @@ export type FlowStep =
127
130
  // for DnD features a synthetic-event assert can't cleanly drive (lens-gap 019f2438).
128
131
  | { type: 'clear'; testid?: string; target?: Target; label?: string; timeout?: number }
129
132
  | { type: 'press'; key: string; testid?: string; label?: string; timeout?: number }
130
- | { type: 'drag'; testid: string; toTestid: string; label?: string; timeout?: number }
133
+ // F009.123 — `steps` mousemoves between press and release (default DRAG_STEPS): a
134
+ // d3-drag / React Flow node STARTS dragging on the first move and only moves on the
135
+ // next, so a one-hop drag passed while the node stayed put. `by` drags by an offset
136
+ // when the target is a canvas position, not an element. Exactly one of toTestid / by.
137
+ | { type: 'drag'; testid: string; toTestid?: string; by?: DragOffset; steps?: number; label?: string; timeout?: number }
131
138
  // F213.6 — uploadFile: set a fixture file on an input[type=file] (Playwright
132
139
  // setInputFiles), INCLUDING a hidden input behind a custom "add photo" button.
133
140
  // `path` is a repo-relative fixture path (sandboxed to local_path) or a data:
@@ -457,6 +464,10 @@ const SHARED_STEP_FIELDS = ['type', 'label', 'timeout', 'timeout_ms', 'browser']
457
464
  * context (own cookies + storage), opened on first use and kept open for the rest
458
465
  * of the run, so two people can be driven at the same time. `main` is the default. */
459
466
  const BROWSER_NAME = /^[a-z][a-z0-9_-]{0,23}$/;
467
+
468
+ /** F009.123 — mousemoves per drag. jam measured 10 moving a React Flow node where 1
469
+ * did not; more than one is what matters, ten keeps the travel smooth. */
470
+ const DRAG_STEPS = 10;
460
471
  const TARGETED = ['testid', 'target'];
461
472
  export const STEP_FIELDS: Record<string, string[]> = {
462
473
  goto: ['path'],
@@ -483,7 +494,7 @@ export const STEP_FIELDS: Record<string, string[]> = {
483
494
  screenshot: [],
484
495
  clear: TARGETED,
485
496
  press: ['key', 'testid'],
486
- drag: ['testid', 'toTestid'],
497
+ drag: ['testid', 'toTestid', 'by', 'steps'],
487
498
  uploadFile: ['testid', 'path'],
488
499
  autocomplete: ['testid', 'value', 'option', 'pickText', 'pickIndex'],
489
500
  capture: ['testid', 'as', 'from'],
@@ -618,8 +629,20 @@ function validateAndNormalizeSteps(steps: unknown[], depth = 0, inParallel = fal
618
629
  }
619
630
  }
620
631
  // F213.2 — drag needs a target testid; press needs a key (its testid is optional).
621
- if (s.type === 'drag' && typeof (s as { toTestid?: unknown }).toTestid !== 'string') {
622
- throw new Error('drag step requires a string toTestid');
632
+ if (s.type === 'drag') {
633
+ const d = s as { toTestid?: unknown; by?: unknown; steps?: unknown };
634
+ const hasTo = d.toTestid !== undefined;
635
+ const hasBy = d.by !== undefined;
636
+ if (hasTo === hasBy) throw new Error('drag step requires exactly one of toTestid (drag onto an element) or by {x,y} (drag by an offset)');
637
+ if (hasTo && typeof d.toTestid !== 'string') throw new Error('drag step requires a string toTestid');
638
+ if (hasBy) {
639
+ const b = d.by as { x?: unknown; y?: unknown } | null;
640
+ const keys = b && typeof b === 'object' && !Array.isArray(b) ? Object.keys(b).sort().join(',') : '';
641
+ if (keys !== 'x,y' || !Number.isFinite(b!.x) || !Number.isFinite(b!.y)) throw new Error('drag by must be {x: number, y: number} in css pixels');
642
+ }
643
+ if (d.steps !== undefined && !(Number.isInteger(d.steps) && (d.steps as number) >= 1 && (d.steps as number) <= 100)) {
644
+ throw new Error('drag steps must be a whole number from 1 to 100');
645
+ }
623
646
  }
624
647
  if (s.type === 'press' && typeof (s as { key?: unknown }).key !== 'string') {
625
648
  throw new Error('press step requires a string key');
@@ -964,9 +987,18 @@ function parseManuscriptStep(s: string): FlowStep {
964
987
  }
965
988
  case 'drag': {
966
989
  // "drag [source] -> [target]" (any two [testid]s in order).
990
+ // F009.123 — "drag [source] by dx,dy" for a canvas position, and an optional
991
+ // trailing "steps N" on either form.
967
992
  const ids = [...s.matchAll(/\[([^\]]+)\]/g)].map((m) => m[1]!.trim());
968
- if (ids.length < 2) throw new Error(`drag step "${s}" needs [source] and [target] testids`);
969
- return { type: 'drag', testid: ids[0]!, toTestid: ids[1]! };
993
+ const stepsM = s.match(/\bsteps\s+(\d+)\s*$/i);
994
+ const stepsPart = stepsM ? { steps: Number(stepsM[1]) } : {};
995
+ const byM = s.match(/\]\s+by\s+(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)/i);
996
+ if (byM) {
997
+ if (ids.length !== 1) throw new Error(`drag step "${s}" takes [source] by dx,dy OR [source] -> [target], not both`);
998
+ return { type: 'drag', testid: ids[0]!, by: { x: Number(byM[1]), y: Number(byM[2]) }, ...stepsPart };
999
+ }
1000
+ if (ids.length < 2) throw new Error(`drag step "${s}" needs [source] and [target] testids, or [source] by dx,dy`);
1001
+ return { type: 'drag', testid: ids[0]!, toTestid: ids[1]!, ...stepsPart };
970
1002
  }
971
1003
  case 'upload': {
972
1004
  // "upload [input-testid] = <fixture>" — a repo-relative fixture path
@@ -1252,7 +1284,7 @@ function describe(step: FlowStep): string {
1252
1284
  case 'clickSelector': return `clickSelector ${step.selector}${step.at ? ` @ ${step.at}` : ''}`;
1253
1285
  case 'waitForUrl': return `waitForUrl ${step.url}`;
1254
1286
  case 'inspect': return step.html ? `inspect html${step.testid ? ` [${step.testid}]` : ''}` : step.frame ? 'inspect @frame' : 'inspect';
1255
- case 'drag': return `drag ${step.testid} → ${step.toTestid}`;
1287
+ case 'drag': return `drag ${step.testid} ${step.by ? `by ${step.by.x},${step.by.y}` : `→ ${step.toTestid}`}`;
1256
1288
  case 'uploadFile': return `upload ${Array.isArray(step.path) ? `${step.path.length} files` : (step.path.startsWith('data:') ? 'data:…' : step.path)} → ${step.testid}`;
1257
1289
  case 'press': return `press ${step.key}${step.testid ? ` @${step.testid}` : ''}`;
1258
1290
  case 'autocomplete': return `autocomplete ${step.testid} → ${step.pickText !== undefined ? `"${step.pickText}"` : `#${step.pickIndex}`}`;
@@ -2100,14 +2132,28 @@ async function execStep(page: Page, step: FlowStep, base: string, vars: Record<s
2100
2132
  await page.keyboard.press(step.key);
2101
2133
  }
2102
2134
  return null;
2103
- case 'drag':
2135
+ case 'drag': {
2104
2136
  // A real HTML5 drag-and-drop (dragstart→dragover→drop) via Playwright's
2105
2137
  // dragTo — drives the app's actual DnD handlers, unlike synthetic events.
2106
- await page
2107
- .locator(sel(step.testid))
2108
- .first()
2109
- .dragTo(page.locator(sel(step.toTestid)).first(), { timeout: stepTimeout(step) });
2138
+ // F009.123 — in DRAG_STEPS mousemoves, not one: a d3-drag / React Flow node
2139
+ // only starts dragging on the first move, so one hop left it in place, green.
2140
+ const steps = step.steps ?? DRAG_STEPS;
2141
+ const src = page.locator(sel(step.testid)).first();
2142
+ if (step.by) {
2143
+ await src.scrollIntoViewIfNeeded({ timeout: stepTimeout(step) });
2144
+ const box = await src.boundingBox({ timeout: stepTimeout(step) });
2145
+ if (!box) throw new Error(`drag: [data-testid="${step.testid}"] has no box to drag from`);
2146
+ const x = box.x + box.width / 2;
2147
+ const y = box.y + box.height / 2;
2148
+ await page.mouse.move(x, y);
2149
+ await page.mouse.down();
2150
+ await page.mouse.move(x + step.by.x, y + step.by.y, { steps });
2151
+ await page.mouse.up();
2152
+ return null;
2153
+ }
2154
+ await src.dragTo(page.locator(sel(step.toTestid!)).first(), { timeout: stepTimeout(step), steps });
2110
2155
  return null;
2156
+ }
2111
2157
  case 'uploadFile': {
2112
2158
  // F213.6 — set the fixture(s) on the target input[type=file] (works on a
2113
2159
  // HIDDEN input behind a custom button). setInputFiles takes either a list of
package/src/frames.ts CHANGED
@@ -4,7 +4,9 @@
4
4
  // moment the probe already named, because a 600-frame contact sheet is a disk bill
5
5
  // and a token bill that answers nothing. The default is sparse-everywhere plus
6
6
  // dense-where-it-matters.
7
- import { spawn } from 'node:child_process';
7
+ // F007.14 — ffmpeg is run and probed through the engine's ONE implementation, the same
8
+ // one the fleet's video slicer (sliceVideo) uses; this file keeps only the fps cut.
9
+ import { ffmpegAvailable, runFfmpeg } from '@broberg/lens-engine';
8
10
  import { mkdir, readdir, rm, stat } from 'node:fs/promises';
9
11
  import { join } from 'node:path';
10
12
 
@@ -31,14 +33,7 @@ export interface CutResult {
31
33
  unavailable?: string;
32
34
  }
33
35
 
34
- /** Is ffmpeg reachable at all? Answered once per call, cheaply. */
35
- export async function ffmpegAvailable(bin = 'ffmpeg'): Promise<boolean> {
36
- return new Promise((resolve) => {
37
- const p = spawn(bin, ['-version'], { stdio: 'ignore' });
38
- p.on('error', () => resolve(false));
39
- p.on('close', (code) => resolve(code === 0));
40
- });
41
- }
36
+ export { ffmpegAvailable };
42
37
 
43
38
  /**
44
39
  * Cut `video` into PNGs in `outDir`.
@@ -69,11 +64,7 @@ export async function cutFrames(
69
64
  // flag was doing nothing even on the versions that still accepted it.
70
65
  args.push('-vf', `fps=${fps}`, join(outDir, 'f-%05d.png'));
71
66
 
72
- const code = await new Promise<number>((resolve) => {
73
- const p = spawn(bin, args, { stdio: 'ignore' });
74
- p.on('error', () => resolve(-1));
75
- p.on('close', (c) => resolve(c ?? -1));
76
- });
67
+ const code = await runFfmpeg(bin, args).then((r) => r.code, () => -1);
77
68
  if (code !== 0) {
78
69
  await rm(outDir, { recursive: true, force: true }).catch(() => {});
79
70
  return { frames: [], unavailable: `ffmpeg exited ${code} — video kept, no stills` };
package/src/mcp.ts CHANGED
@@ -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>". 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.`,
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]" 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>" · "- 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'],