@broberg/lens-daemon 0.1.5 → 0.1.7
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 +2 -2
- package/src/flow.ts +113 -13
- package/src/mcp.ts +2 -2
- package/src/secret-fill.ts +15 -13
- package/src/service.ts +16 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@broberg/lens-daemon",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
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,7 +15,7 @@
|
|
|
15
15
|
"sharp": "^0.33.5",
|
|
16
16
|
"turndown": "^7.2.4",
|
|
17
17
|
"zod": "^3.23.8",
|
|
18
|
-
"@broberg/lens-engine": "0.
|
|
18
|
+
"@broberg/lens-engine": "0.11.0",
|
|
19
19
|
"@broberg/lens-svg": "0.1.0"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
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
|
-
|
|
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
|
-
|
|
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];
|
|
@@ -1667,7 +1696,7 @@ async function sampleMotion(
|
|
|
1667
1696
|
const MOTION_SAMPLE_MS = 250;
|
|
1668
1697
|
|
|
1669
1698
|
|
|
1670
|
-
async function execStep(page: Page, step: FlowStep, base: string, vars: Record<string, unknown>, critic: CriticMode, localPath?: string, sink?: CriticSink, pwSink?: FilledPassword[], readySink?: NotListeningFinding[]): Promise<Buffer | null | { __inspect: string } | { __via: string; __build?: string; __redirect?: string }> {
|
|
1699
|
+
async function execStep(page: Page, step: FlowStep, base: string, vars: Record<string, unknown>, critic: CriticMode, localPath?: string, sink?: CriticSink, pwSink?: FilledPassword[], readySink?: NotListeningFinding[], secretRoot?: string): Promise<Buffer | null | { __inspect: string } | { __via: string; __build?: string; __redirect?: string }> {
|
|
1671
1700
|
switch (step.type) {
|
|
1672
1701
|
case 'goto': {
|
|
1673
1702
|
const p = subst(step.path, vars);
|
|
@@ -1706,7 +1735,7 @@ async function execStep(page: Page, step: FlowStep, base: string, vars: Record<s
|
|
|
1706
1735
|
// used to pass as success, which is the half of the fleet save-rule this
|
|
1707
1736
|
// instrument could not perform.
|
|
1708
1737
|
if (step.valueFrom) {
|
|
1709
|
-
await fillSecret(loc, step.valueFrom,
|
|
1738
|
+
await fillSecret(loc, step.valueFrom, secretRoot, remaining, pwSink, anchorOf(step));
|
|
1710
1739
|
return via ? { __via: via } : null;
|
|
1711
1740
|
}
|
|
1712
1741
|
const filledValue = subst(step.value ?? '', vars);
|
|
@@ -2207,6 +2236,12 @@ export interface RunFlowOptions {
|
|
|
2207
2236
|
/** F213.6 — repo root for resolving an uploadFile step's fixture path. The
|
|
2208
2237
|
* path is sandboxed to this dir (no `../` escape). Absent → only data: URIs. */
|
|
2209
2238
|
localPath?: string;
|
|
2239
|
+
/** F009.121 — the repo root a `fill valueFrom {path}` may read from, ALREADY
|
|
2240
|
+
* checked against the daemon sandbox by the caller. Absent → only {env} works.
|
|
2241
|
+
* Kept apart from localPath on purpose: a secret file is typed into whatever
|
|
2242
|
+
* page the caller named, so an unsandboxed root would let a flow post any file
|
|
2243
|
+
* the daemon can read to any URL. */
|
|
2244
|
+
secretRoot?: string;
|
|
2210
2245
|
/** F255.5 — record the whole driven run to a .webm, returned as FlowResult.video.
|
|
2211
2246
|
* Off by default: recording costs disk + a little time on every run, and most flows
|
|
2212
2247
|
* only need the per-step stills. */
|
|
@@ -2373,12 +2408,60 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
|
|
|
2373
2408
|
skipFill(s.steps, idx, vars, detail);
|
|
2374
2409
|
idx += flattenCount(s.steps);
|
|
2375
2410
|
} else {
|
|
2376
|
-
|
|
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 });
|
|
2377
2413
|
idx += 1;
|
|
2378
2414
|
}
|
|
2379
2415
|
}
|
|
2380
2416
|
};
|
|
2381
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
|
+
|
|
2382
2465
|
// Run a step-list on `page` starting at `startIndex`. Returns whether a
|
|
2383
2466
|
// non-loop step failed (so the caller can stop THIS sequence).
|
|
2384
2467
|
async function runSeq(page: Page, steps: FlowStep[], vars: Record<string, unknown>, startIndex: number): Promise<{ failedHere: boolean }> {
|
|
@@ -2453,6 +2536,11 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
|
|
|
2453
2536
|
const i = idx;
|
|
2454
2537
|
idx += 1;
|
|
2455
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;
|
|
2456
2544
|
if (i < from) {
|
|
2457
2545
|
record({ index: i, type: step.type, label, status: 'skip', testid: testidOf(step), detail: 'resumed past', screenshot: null });
|
|
2458
2546
|
continue;
|
|
@@ -2468,7 +2556,9 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
|
|
|
2468
2556
|
// matter most, and a sink scoped to the try is unreachable from the catch.
|
|
2469
2557
|
const sink: CriticSink = { critic: null };
|
|
2470
2558
|
try {
|
|
2471
|
-
|
|
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);
|
|
2472
2562
|
const obj = out !== null && !Buffer.isBuffer(out) && typeof out === 'object' ? out : null;
|
|
2473
2563
|
const inspect = obj && '__inspect' in obj ? (obj as { __inspect: string }).__inspect : null;
|
|
2474
2564
|
const via = obj && '__via' in obj ? (obj as { __via: string }).__via : null;
|
|
@@ -2490,7 +2580,7 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
|
|
|
2490
2580
|
// would call that an ejection on every single run.
|
|
2491
2581
|
if (formsAVerdict(step.type)) formedAVerdict = true;
|
|
2492
2582
|
if (NAVIGATES_BY_DESIGN.has(step.type)) {
|
|
2493
|
-
urlAtLoad = safeUrl(
|
|
2583
|
+
urlAtLoad = safeUrl(sp) ?? urlAtLoad;
|
|
2494
2584
|
// A goto is already settled — Playwright waited for it. Any OTHER
|
|
2495
2585
|
// navigating action may commit after it returns, so it leaves a note
|
|
2496
2586
|
// saying "one URL change after this is mine". See classifyUrlChange.
|
|
@@ -2500,7 +2590,7 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
|
|
|
2500
2590
|
// under it, that opinion is void — and `page.url()` is a synchronous
|
|
2501
2591
|
// read of state Playwright already holds, so this costs nothing and
|
|
2502
2592
|
// adds no polling (the tax AC4 forbids).
|
|
2503
|
-
const verdict = classifyUrlChange({ loaded: urlAtLoad, current: safeUrl(
|
|
2593
|
+
const verdict = classifyUrlChange({ loaded: urlAtLoad, current: safeUrl(sp) ?? urlAtLoad, navActionPending });
|
|
2504
2594
|
if (verdict.kind === 'flow-navigation') {
|
|
2505
2595
|
urlAtLoad = verdict.rebaseTo;
|
|
2506
2596
|
navActionPending = false;
|
|
@@ -2519,6 +2609,7 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
|
|
|
2519
2609
|
index: i,
|
|
2520
2610
|
type: step.type,
|
|
2521
2611
|
label,
|
|
2612
|
+
...onBrowser,
|
|
2522
2613
|
status: 'pass',
|
|
2523
2614
|
...navOffset,
|
|
2524
2615
|
ms_step_duration: Date.now() - stepStartedAt,
|
|
@@ -2541,7 +2632,7 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
|
|
|
2541
2632
|
const msg = err instanceof Error ? err.message : String(err);
|
|
2542
2633
|
let shot: Buffer | null = null;
|
|
2543
2634
|
try {
|
|
2544
|
-
shot = (await takeShot(
|
|
2635
|
+
shot = (await takeShot(sp, 'viewport', null, 5000)).screenshot;
|
|
2545
2636
|
} catch {
|
|
2546
2637
|
// page may be in a bad state — a missing failure shot is acceptable
|
|
2547
2638
|
}
|
|
@@ -2575,6 +2666,7 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
|
|
|
2575
2666
|
index: i,
|
|
2576
2667
|
type: step.type,
|
|
2577
2668
|
label,
|
|
2669
|
+
...onBrowser,
|
|
2578
2670
|
// Still 'fail' — a broken probe is never less than a failure.
|
|
2579
2671
|
status: 'fail',
|
|
2580
2672
|
// F213.11 — on the FAILING step above all: a lost write is exactly where
|
|
@@ -2682,7 +2774,15 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
|
|
|
2682
2774
|
// cookie read failed — leave authReach null rather than invent a verdict
|
|
2683
2775
|
}
|
|
2684
2776
|
}
|
|
2685
|
-
|
|
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
|
+
}
|
|
2686
2786
|
if (r.failedHere) aborted = true; // a TOP-LEVEL step failed → flow aborts
|
|
2687
2787
|
// ── F213.10 · the window, and why it has to exist at all ──────────────
|
|
2688
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/secret-fill.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// login form with a test account, and the only way before was the plaintext
|
|
5
5
|
// password in the manuscript.
|
|
6
6
|
import { readFile } from 'node:fs/promises';
|
|
7
|
-
import {
|
|
7
|
+
import { resolve, sep } from 'node:path';
|
|
8
8
|
import type { Locator } from 'playwright';
|
|
9
9
|
import { fillAndVerify } from './fill.ts';
|
|
10
10
|
import type { FilledPassword } from './password-in-url.ts';
|
|
@@ -13,7 +13,7 @@ import type { ValueFrom } from './flow.ts';
|
|
|
13
13
|
/** Read the secret. Every error names WHERE it looked and never WHAT it found. */
|
|
14
14
|
export async function readSecret(
|
|
15
15
|
vf: ValueFrom,
|
|
16
|
-
|
|
16
|
+
secretRoot: string | undefined,
|
|
17
17
|
env: Record<string, string | undefined> = process.env,
|
|
18
18
|
): Promise<string> {
|
|
19
19
|
if ('env' in vf) {
|
|
@@ -21,15 +21,17 @@ export async function readSecret(
|
|
|
21
21
|
if (!v) throw new Error(`fill valueFrom: env ${vf.env} is not set in the daemon`);
|
|
22
22
|
return v;
|
|
23
23
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
24
|
+
// The file is typed into whatever page the caller named, so it must live
|
|
25
|
+
// inside a repo the daemon sandbox allows (secretRoot is checked by the
|
|
26
|
+
// caller) — never anywhere else the daemon user can read. Same containment
|
|
27
|
+
// rule as an uploadFile fixture.
|
|
28
|
+
if (!secretRoot) {
|
|
29
|
+
throw new Error('fill valueFrom {path} needs local_path inside the daemon sandbox — the secret file must live in that repo (use {env} otherwise)');
|
|
30
|
+
}
|
|
31
|
+
const root = resolve(secretRoot);
|
|
32
|
+
const p = resolve(root, vf.path);
|
|
33
|
+
if (p !== root && !p.startsWith(root + sep)) {
|
|
34
|
+
throw new Error(`fill valueFrom: path escapes the repo root: ${vf.path}`);
|
|
33
35
|
}
|
|
34
36
|
let raw: string;
|
|
35
37
|
try {
|
|
@@ -50,7 +52,7 @@ export async function readSecret(
|
|
|
50
52
|
export async function fillSecret(
|
|
51
53
|
loc: Locator,
|
|
52
54
|
vf: ValueFrom,
|
|
53
|
-
|
|
55
|
+
secretRoot: string | undefined,
|
|
54
56
|
timeout: number,
|
|
55
57
|
pwSink: FilledPassword[] | undefined,
|
|
56
58
|
anchor: string,
|
|
@@ -64,7 +66,7 @@ export async function fillSecret(
|
|
|
64
66
|
if (!isPassword) {
|
|
65
67
|
throw new Error('fill valueFrom only fills a password input (type="password") — any other field would show the secret in every later screenshot');
|
|
66
68
|
}
|
|
67
|
-
const secret = await readSecret(vf,
|
|
69
|
+
const secret = await readSecret(vf, secretRoot, env);
|
|
68
70
|
try {
|
|
69
71
|
await fillAndVerify(loc, secret, timeout);
|
|
70
72
|
} catch {
|
package/src/service.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// dashboard + cc point <img>/links at the daemon, the same way the rest of
|
|
9
9
|
// the SPA already talks to 127.0.0.1:7475.
|
|
10
10
|
|
|
11
|
+
import { resolveSandboxedPath } from './sandbox.ts';
|
|
11
12
|
import { randomUUID } from 'node:crypto';
|
|
12
13
|
import sharp from 'sharp';
|
|
13
14
|
import { durabilityOf } from './durability.ts';
|
|
@@ -299,6 +300,19 @@ function projectLocale(deps: LensDeps, project: string): { locale?: string; time
|
|
|
299
300
|
return row?.local_path ? manifestLocale(row.local_path, deps.sandboxRoots) : null;
|
|
300
301
|
}
|
|
301
302
|
|
|
303
|
+
/** F009.121 — the root a `fill valueFrom {path}` may read under: the caller's
|
|
304
|
+
* local_path, but only once the daemon sandbox has accepted it. Outside the
|
|
305
|
+
* sandbox (or absent) there is no root, so only {env} works — a secret file is
|
|
306
|
+
* typed into a page the caller chose, and must not be any file the daemon can read. */
|
|
307
|
+
export function secretRootOf(localPath: string | undefined, sandboxRoots: string[]): string | undefined {
|
|
308
|
+
if (!localPath) return undefined;
|
|
309
|
+
try {
|
|
310
|
+
return resolveSandboxedPath(localPath, sandboxRoots);
|
|
311
|
+
} catch {
|
|
312
|
+
return undefined; // refused by the sandbox; fill reports "needs local_path inside the daemon sandbox"
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
302
316
|
export function resolveVerifyAuth(
|
|
303
317
|
deps: LensDeps,
|
|
304
318
|
project: string,
|
|
@@ -926,6 +940,7 @@ export async function lensRunFlow(deps: LensDeps, input: RunFlowInput) {
|
|
|
926
940
|
viewport: coerceViewport(input.viewport, input.device),
|
|
927
941
|
critic: input.critic,
|
|
928
942
|
localPath: input.local_path, // F213.6 — sandbox root for uploadFile fixture paths
|
|
943
|
+
secretRoot: secretRootOf(input.local_path, deps.sandboxRoots), // F009.121
|
|
929
944
|
record: input.record === true, // F255.5 — opt-in .webm of the whole run
|
|
930
945
|
storage: coerceStorageSeed(input.storage), // F074.97 — seed saved client state
|
|
931
946
|
});
|
|
@@ -988,6 +1003,7 @@ export async function lensRunManuscript(deps: LensDeps, input: RunManuscriptInpu
|
|
|
988
1003
|
reducedMotion: input.reduced_motion,
|
|
989
1004
|
viewport: coerceViewport(input.viewport, input.device), // F252.2 — mobile-only UI E2E in manuscripts
|
|
990
1005
|
localPath: input.local_path, // F213.6 — uploadFile fixture root
|
|
1006
|
+
secretRoot: secretRootOf(input.local_path, deps.sandboxRoots), // F009.121
|
|
991
1007
|
record: input.record === true, // F255.5 — opt-in .webm of the whole run
|
|
992
1008
|
storage: coerceStorageSeed(input.storage), // F074.97 — seed saved client state
|
|
993
1009
|
});
|