@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 +3 -3
- package/src/flow-store.ts +1 -1
- package/src/flow.ts +81 -8
- 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.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-
|
|
19
|
-
"@broberg/lens-
|
|
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:"
|
|
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:"
|
|
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:"
|
|
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
|
-
|
|
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'],
|