@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 +3 -3
- package/src/flow.ts +58 -12
- package/src/frames.ts +5 -14
- package/src/mcp.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@broberg/lens-daemon",
|
|
3
|
-
"version": "0.1.
|
|
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-
|
|
19
|
-
"@broberg/lens-
|
|
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
|
-
|
|
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'
|
|
622
|
-
|
|
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
|
-
|
|
969
|
-
|
|
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}
|
|
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
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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'],
|