@broberg/lens-daemon 0.1.11 → 0.1.13
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/extension-flow.ts +82 -0
- package/src/flow-store.ts +7 -0
- package/src/flow.ts +127 -7
- package/src/mcp.ts +11 -1
- package/src/service.ts +10 -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.13",
|
|
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-svg": "0.1.0",
|
|
19
|
+
"@broberg/lens-engine": "0.13.0"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"@happy-dom/global-registrator": "^20.10.6",
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// F009.125 — what a flow needs around an extension build: which popup to open,
|
|
2
|
+
// and (only when asked) a temporary copy of the build that may read the page.
|
|
3
|
+
//
|
|
4
|
+
// MEASURED before this file existed (Jam Clipper's real dist/, M1, 15 Oct 2026):
|
|
5
|
+
// · a popup opened as its own tab sees the TARGET tab as active once the target
|
|
6
|
+
// is brought to the front (tabs.query → the target's id, not its own);
|
|
7
|
+
// · chrome.scripting.executeScript on that tab FAILS without the toolbar gesture:
|
|
8
|
+
// "Cannot access contents of the page. Extension manifest must request
|
|
9
|
+
// permission to access the respective host."
|
|
10
|
+
// · the same call SUCCEEDS on a copy whose manifest adds host access.
|
|
11
|
+
// A headless run cannot press the toolbar button, so `activeTab` can never be
|
|
12
|
+
// granted the way a person grants it. The copy is the honest substitute — and it
|
|
13
|
+
// is opt-in and reported, because a simulated permission must not read as the
|
|
14
|
+
// real gesture.
|
|
15
|
+
|
|
16
|
+
import { cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
17
|
+
import { tmpdir } from 'node:os';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
|
|
20
|
+
interface Manifest {
|
|
21
|
+
manifest_version?: number;
|
|
22
|
+
action?: { default_popup?: string };
|
|
23
|
+
browser_action?: { default_popup?: string };
|
|
24
|
+
host_permissions?: string[];
|
|
25
|
+
permissions?: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function readManifest(dir: string): Promise<Manifest> {
|
|
29
|
+
let raw: string;
|
|
30
|
+
try {
|
|
31
|
+
raw = await readFile(join(dir, 'manifest.json'), 'utf8');
|
|
32
|
+
} catch {
|
|
33
|
+
throw new Error(
|
|
34
|
+
`extension.path ${dir} has no manifest.json — point it at the UNPACKED build directory (e.g. dist/), not the source or a .zip`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
return JSON.parse(raw) as Manifest;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The popup page an `openPopup` step opens when it names none: the one Chrome
|
|
41
|
+
* opens on the toolbar click (MV3 `action`, MV2 `browser_action`). */
|
|
42
|
+
export async function defaultPopupOf(dir: string): Promise<string> {
|
|
43
|
+
const m = await readManifest(dir);
|
|
44
|
+
const p = m.action?.default_popup ?? m.browser_action?.default_popup;
|
|
45
|
+
if (!p) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
'openPopup: the manifest declares no action.default_popup — name the page with openPopup {path: "…"}',
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
return p.replace(/^\/+/, '');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface PreparedExtension {
|
|
54
|
+
/** The directory Chromium loads — the original, or the temporary copy. */
|
|
55
|
+
dir: string;
|
|
56
|
+
/** True when host access was added to a copy rather than granted by a gesture. */
|
|
57
|
+
simulated: boolean;
|
|
58
|
+
cleanup: () => Promise<void>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The build as-is, or — when `grantHostAccess` — a TEMPORARY COPY whose manifest
|
|
62
|
+
* may read every page, standing in for the toolbar gesture a headless run cannot
|
|
63
|
+
* make. The original directory is never written to. */
|
|
64
|
+
export async function prepareExtension(path: string, grantHostAccess: boolean): Promise<PreparedExtension> {
|
|
65
|
+
const manifest = await readManifest(path);
|
|
66
|
+
if (!grantHostAccess) return { dir: path, simulated: false, cleanup: async () => {} };
|
|
67
|
+
const dir = await mkdtemp(join(tmpdir(), 'lens-ext-copy-'));
|
|
68
|
+
await cp(path, dir, { recursive: true });
|
|
69
|
+
const hostPermissions = [...new Set([...(manifest.host_permissions ?? []), '<all_urls>'])];
|
|
70
|
+
// MV2 has no host_permissions key; host access lives in `permissions` there.
|
|
71
|
+
const next: Manifest =
|
|
72
|
+
manifest.manifest_version === 2
|
|
73
|
+
? { ...manifest, permissions: [...new Set([...(manifest.permissions ?? []), '<all_urls>'])] }
|
|
74
|
+
: { ...manifest, host_permissions: hostPermissions };
|
|
75
|
+
await writeFile(join(dir, 'manifest.json'), JSON.stringify(next, null, 2));
|
|
76
|
+
return { dir, simulated: true, cleanup: () => rm(dir, { recursive: true, force: true }) };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The one line every report that used the copy carries. */
|
|
80
|
+
export const SIMULATED_ACCESS_NOTE =
|
|
81
|
+
'host access SIMULATED: the extension ran from a temporary copy that may read every page, ' +
|
|
82
|
+
'standing in for the toolbar click (activeTab) a headless run cannot make';
|
package/src/flow-store.ts
CHANGED
|
@@ -19,6 +19,8 @@ export interface StoredFlowStep {
|
|
|
19
19
|
label: string;
|
|
20
20
|
/** F009.122 — the named browser the step ran in; absent = the default one. */
|
|
21
21
|
browser?: string;
|
|
22
|
+
/** F009.125 — the step ran in the extension popup. */
|
|
23
|
+
in?: 'popup';
|
|
22
24
|
status: 'pass' | 'fail' | 'skip';
|
|
23
25
|
testid: string | null;
|
|
24
26
|
detail: string | null;
|
|
@@ -86,6 +88,7 @@ export async function recordFlowRun(
|
|
|
86
88
|
label: s.label,
|
|
87
89
|
// F009.122 — third-place rule: a field only flow.ts knows reaches nobody.
|
|
88
90
|
...(s.browser ? { browser: s.browser } : {}),
|
|
91
|
+
...(s.in ? { in: s.in } : {}),
|
|
89
92
|
status: s.status,
|
|
90
93
|
testid: s.testid,
|
|
91
94
|
detail: s.detail,
|
|
@@ -148,6 +151,10 @@ export async function recordFlowRun(
|
|
|
148
151
|
// findings were produced in flow.ts and written nowhere until fd-sundhed
|
|
149
152
|
// measured it from a consumer's seat: correct instrument, nobody told.
|
|
150
153
|
findings_json: JSON.stringify({
|
|
154
|
+
// F009.125 — which extension the run loaded, and whether its host access was
|
|
155
|
+
// simulated. Kept with the findings because a simulated permission is
|
|
156
|
+
// something a reader must see before trusting the green.
|
|
157
|
+
...(result.extension ? { extension: result.extension } : {}),
|
|
151
158
|
...(result.password_in_url ? { password_in_url: result.password_in_url } : {}),
|
|
152
159
|
...(result.not_listening ? { not_listening: result.not_listening } : {}),
|
|
153
160
|
}),
|
package/src/flow.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import nodePath from 'node:path';
|
|
15
15
|
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
16
16
|
import { tmpdir } from 'node:os';
|
|
17
|
-
import type { Locator, Page } from 'playwright';
|
|
17
|
+
import type { BrowserContext, Locator, Page } from 'playwright';
|
|
18
18
|
import {
|
|
19
19
|
resolveTarget,
|
|
20
20
|
isEditableElement,
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
type Target,
|
|
25
25
|
} from '@broberg/lens-engine';
|
|
26
26
|
import { withPage, type LensEngine } from './browser.ts';
|
|
27
|
+
import { defaultPopupOf, prepareExtension, SIMULATED_ACCESS_NOTE } from './extension-flow.ts';
|
|
27
28
|
import { authPrepare, type AuthConfig, type AuthEnv } from './auth/index.ts';
|
|
28
29
|
import { takeShot, settleForShot } from './capture.ts';
|
|
29
30
|
import { runCritic, topFindingMessage, type CriticMode, type CriticReport } from './critic.ts';
|
|
@@ -153,7 +154,11 @@ export type FlowStep =
|
|
|
153
154
|
| { type: 'autocomplete'; testid: string; value: string; option: string; pickText?: string; pickIndex?: number; label?: string; timeout?: number }
|
|
154
155
|
| { type: 'capture'; testid: string; as: string; from?: 'text' | 'value'; label?: string; timeout?: number }
|
|
155
156
|
| { type: 'conditional'; testid: string; present: boolean; steps: FlowStep[]; label?: string; timeout?: number }
|
|
156
|
-
| { type: 'loop'; data: Array<Record<string, unknown>>; steps: FlowStep[]; label?: string; parallel?: number }
|
|
157
|
+
| { type: 'loop'; data: Array<Record<string, unknown>>; steps: FlowStep[]; label?: string; parallel?: number }
|
|
158
|
+
// F009.125 — open the loaded extension's popup as its own page in the SAME browser,
|
|
159
|
+
// then put the page under test back in front so the extension sees it as the active
|
|
160
|
+
// tab (measured). `path` defaults to the manifest's action.default_popup.
|
|
161
|
+
| { type: 'openPopup'; path?: string; label?: string; timeout?: number };
|
|
157
162
|
|
|
158
163
|
export interface Flow {
|
|
159
164
|
name: string;
|
|
@@ -171,9 +176,19 @@ export interface Flow {
|
|
|
171
176
|
* ejecting us. Default {@link DEFAULT_EJECT_GRACE_MS}; 0 opts out. Raise it
|
|
172
177
|
* for an app whose auth check is slow (fd-sundhed's bounced at ~3.6s). */
|
|
173
178
|
eject_grace_ms?: number;
|
|
179
|
+
/** F009.125 — run the whole flow in a Chromium with this UNPACKED extension
|
|
180
|
+
* loaded. `grant_host_access` loads a temporary copy that may read every page,
|
|
181
|
+
* standing in for the toolbar click (activeTab) a headless run cannot make;
|
|
182
|
+
* the original build is never written, and the report says it was simulated. */
|
|
183
|
+
extension?: FlowExtension;
|
|
174
184
|
steps: FlowStep[];
|
|
175
185
|
}
|
|
176
186
|
|
|
187
|
+
export interface FlowExtension {
|
|
188
|
+
path: string;
|
|
189
|
+
grant_host_access?: boolean;
|
|
190
|
+
}
|
|
191
|
+
|
|
177
192
|
/**
|
|
178
193
|
* MEASURED, not chosen. Against a fixture that bounces 800ms after load:
|
|
179
194
|
*
|
|
@@ -242,6 +257,8 @@ export interface FlowStepResult {
|
|
|
242
257
|
label: string;
|
|
243
258
|
/** F009.122 — the named browser this step ran in. Absent = the default one. */
|
|
244
259
|
browser?: string;
|
|
260
|
+
/** F009.125 — present when the step ran in the extension popup. */
|
|
261
|
+
in?: 'popup';
|
|
245
262
|
status: 'pass' | 'fail' | 'skip';
|
|
246
263
|
testid: string | null;
|
|
247
264
|
detail: string | null;
|
|
@@ -336,6 +353,9 @@ export class InstrumentError extends Error {
|
|
|
336
353
|
}
|
|
337
354
|
|
|
338
355
|
export interface FlowResult {
|
|
356
|
+
/** F009.125 — the extension this run loaded: its id (read at load time), the
|
|
357
|
+
* build path the caller named, and whether host access was SIMULATED on a copy. */
|
|
358
|
+
extension?: { id: string; path: string; host_access: 'as-built' | 'simulated'; note?: string } | null;
|
|
339
359
|
name: string;
|
|
340
360
|
status: 'pass' | 'fail' | 'error';
|
|
341
361
|
steps: FlowStepResult[];
|
|
@@ -403,6 +423,8 @@ export const STEP_TYPES = new Set([
|
|
|
403
423
|
'waitForBuild',
|
|
404
424
|
// F213.8 — readiness gate (the page is listening, not just painted)
|
|
405
425
|
'waitForReady',
|
|
426
|
+
// F009.125 — drive a loaded extension's popup
|
|
427
|
+
'openPopup',
|
|
406
428
|
]);
|
|
407
429
|
|
|
408
430
|
/** Validate + normalise an untrusted flow (HTTP/MCP boundary). Throws on the
|
|
@@ -429,7 +451,25 @@ export function parseFlow(input: unknown): Flow {
|
|
|
429
451
|
if (f.eject_grace_ms !== undefined && (typeof f.eject_grace_ms !== 'number' || !Number.isFinite(f.eject_grace_ms) || f.eject_grace_ms < 0)) {
|
|
430
452
|
throw new Error('flow.eject_grace_ms must be a non-negative number of milliseconds');
|
|
431
453
|
}
|
|
454
|
+
// F009.125 — refused when malformed rather than dropped: before this, an unknown
|
|
455
|
+
// flow-level key vanished in silence, so `extension` would have run the flow
|
|
456
|
+
// WITHOUT the extension and reported green about a page the extension never saw.
|
|
457
|
+
let extension: FlowExtension | undefined;
|
|
458
|
+
if (f.extension !== undefined) {
|
|
459
|
+
const e = f.extension as unknown as Record<string, unknown>;
|
|
460
|
+
const keys = e && typeof e === 'object' && !Array.isArray(e) ? Object.keys(e) : null;
|
|
461
|
+
if (!keys || typeof e.path !== 'string' || !e.path || keys.some((k) => k !== 'path' && k !== 'grant_host_access')) {
|
|
462
|
+
throw new Error('flow.extension must be {path: "<unpacked build dir>", grant_host_access?: boolean}');
|
|
463
|
+
}
|
|
464
|
+
if (e.grant_host_access !== undefined && typeof e.grant_host_access !== 'boolean') {
|
|
465
|
+
throw new Error('flow.extension.grant_host_access must be a boolean');
|
|
466
|
+
}
|
|
467
|
+
extension = { path: e.path, ...(e.grant_host_access === true ? { grant_host_access: true } : {}) };
|
|
468
|
+
}
|
|
432
469
|
validateAndNormalizeSteps(f.steps);
|
|
470
|
+
if (!extension && usesPopup(f.steps as FlowStep[])) {
|
|
471
|
+
throw new Error('openPopup / in:"popup" need flow.extension — there is no extension loaded to open a popup from');
|
|
472
|
+
}
|
|
433
473
|
return {
|
|
434
474
|
name: f.name.trim(),
|
|
435
475
|
base_url: f.base_url,
|
|
@@ -437,10 +477,20 @@ export function parseFlow(input: unknown): Flow {
|
|
|
437
477
|
mutates: f.mutates === true,
|
|
438
478
|
...(f.wait_for_build !== undefined ? { wait_for_build: f.wait_for_build } : {}),
|
|
439
479
|
...(f.eject_grace_ms !== undefined ? { eject_grace_ms: f.eject_grace_ms } : {}),
|
|
480
|
+
...(extension ? { extension } : {}),
|
|
440
481
|
steps: f.steps as FlowStep[],
|
|
441
482
|
};
|
|
442
483
|
}
|
|
443
484
|
|
|
485
|
+
function usesPopup(steps: FlowStep[]): boolean {
|
|
486
|
+
return steps.some(
|
|
487
|
+
(st) =>
|
|
488
|
+
st.type === 'openPopup' ||
|
|
489
|
+
(st as { in?: string }).in === 'popup' ||
|
|
490
|
+
((st.type === 'loop' || st.type === 'conditional') && usesPopup(st.steps)),
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
|
|
444
494
|
// F074.23 — light structural check for a self-healing `target` at the HTTP/MCP
|
|
445
495
|
// boundary (the daemon stays zod-free in this validator; the engine's
|
|
446
496
|
// resolveTarget does the real resolution at run time). A bare string is the
|
|
@@ -458,7 +508,7 @@ function isValidTarget(t: unknown): boolean {
|
|
|
458
508
|
* FlowStep union above; flow-step-fields.test.ts reads the union from source and
|
|
459
509
|
* fails if the two drift. An unknown field is REFUSED: `inspect` with a guessed
|
|
460
510
|
* `selector` used to run a whole-page dump in silence (cardmem, flow b62ffca3). */
|
|
461
|
-
const SHARED_STEP_FIELDS = ['type', 'label', 'timeout', 'timeout_ms', 'browser'];
|
|
511
|
+
const SHARED_STEP_FIELDS = ['type', 'label', 'timeout', 'timeout_ms', 'browser', 'in'];
|
|
462
512
|
|
|
463
513
|
/** F009.122 — a step may name the browser it runs in. Each name is its own browser
|
|
464
514
|
* context (own cookies + storage), opened on first use and kept open for the rest
|
|
@@ -500,6 +550,7 @@ export const STEP_FIELDS: Record<string, string[]> = {
|
|
|
500
550
|
capture: ['testid', 'as', 'from'],
|
|
501
551
|
conditional: ['testid', 'present', 'steps'],
|
|
502
552
|
loop: ['data', 'steps', 'parallel'],
|
|
553
|
+
openPopup: ['path'],
|
|
503
554
|
};
|
|
504
555
|
|
|
505
556
|
function validateAndNormalizeSteps(steps: unknown[], depth = 0, inParallel = false): void {
|
|
@@ -560,6 +611,28 @@ function validateAndNormalizeSteps(steps: unknown[], depth = 0, inParallel = fal
|
|
|
560
611
|
throw new Error(`step ${index} (${s.type}): browser is not allowed inside a parallel loop — each iteration already runs in its own browser`);
|
|
561
612
|
}
|
|
562
613
|
}
|
|
614
|
+
// F009.125 — `in: "popup"` runs a step in the extension popup an earlier
|
|
615
|
+
// openPopup opened. Refused where it cannot mean anything, the same way
|
|
616
|
+
// `browser` is: the popup lives in the main browser only.
|
|
617
|
+
if ('in' in s) {
|
|
618
|
+
const where = (s as { in?: unknown }).in;
|
|
619
|
+
if (where !== 'popup') throw new Error(`step ${index} (${s.type}): in must be "popup", got ${JSON.stringify(where)}`);
|
|
620
|
+
if (s.type === 'loop' || s.type === 'conditional' || s.type === 'openPopup') {
|
|
621
|
+
throw new Error(`step ${index} (${s.type}): in:"popup" goes on the steps that act in the popup, not on a ${s.type}`);
|
|
622
|
+
}
|
|
623
|
+
const b = (s as { browser?: unknown }).browser;
|
|
624
|
+
if (b !== undefined && b !== 'main') {
|
|
625
|
+
throw new Error(`step ${index} (${s.type}): in:"popup" and browser:${JSON.stringify(b)} cannot be combined — the extension is loaded in the main browser only`);
|
|
626
|
+
}
|
|
627
|
+
if (inParallel) throw new Error(`step ${index} (${s.type}): in:"popup" is not allowed inside a parallel loop`);
|
|
628
|
+
}
|
|
629
|
+
if (s.type === 'openPopup') {
|
|
630
|
+
const pth = (s as { path?: unknown }).path;
|
|
631
|
+
if (pth !== undefined && (typeof pth !== 'string' || !pth || pth.includes('..') || /^[a-z]+:/i.test(pth))) {
|
|
632
|
+
throw new Error(`step ${index} (openPopup): path must be a page inside the extension, like "popup.html"`);
|
|
633
|
+
}
|
|
634
|
+
if ('browser' in s || inParallel) throw new Error(`step ${index} (openPopup): the popup opens in the main browser only`);
|
|
635
|
+
}
|
|
563
636
|
if (s.type === 'autocomplete') {
|
|
564
637
|
if (typeof (s as { value?: unknown }).value !== 'string') throw new Error('autocomplete step requires a string value');
|
|
565
638
|
if (typeof (s as { option?: unknown }).option !== 'string') throw new Error('autocomplete step requires a string option testid');
|
|
@@ -1291,6 +1364,7 @@ function describe(step: FlowStep): string {
|
|
|
1291
1364
|
case 'capture': return `capture ${step.testid} → {{${step.as}}}`;
|
|
1292
1365
|
case 'conditional': return `${step.present ? 'if' : 'unless'} [${step.testid}]`;
|
|
1293
1366
|
case 'loop': return 'loop';
|
|
1367
|
+
case 'openPopup': return `openPopup ${step.path ?? '(manifest popup)'}`;
|
|
1294
1368
|
case 'waitForBuild': return `waitForBuild${step.sha ? ` ${step.sha.slice(0, 8)}` : ' (changed)'}`;
|
|
1295
1369
|
// F255.4 — show the offset in the step label so a positioned click is
|
|
1296
1370
|
// recognisable in the report (a plain "click canvas" hides where it aimed).
|
|
@@ -2253,6 +2327,8 @@ async function execStep(page: Page, step: FlowStep, base: string, vars: Record<s
|
|
|
2253
2327
|
throw new Error('conditional steps are handled by runSeq, never execStep');
|
|
2254
2328
|
case 'loop':
|
|
2255
2329
|
throw new Error('loop steps are flattened before execution');
|
|
2330
|
+
case 'openPopup':
|
|
2331
|
+
throw new Error('openPopup steps are handled by runSeq, never execStep');
|
|
2256
2332
|
}
|
|
2257
2333
|
}
|
|
2258
2334
|
|
|
@@ -2585,7 +2661,11 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
|
|
|
2585
2661
|
// F009.122 — which browser this step runs in. `sp` is the page every line
|
|
2586
2662
|
// below acts on; the loop's own `page` is the default browser.
|
|
2587
2663
|
const browserName = (step as { browser?: string }).browser ?? 'main';
|
|
2588
|
-
|
|
2664
|
+
// F009.125 — a popup step says so in the report, the same way a named browser does.
|
|
2665
|
+
const onBrowser = {
|
|
2666
|
+
...(browserName !== 'main' ? { browser: browserName } : {}),
|
|
2667
|
+
...((step as { in?: string }).in === 'popup' ? { in: 'popup' as const } : {}),
|
|
2668
|
+
};
|
|
2589
2669
|
let sp: Page = page;
|
|
2590
2670
|
if (i < from) {
|
|
2591
2671
|
record({ index: i, type: step.type, label, status: 'skip', testid: testidOf(step), detail: 'resumed past', screenshot: null });
|
|
@@ -2604,7 +2684,16 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
|
|
|
2604
2684
|
try {
|
|
2605
2685
|
switchBrowser(browserName);
|
|
2606
2686
|
sp = await pageFor(browserName, page);
|
|
2607
|
-
|
|
2687
|
+
if ((step as { in?: string }).in === 'popup') {
|
|
2688
|
+
if (!extCtx?.popup) throw new Error('in:"popup" before any openPopup step — open the popup first');
|
|
2689
|
+
sp = extCtx.popup;
|
|
2690
|
+
// Keep the page under test the extension's active tab while the popup is driven.
|
|
2691
|
+
await extCtx.main.bringToFront();
|
|
2692
|
+
}
|
|
2693
|
+
const out =
|
|
2694
|
+
step.type === 'openPopup'
|
|
2695
|
+
? await openPopup(step)
|
|
2696
|
+
: await execStep(sp, step, base, vars, opts.critic ?? 'off', opts.localPath, sink, filledPasswords, notListening, opts.secretRoot);
|
|
2608
2697
|
const obj = out !== null && !Buffer.isBuffer(out) && typeof out === 'object' ? out : null;
|
|
2609
2698
|
const inspect = obj && '__inspect' in obj ? (obj as { __inspect: string }).__inspect : null;
|
|
2610
2699
|
const via = obj && '__via' in obj ? (obj as { __via: string }).__via : null;
|
|
@@ -2754,6 +2843,22 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
|
|
|
2754
2843
|
|
|
2755
2844
|
const videoDir = opts.record ? await mkdtemp(nodePath.join(tmpdir(), 'lens-flow-video-')) : null;
|
|
2756
2845
|
let videoPage: Page | null = null;
|
|
2846
|
+
// F009.125 — set once the extension's browser is up; read by openPopup / in:"popup".
|
|
2847
|
+
let extCtx: { context: BrowserContext; id: string; main: Page; popup: Page | null } | null = null;
|
|
2848
|
+
let extensionInfo: FlowResult['extension'] = null;
|
|
2849
|
+
const prepared = flow.extension ? await prepareExtension(flow.extension.path, flow.extension.grant_host_access === true) : null;
|
|
2850
|
+
const openPopup = async (step: Extract<FlowStep, { type: 'openPopup' }>): Promise<null> => {
|
|
2851
|
+
if (!extCtx || !flow.extension || !prepared) throw new Error('openPopup needs flow.extension');
|
|
2852
|
+
const path = step.path ?? (await defaultPopupOf(prepared.dir));
|
|
2853
|
+
const popup = await extCtx.context.newPage();
|
|
2854
|
+
collectFailures(popup);
|
|
2855
|
+
await popup.goto(`chrome-extension://${extCtx.id}/${path}`, { timeout: stepTimeout(step) });
|
|
2856
|
+
// MEASURED: with the page under test back in front, the popup's
|
|
2857
|
+
// tabs.query({active, currentWindow}) answers with THAT tab, as on a real click.
|
|
2858
|
+
await extCtx.main.bringToFront();
|
|
2859
|
+
extCtx.popup = popup;
|
|
2860
|
+
return null;
|
|
2861
|
+
};
|
|
2757
2862
|
const readVideo = async (): Promise<Buffer | null> => {
|
|
2758
2863
|
if (!videoDir) return null;
|
|
2759
2864
|
try {
|
|
@@ -2778,6 +2883,7 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
|
|
|
2778
2883
|
} catch (err) {
|
|
2779
2884
|
const msg = err instanceof Error ? err.message : String(err);
|
|
2780
2885
|
skipFill(flow.steps, 0, {}, 'skipped — the build under test never went live');
|
|
2886
|
+
await prepared?.cleanup();
|
|
2781
2887
|
return {
|
|
2782
2888
|
name: flow.name,
|
|
2783
2889
|
status: 'error',
|
|
@@ -2801,10 +2907,20 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
|
|
|
2801
2907
|
timezone: opts.timezone,
|
|
2802
2908
|
reducedMotion: opts.reducedMotion,
|
|
2803
2909
|
...(videoDir ? { recordVideoDir: videoDir } : {}),
|
|
2910
|
+
...(prepared ? { extension: { path: prepared.dir } } : {}),
|
|
2804
2911
|
},
|
|
2805
|
-
async (page, context) => {
|
|
2912
|
+
async (page, context, ext) => {
|
|
2806
2913
|
videoPage = page;
|
|
2807
2914
|
collectFailures(page);
|
|
2915
|
+
if (ext && flow.extension && prepared) {
|
|
2916
|
+
extCtx = { context, id: ext.id, main: page, popup: null };
|
|
2917
|
+
extensionInfo = {
|
|
2918
|
+
id: ext.id,
|
|
2919
|
+
path: flow.extension.path,
|
|
2920
|
+
host_access: prepared.simulated ? 'simulated' : 'as-built',
|
|
2921
|
+
...(prepared.simulated ? { note: SIMULATED_ACCESS_NOTE } : {}),
|
|
2922
|
+
};
|
|
2923
|
+
}
|
|
2808
2924
|
// F074.36 — measure the credential BEFORE driving, so a run that cannot
|
|
2809
2925
|
// possibly be authenticated says so instead of producing a confident
|
|
2810
2926
|
// verdict about a login screen. Only when auth was actually requested:
|
|
@@ -2902,10 +3018,13 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
|
|
|
2902
3018
|
reason = reason ?? (err instanceof Error ? err.message : String(err));
|
|
2903
3019
|
// Keep the recording even on a crash — watching HOW it broke is the whole point.
|
|
2904
3020
|
const video = await readVideo();
|
|
2905
|
-
|
|
3021
|
+
await prepared?.cleanup();
|
|
3022
|
+
return { ...(extensionInfo ? { extension: extensionInfo } : {}), name: flow.name, status: 'error', steps: results.sort((a, b) => a.index - b.index), failed_at: firstFailedAt, reason: leadWithRedirect(reason, firstRedirect), duration_ms: Date.now() - startedAt, video, build: observedBuild, final_url: finalUrl, redirected: firstRedirect, password_in_url: passwordInUrl(finalUrl, filledPasswords), not_listening: notListening[0] ?? null, reduced_motion: opts.reducedMotion ?? 'reduce' };
|
|
2906
3023
|
}
|
|
2907
3024
|
|
|
2908
3025
|
const video = await readVideo();
|
|
3026
|
+
// F009.125 — the temporary copy (if any) is ours alone; the original is never touched.
|
|
3027
|
+
await prepared?.cleanup();
|
|
2909
3028
|
results.sort((a, b) => a.index - b.index);
|
|
2910
3029
|
const anyFail = results.some((s) => s.status === 'fail');
|
|
2911
3030
|
const baseReason = reason ?? (aborted ? 'flow aborted on a top-level failure' : null);
|
|
@@ -2953,6 +3072,7 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
|
|
|
2953
3072
|
// ASKED for, defaulted the same way the context does, so the report and the
|
|
2954
3073
|
// context can never disagree about it.
|
|
2955
3074
|
reduced_motion: opts.reducedMotion ?? 'reduce',
|
|
3075
|
+
...(extensionInfo ? { extension: extensionInfo } : {}),
|
|
2956
3076
|
};
|
|
2957
3077
|
}
|
|
2958
3078
|
|
package/src/mcp.ts
CHANGED
|
@@ -360,10 +360,20 @@ export const TOOLS = [
|
|
|
360
360
|
'F255.7 — do not start until the app is serving the build under test. A commit sha waits for THAT build; true waits for any change from the build live at run start. Polls GET /api/health for a `build` field (the fleet contract), from the runner with no-store, requiring 3 consecutive matching reads so a fleet mid-rollout cannot end the wait early. Fails loudly if the app exposes no marker. Use it right after a deploy — otherwise a green run may be verifying the build you just replaced.',
|
|
361
361
|
},
|
|
362
362
|
auth: authSchema,
|
|
363
|
+
extension: {
|
|
364
|
+
type: 'object',
|
|
365
|
+
required: ['path'],
|
|
366
|
+
description:
|
|
367
|
+
'F009.125 — run the flow in a Chromium with this UNPACKED extension loaded (the build dir holding manifest.json; relative = relative to local_path; must be inside the daemon sandbox). Then: {type:"openPopup", path?} opens the popup (default: the manifest action.default_popup) as its own page and keeps the page under test as the extension\'s ACTIVE tab (measured), and any step with in:"popup" runs in the popup. grant_host_access:true loads a TEMPORARY COPY with <all_urls> host access, standing in for the toolbar click (activeTab) a headless run cannot make — without it chrome.scripting on the page fails ("Cannot access contents of the page"). The original build is never written; the report carries extension {id, host_access:"simulated"|"as-built"}. CHROMIUM ONLY.',
|
|
368
|
+
properties: {
|
|
369
|
+
path: { type: 'string' },
|
|
370
|
+
grant_host_access: { type: 'boolean' },
|
|
371
|
+
},
|
|
372
|
+
},
|
|
363
373
|
steps: {
|
|
364
374
|
type: 'array',
|
|
365
375
|
description: 'Ordered steps. Each has a "type"; testid-anchored steps carry a "testid". fillSelector/clickSelector carry a CSS "selector" + optional "frame" (one iframe selector or an outer→inner string[] chain) to reach a cross-origin iframe; fillSelector also carries "value". waitForUrl carries a "url" substring. A "loop" step has data[] + steps[] and substitutes {{field}}/{{__index}}.',
|
|
366
|
-
items: { type: 'object', required: ['type'], properties: { type: { type: 'string' }, testid: { type: 'string' }, selector: { type: 'string' }, frame: { type: ['string', 'array'], items: { type: 'string' } }, url: { type: 'string' }, value: { type: 'string' }, text: { type: 'string' }, path: { type: 'string' }, js: { type: 'string' }, label: { type: 'string' }, timeout: { type: 'number' }, sha: { type: 'string', description: 'F255.7 — waitForBuild step: the commit sha to wait for. Omit to wait for any change from the build live when the step starts.' }, at: { type: 'string', description: 'F255.4 — click a POINT inside the element instead of its centre: "50%,28%" (fraction of the box) or "40,120" (css px). For canvas/3D surfaces where the centre hits nothing. Works on click + clickSelector.' }, activate: { type: 'string', enum: ['auto', 'click', 'enter'], description: 'F255.1 — how a button is fired. auto (default) clicks and falls back to focus+Enter only if nothing observable happened; enter forces focus+Enter (Angular/MDC forms); click keeps mouse-only.' } } },
|
|
376
|
+
items: { type: 'object', required: ['type'], properties: { type: { type: 'string' }, testid: { type: 'string' }, selector: { type: 'string' }, frame: { type: ['string', 'array'], items: { type: 'string' } }, url: { type: 'string' }, value: { type: 'string' }, text: { type: 'string' }, path: { type: 'string' }, js: { type: 'string' }, label: { type: 'string' }, timeout: { type: 'number' }, sha: { type: 'string', description: 'F255.7 — waitForBuild step: the commit sha to wait for. Omit to wait for any change from the build live when the step starts.' }, at: { type: 'string', description: 'F255.4 — click a POINT inside the element instead of its centre: "50%,28%" (fraction of the box) or "40,120" (css px). For canvas/3D surfaces where the centre hits nothing. Works on click + clickSelector.' }, activate: { type: 'string', enum: ['auto', 'click', 'enter'], description: 'F255.1 — how a button is fired. auto (default) clicks and falls back to focus+Enter only if nothing observable happened; enter forces focus+Enter (Angular/MDC forms); click keeps mouse-only.' }, in: { type: 'string', enum: ['popup'], description: 'F009.125 — run this step in the extension popup an earlier openPopup opened (needs flow.extension).' } } },
|
|
367
377
|
},
|
|
368
378
|
},
|
|
369
379
|
},
|
package/src/service.ts
CHANGED
|
@@ -684,6 +684,7 @@ export function flowRunView(row: LensFlowRunRow, port: number) {
|
|
|
684
684
|
status: s.status,
|
|
685
685
|
// F009.122 — which named browser ran the step. Absent = the default one.
|
|
686
686
|
...(s.browser ? { browser: s.browser } : {}),
|
|
687
|
+
...(s.in ? { in: s.in } : {}),
|
|
687
688
|
testid: s.testid,
|
|
688
689
|
detail: s.detail,
|
|
689
690
|
...(s.fault ? { fault: s.fault } : {}),
|
|
@@ -929,6 +930,15 @@ export async function runFlatFlow(deps: LensDeps, input: FlatFlowInput) {
|
|
|
929
930
|
export async function lensRunFlow(deps: LensDeps, input: RunFlowInput) {
|
|
930
931
|
const project = requireProject(input.project);
|
|
931
932
|
const flow = parseFlow(input.flow);
|
|
933
|
+
// F009.125 — an extension build is loaded into a real browser (and, with
|
|
934
|
+
// grant_host_access, may read every page), so its directory must be inside the
|
|
935
|
+
// daemon sandbox like any other repo path. Relative = relative to local_path.
|
|
936
|
+
if (flow.extension) {
|
|
937
|
+
const raw = flow.extension.path;
|
|
938
|
+
const abs = raw.startsWith('/') ? raw : input.local_path ? `${input.local_path.replace(/\/$/, '')}/${raw}` : null;
|
|
939
|
+
if (!abs) throw new Error('flow.extension.path is relative — pass local_path, or give an absolute path');
|
|
940
|
+
flow.extension = { ...flow.extension, path: resolveSandboxedPath(abs, deps.sandboxRoots) };
|
|
941
|
+
}
|
|
932
942
|
// F082.6 — auth reaches flows: own auth (relative paths resolved) or inherit
|
|
933
943
|
// the manifest's. Fixes the blocker where authed routes redirected to login.
|
|
934
944
|
// F074.36 — and the run now REPORTS which of those two it used, or why neither.
|