@broberg/lens-daemon 0.1.14 → 0.1.18
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 +15 -1
- package/src/flow.ts +218 -7
- package/src/mcp.ts +3 -2
- package/src/service.ts +9 -4
- package/src/storage.ts +3 -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.18",
|
|
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.13.1",
|
|
19
|
+
"@broberg/lens-svg": "0.1.0"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"@happy-dom/global-registrator": "^20.10.6",
|
package/src/extension-flow.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import { cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
17
17
|
import { tmpdir } from 'node:os';
|
|
18
|
+
import type { BrowserContext } from 'playwright';
|
|
18
19
|
import { join } from 'node:path';
|
|
19
20
|
|
|
20
21
|
interface Manifest {
|
|
@@ -80,3 +81,84 @@ export async function prepareExtension(path: string, grantHostAccess: boolean):
|
|
|
80
81
|
export const SIMULATED_ACCESS_NOTE =
|
|
81
82
|
'host access SIMULATED: the extension ran from a temporary copy that may read every page, ' +
|
|
82
83
|
'standing in for the toolbar click (activeTab) a headless run cannot make';
|
|
84
|
+
|
|
85
|
+
/** F009.126 — write `values` into the loaded extension's chrome.storage.local, from
|
|
86
|
+
* the extension's own context (MV3 service worker, or MV2 background page), so the
|
|
87
|
+
* popup's first render already finds them — a login it keeps there, for example.
|
|
88
|
+
* Read back and compared, so a seed that did not land fails the run here instead
|
|
89
|
+
* of showing up later as a popup that is mysteriously logged out. */
|
|
90
|
+
export async function seedExtensionStorage(context: BrowserContext, values: Record<string, unknown>): Promise<void> {
|
|
91
|
+
const keys = Object.keys(values);
|
|
92
|
+
// An MV3 service worker can be stopped and restarted by Chrome at any moment, and an
|
|
93
|
+
// evaluate on the stopped one never settles — observed as a 90 s hang under a loaded
|
|
94
|
+
// test suite, never in isolation. So every attempt has a deadline, and each retry asks
|
|
95
|
+
// the context for the worker that is live NOW instead of reusing the old handle.
|
|
96
|
+
let back: Record<string, unknown> | null | undefined;
|
|
97
|
+
let lastError = 'no attempt made';
|
|
98
|
+
for (let attempt = 1; attempt <= SEED_ATTEMPTS && back === undefined; attempt++) {
|
|
99
|
+
const target =
|
|
100
|
+
context.serviceWorkers()[0] ??
|
|
101
|
+
context.backgroundPages()[0] ??
|
|
102
|
+
(await context.waitForEvent('serviceworker', { timeout: SEED_DEADLINE_MS }).catch(() => undefined));
|
|
103
|
+
if (!target) {
|
|
104
|
+
lastError = 'no service worker or background page to write chrome.storage.local from';
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
back = await deadline(
|
|
109
|
+
target.evaluate(async (v: Record<string, unknown>) => {
|
|
110
|
+
const c = (globalThis as unknown as { chrome: { storage?: { local: { set(x: object): Promise<void>; get(k: string[]): Promise<Record<string, unknown>> } } } }).chrome;
|
|
111
|
+
if (!c.storage) return null;
|
|
112
|
+
await c.storage.local.set(v);
|
|
113
|
+
return c.storage.local.get(Object.keys(v));
|
|
114
|
+
}, values),
|
|
115
|
+
SEED_DEADLINE_MS,
|
|
116
|
+
);
|
|
117
|
+
} catch (err) {
|
|
118
|
+
lastError = err instanceof Error ? err.message : String(err);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (back === undefined) throw new Error(`storage_local: could not write chrome.storage.local after ${SEED_ATTEMPTS} attempts — ${lastError}`);
|
|
122
|
+
if (!back) throw new Error('storage_local: the extension has no chrome.storage — its manifest does not request the "storage" permission, so there is nowhere to seed');
|
|
123
|
+
const missing = keys.filter((k) => !sameJson(back![k], values[k]));
|
|
124
|
+
if (missing.length) throw new Error(`storage_local: chrome.storage.local did not keep ${missing.join(', ')} — read back differs from what was written`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const SEED_ATTEMPTS = 3;
|
|
128
|
+
const SEED_DEADLINE_MS = 5_000;
|
|
129
|
+
|
|
130
|
+
/** Reject after `ms` instead of waiting forever on a call that will never settle. */
|
|
131
|
+
export function deadline<T>(p: Promise<T>, ms: number): Promise<T> {
|
|
132
|
+
return new Promise<T>((resolve, reject) => {
|
|
133
|
+
const t = setTimeout(() => reject(new Error(`no answer within ${ms} ms`)), ms);
|
|
134
|
+
t.unref?.();
|
|
135
|
+
p.then(
|
|
136
|
+
(v) => {
|
|
137
|
+
clearTimeout(t);
|
|
138
|
+
resolve(v);
|
|
139
|
+
},
|
|
140
|
+
(e) => {
|
|
141
|
+
clearTimeout(t);
|
|
142
|
+
reject(e);
|
|
143
|
+
},
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** F009.127 — structural equality for JSON values, blind to object key ORDER.
|
|
149
|
+
* chrome.storage hands an object back with its keys reordered, so comparing
|
|
150
|
+
* JSON text refused a login object that had landed perfectly. Array order and
|
|
151
|
+
* every value still count. Exported for test. */
|
|
152
|
+
export function sameJson(a: unknown, b: unknown): boolean {
|
|
153
|
+
if (a === b) return true;
|
|
154
|
+
if (typeof a !== typeof b || a === null || b === null || typeof a !== 'object') return false;
|
|
155
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
156
|
+
if (Array.isArray(a)) {
|
|
157
|
+
const bb = b as unknown[];
|
|
158
|
+
return a.length === bb.length && a.every((v, i) => sameJson(v, bb[i]));
|
|
159
|
+
}
|
|
160
|
+
const ao = a as Record<string, unknown>;
|
|
161
|
+
const bo = b as Record<string, unknown>;
|
|
162
|
+
const ak = Object.keys(ao);
|
|
163
|
+
return ak.length === Object.keys(bo).length && ak.every((k) => Object.hasOwn(bo, k) && sameJson(ao[k], bo[k]));
|
|
164
|
+
}
|
package/src/flow-store.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
9
9
|
import type { LensFlowRunRow } from './schema.ts';
|
|
10
10
|
import { artifactKeys, type LensStorage } from './storage.ts';
|
|
11
11
|
import { enqueueArtifacts } from './artifact-uploads.ts';
|
|
12
|
-
import type { FlowResult, FailedResponse } from './flow.ts';
|
|
12
|
+
import type { FlowDownload, FlowResult, FailedResponse } from './flow.ts';
|
|
13
13
|
|
|
14
14
|
/** A per-step entry as persisted (screenshot bytes already offloaded to an
|
|
15
15
|
* artifact key — null when the step took no shot). */
|
|
@@ -110,6 +110,18 @@ export async function recordFlowRun(
|
|
|
110
110
|
await storage.put(videoKey, result.video);
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
// F009.128 — downloaded files go where the stills go; the row keeps only the key.
|
|
114
|
+
const storedDownloads: Array<Omit<FlowDownload, 'bytes'> & { key?: string }> = [];
|
|
115
|
+
for (const [n, d] of (result.downloads ?? []).entries()) {
|
|
116
|
+
const { bytes, ...meta } = d;
|
|
117
|
+
let key: string | undefined;
|
|
118
|
+
if (bytes) {
|
|
119
|
+
key = artifactKeys.flowDownload(projectSlug, id, n, d.name);
|
|
120
|
+
await storage.put(key, bytes);
|
|
121
|
+
}
|
|
122
|
+
storedDownloads.push({ ...meta, ...(key ? { key } : {}) });
|
|
123
|
+
}
|
|
124
|
+
|
|
113
125
|
const row: LensFlowRunRow = {
|
|
114
126
|
id,
|
|
115
127
|
project_slug: projectSlug,
|
|
@@ -157,6 +169,8 @@ export async function recordFlowRun(
|
|
|
157
169
|
...(result.extension ? { extension: result.extension } : {}),
|
|
158
170
|
...(result.password_in_url ? { password_in_url: result.password_in_url } : {}),
|
|
159
171
|
...(result.not_listening ? { not_listening: result.not_listening } : {}),
|
|
172
|
+
// F009.128 — always written when the run reported it, empty included.
|
|
173
|
+
...(result.downloads ? { downloads: storedDownloads } : {}),
|
|
160
174
|
}),
|
|
161
175
|
};
|
|
162
176
|
|
package/src/flow.ts
CHANGED
|
@@ -24,7 +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
|
+
import { deadline, defaultPopupOf, prepareExtension, seedExtensionStorage, SIMULATED_ACCESS_NOTE } from './extension-flow.ts';
|
|
28
28
|
import { authPrepare, type AuthConfig, type AuthEnv } from './auth/index.ts';
|
|
29
29
|
import { takeShot, settleForShot } from './capture.ts';
|
|
30
30
|
import { runCritic, topFindingMessage, type CriticMode, type CriticReport } from './critic.ts';
|
|
@@ -158,7 +158,11 @@ export type FlowStep =
|
|
|
158
158
|
// F009.125 — open the loaded extension's popup as its own page in the SAME browser,
|
|
159
159
|
// then put the page under test back in front so the extension sees it as the active
|
|
160
160
|
// tab (measured). `path` defaults to the manifest's action.default_popup.
|
|
161
|
-
| { type: 'openPopup'; path?: string; label?: string; timeout?: number }
|
|
161
|
+
| { type: 'openPopup'; path?: string; label?: string; timeout?: number }
|
|
162
|
+
// F009.126 — pass when the run made a matching request (page, popup, any page in the
|
|
163
|
+
// run's browser). `url` is a substring, or a regex with match:"regex" (a path like
|
|
164
|
+
// "/api/clips" would otherwise read as a /pattern/flags literal). Fails listing what WAS seen.
|
|
165
|
+
| { type: 'expectRequest'; url: string; match?: 'contains' | 'regex'; method?: string; status?: number; label?: string; timeout?: number };
|
|
162
166
|
|
|
163
167
|
export interface Flow {
|
|
164
168
|
name: string;
|
|
@@ -187,6 +191,9 @@ export interface Flow {
|
|
|
187
191
|
export interface FlowExtension {
|
|
188
192
|
path: string;
|
|
189
193
|
grant_host_access?: boolean;
|
|
194
|
+
/** F009.126 — written to the extension's chrome.storage.local before step 0, so a
|
|
195
|
+
* popup that keeps its login there starts signed in (OAuth cannot run headless). */
|
|
196
|
+
storage_local?: Record<string, unknown>;
|
|
190
197
|
}
|
|
191
198
|
|
|
192
199
|
/**
|
|
@@ -352,7 +359,36 @@ export class InstrumentError extends Error {
|
|
|
352
359
|
}
|
|
353
360
|
}
|
|
354
361
|
|
|
362
|
+
/** F009.128 — one file the run's browser downloaded. `bytes` is dropped once the
|
|
363
|
+
* store has written it; a reader gets `url` instead. */
|
|
364
|
+
export interface FlowDownload {
|
|
365
|
+
name: string;
|
|
366
|
+
size: number;
|
|
367
|
+
/** Which page triggered it: the page under test, the extension popup, or another. */
|
|
368
|
+
page: 'page' | 'popup' | 'other';
|
|
369
|
+
/** Index of the step that was running when it started; null before the first. */
|
|
370
|
+
step: number | null;
|
|
371
|
+
bytes?: Buffer;
|
|
372
|
+
error?: string;
|
|
373
|
+
/** Not kept because a limit was hit (MAX_DOWNLOADS / MAX_DOWNLOAD_BYTES). */
|
|
374
|
+
skipped?: 'cap';
|
|
375
|
+
}
|
|
376
|
+
export const MAX_DOWNLOADS = 10;
|
|
377
|
+
export const MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024;
|
|
378
|
+
const DOWNLOAD_SETTLE_MS = 5_000;
|
|
379
|
+
const DOWNLOAD_FINISH_MS = 30_000;
|
|
380
|
+
|
|
381
|
+
/** F009.128 — wait for the run's downloads, bounded. Whatever has not finished by
|
|
382
|
+
* then is marked as such, never dropped and never waited on forever. Exported for test. */
|
|
383
|
+
export async function finishDownloads(pending: Promise<void>[], downloads: FlowDownload[], ms: number): Promise<void> {
|
|
384
|
+
await deadline(Promise.allSettled(pending), ms).catch(() => {
|
|
385
|
+
for (const d of downloads) if (!d.bytes && !d.error && !d.skipped) d.error = `did not finish within ${ms} ms`;
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
355
389
|
export interface FlowResult {
|
|
390
|
+
/** F009.128 — files the run downloaded. Always present on a finished run. */
|
|
391
|
+
downloads?: FlowDownload[];
|
|
356
392
|
/** F009.125 — the extension this run loaded: its id (read at load time), the
|
|
357
393
|
* build path the caller named, and whether host access was SIMULATED on a copy. */
|
|
358
394
|
extension?: { id: string; path: string; host_access: 'as-built' | 'simulated'; note?: string } | null;
|
|
@@ -425,6 +461,8 @@ export const STEP_TYPES = new Set([
|
|
|
425
461
|
'waitForReady',
|
|
426
462
|
// F009.125 — drive a loaded extension's popup
|
|
427
463
|
'openPopup',
|
|
464
|
+
// F009.126 — assert a request the run made
|
|
465
|
+
'expectRequest',
|
|
428
466
|
]);
|
|
429
467
|
|
|
430
468
|
/** Validate + normalise an untrusted flow (HTTP/MCP boundary). Throws on the
|
|
@@ -458,13 +496,21 @@ export function parseFlow(input: unknown): Flow {
|
|
|
458
496
|
if (f.extension !== undefined) {
|
|
459
497
|
const e = f.extension as unknown as Record<string, unknown>;
|
|
460
498
|
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}');
|
|
499
|
+
if (!keys || typeof e.path !== 'string' || !e.path || keys.some((k) => k !== 'path' && k !== 'grant_host_access' && k !== 'storage_local')) {
|
|
500
|
+
throw new Error('flow.extension must be {path: "<unpacked build dir>", grant_host_access?: boolean, storage_local?: {…}}');
|
|
463
501
|
}
|
|
464
502
|
if (e.grant_host_access !== undefined && typeof e.grant_host_access !== 'boolean') {
|
|
465
503
|
throw new Error('flow.extension.grant_host_access must be a boolean');
|
|
466
504
|
}
|
|
467
|
-
|
|
505
|
+
const sl = e.storage_local;
|
|
506
|
+
if (sl !== undefined && (sl === null || typeof sl !== 'object' || Array.isArray(sl))) {
|
|
507
|
+
throw new Error('flow.extension.storage_local must be an object of {key: JSON value} — it is passed to chrome.storage.local.set as-is');
|
|
508
|
+
}
|
|
509
|
+
extension = {
|
|
510
|
+
path: e.path,
|
|
511
|
+
...(e.grant_host_access === true ? { grant_host_access: true } : {}),
|
|
512
|
+
...(sl !== undefined ? { storage_local: sl as Record<string, unknown> } : {}),
|
|
513
|
+
};
|
|
468
514
|
}
|
|
469
515
|
validateAndNormalizeSteps(f.steps);
|
|
470
516
|
if (!extension && usesPopup(f.steps as FlowStep[])) {
|
|
@@ -482,6 +528,40 @@ export function parseFlow(input: unknown): Flow {
|
|
|
482
528
|
};
|
|
483
529
|
}
|
|
484
530
|
|
|
531
|
+
function requestRegex(u: string): RegExp {
|
|
532
|
+
try {
|
|
533
|
+
return new RegExp(u);
|
|
534
|
+
} catch (err) {
|
|
535
|
+
throw new Error(`expectRequest: url ${u} is not a valid regex — ${err instanceof Error ? err.message : String(err)}`);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** F009.126 — one request the run's browser made, as expectRequest sees it. */
|
|
540
|
+
export interface SeenRequest {
|
|
541
|
+
method: string;
|
|
542
|
+
url: string;
|
|
543
|
+
/** Playwright's resource type; 'document' is a navigation (and what a link download is). */
|
|
544
|
+
type?: string;
|
|
545
|
+
/** null while in flight, or when it failed before a response. */
|
|
546
|
+
status: number | null;
|
|
547
|
+
failed?: string;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** F009.126 — does this request satisfy the step? Exported for test. */
|
|
551
|
+
export function requestMatches(r: SeenRequest, want: { url: string; match?: 'contains' | 'regex'; method?: string; status?: number }): boolean {
|
|
552
|
+
const urlOk = want.match === 'regex' ? requestRegex(want.url).test(r.url) : r.url.includes(want.url);
|
|
553
|
+
if (!urlOk) return false;
|
|
554
|
+
if (want.method && r.method.toUpperCase() !== want.method.toUpperCase()) return false;
|
|
555
|
+
if (want.status !== undefined && r.status !== want.status) return false;
|
|
556
|
+
return true;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** Query strings can carry tokens; a failure report shows the path only. */
|
|
560
|
+
function redactQuery(u: string): string {
|
|
561
|
+
const q = u.indexOf('?');
|
|
562
|
+
return q === -1 ? u : `${u.slice(0, q)}?…`;
|
|
563
|
+
}
|
|
564
|
+
|
|
485
565
|
function usesPopup(steps: FlowStep[]): boolean {
|
|
486
566
|
return steps.some(
|
|
487
567
|
(st) =>
|
|
@@ -551,6 +631,7 @@ export const STEP_FIELDS: Record<string, string[]> = {
|
|
|
551
631
|
conditional: ['testid', 'present', 'steps'],
|
|
552
632
|
loop: ['data', 'steps', 'parallel'],
|
|
553
633
|
openPopup: ['path'],
|
|
634
|
+
expectRequest: ['url', 'match', 'method', 'status'],
|
|
554
635
|
};
|
|
555
636
|
|
|
556
637
|
function validateAndNormalizeSteps(steps: unknown[], depth = 0, inParallel = false): void {
|
|
@@ -633,6 +714,20 @@ function validateAndNormalizeSteps(steps: unknown[], depth = 0, inParallel = fal
|
|
|
633
714
|
}
|
|
634
715
|
if ('browser' in s || inParallel) throw new Error(`step ${index} (openPopup): the popup opens in the main browser only`);
|
|
635
716
|
}
|
|
717
|
+
if (s.type === 'expectRequest') {
|
|
718
|
+
const r = s as { url?: unknown; match?: unknown; method?: unknown; status?: unknown };
|
|
719
|
+
if (typeof r.url !== 'string' || !r.url) throw new Error(`step ${index} (expectRequest): url is required — a substring, or a regex with match:"regex"`);
|
|
720
|
+
if (r.match !== undefined && r.match !== 'contains' && r.match !== 'regex') {
|
|
721
|
+
throw new Error(`step ${index} (expectRequest): match must be "contains" (default) or "regex"`);
|
|
722
|
+
}
|
|
723
|
+
if (r.match === 'regex') requestRegex(r.url); // throws on a bad pattern now, not mid-run
|
|
724
|
+
if (r.method !== undefined && (typeof r.method !== 'string' || !/^[A-Za-z]+$/.test(r.method))) {
|
|
725
|
+
throw new Error(`step ${index} (expectRequest): method must be an HTTP method like "POST"`);
|
|
726
|
+
}
|
|
727
|
+
if (r.status !== undefined && (typeof r.status !== 'number' || !Number.isInteger(r.status) || r.status < 100 || r.status > 599)) {
|
|
728
|
+
throw new Error(`step ${index} (expectRequest): status must be an HTTP status code (100–599)`);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
636
731
|
if (s.type === 'autocomplete') {
|
|
637
732
|
if (typeof (s as { value?: unknown }).value !== 'string') throw new Error('autocomplete step requires a string value');
|
|
638
733
|
if (typeof (s as { option?: unknown }).option !== 'string') throw new Error('autocomplete step requires a string option testid');
|
|
@@ -1365,6 +1460,7 @@ function describe(step: FlowStep): string {
|
|
|
1365
1460
|
case 'conditional': return `${step.present ? 'if' : 'unless'} [${step.testid}]`;
|
|
1366
1461
|
case 'loop': return 'loop';
|
|
1367
1462
|
case 'openPopup': return `openPopup ${step.path ?? '(manifest popup)'}`;
|
|
1463
|
+
case 'expectRequest': return `expectRequest ${step.method ? `${step.method.toUpperCase()} ` : ''}${step.url}${step.status ? ` → ${step.status}` : ''}`;
|
|
1368
1464
|
case 'waitForBuild': return `waitForBuild${step.sha ? ` ${step.sha.slice(0, 8)}` : ' (changed)'}`;
|
|
1369
1465
|
// F255.4 — show the offset in the step label so a positioned click is
|
|
1370
1466
|
// recognisable in the report (a plain "click canvas" hides where it aimed).
|
|
@@ -1497,6 +1593,14 @@ export async function clickAndActivate(
|
|
|
1497
1593
|
if (t === 'xhr' || t === 'fetch' || t === 'document') requests += 1;
|
|
1498
1594
|
};
|
|
1499
1595
|
page.on('request', onRequest);
|
|
1596
|
+
// F009.128 — a download IS an effect. A button whose only job is a download
|
|
1597
|
+
// (a blob handed to <a download>) touches no DOM and makes no request, so it
|
|
1598
|
+
// read as a silent no-op and got pressed twice: two files for one click.
|
|
1599
|
+
let downloaded = false;
|
|
1600
|
+
const onDownload = () => {
|
|
1601
|
+
downloaded = true;
|
|
1602
|
+
};
|
|
1603
|
+
page.on('download', onDownload);
|
|
1500
1604
|
const urlBefore = page.url();
|
|
1501
1605
|
await page
|
|
1502
1606
|
.evaluate(() => {
|
|
@@ -1541,7 +1645,7 @@ export async function clickAndActivate(
|
|
|
1541
1645
|
const mutations = await page
|
|
1542
1646
|
.evaluate(() => (window as unknown as { __lensMut?: number }).__lensMut ?? 0)
|
|
1543
1647
|
.catch(() => 1); // page navigated away → that IS an effect
|
|
1544
|
-
if (mutations > 0 || requests > 0 || page.url() !== urlBefore) return false;
|
|
1648
|
+
if (mutations > 0 || requests > 0 || downloaded || page.url() !== urlBefore) return false;
|
|
1545
1649
|
const focusMoved = await page
|
|
1546
1650
|
.evaluate(
|
|
1547
1651
|
([before, clicked]) => {
|
|
@@ -1564,6 +1668,7 @@ export async function clickAndActivate(
|
|
|
1564
1668
|
return true;
|
|
1565
1669
|
} finally {
|
|
1566
1670
|
page.off('request', onRequest);
|
|
1671
|
+
page.off('download', onDownload);
|
|
1567
1672
|
await focusBefore.dispose().catch(() => {});
|
|
1568
1673
|
await target?.dispose().catch(() => {});
|
|
1569
1674
|
await page
|
|
@@ -2329,6 +2434,8 @@ async function execStep(page: Page, step: FlowStep, base: string, vars: Record<s
|
|
|
2329
2434
|
throw new Error('loop steps are flattened before execution');
|
|
2330
2435
|
case 'openPopup':
|
|
2331
2436
|
throw new Error('openPopup steps are handled by runSeq, never execStep');
|
|
2437
|
+
case 'expectRequest':
|
|
2438
|
+
throw new Error('expectRequest steps are handled by runSeq, never execStep');
|
|
2332
2439
|
}
|
|
2333
2440
|
}
|
|
2334
2441
|
|
|
@@ -2674,6 +2781,7 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
|
|
|
2674
2781
|
// F213.11 — read BEFORE the step runs, so the number is "how long after the
|
|
2675
2782
|
// page loaded did this start", never "how long did it take".
|
|
2676
2783
|
const stepStartedAt = Date.now();
|
|
2784
|
+
currentStep = i;
|
|
2677
2785
|
const msAfterNav = stepStartedAt - lastNavAt;
|
|
2678
2786
|
// F213.13 — a goto's reading is measured from the PREVIOUS navigation, so it
|
|
2679
2787
|
// is not the same quantity as its siblings and must not sit in their column.
|
|
@@ -2693,6 +2801,8 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
|
|
|
2693
2801
|
const out =
|
|
2694
2802
|
step.type === 'openPopup'
|
|
2695
2803
|
? await openPopup(step)
|
|
2804
|
+
: step.type === 'expectRequest'
|
|
2805
|
+
? await expectRequest(step, vars)
|
|
2696
2806
|
: await execStep(sp, step, base, vars, opts.critic ?? 'off', opts.localPath, sink, filledPasswords, notListening, opts.secretRoot);
|
|
2697
2807
|
const obj = out !== null && !Buffer.isBuffer(out) && typeof out === 'object' ? out : null;
|
|
2698
2808
|
const inspect = obj && '__inspect' in obj ? (obj as { __inspect: string }).__inspect : null;
|
|
@@ -2859,6 +2969,87 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
|
|
|
2859
2969
|
extCtx.popup = popup;
|
|
2860
2970
|
return null;
|
|
2861
2971
|
};
|
|
2972
|
+
// F009.128 — files the run downloads. A download is awaited before the context
|
|
2973
|
+
// closes; otherwise the bytes die with the browser and the report says nothing.
|
|
2974
|
+
const downloads: FlowDownload[] = [];
|
|
2975
|
+
const pendingDownloads: Promise<void>[] = [];
|
|
2976
|
+
let currentStep: number | null = null;
|
|
2977
|
+
const watchDownloads = (context: BrowserContext): void => {
|
|
2978
|
+
const hooked = new WeakSet<Page>();
|
|
2979
|
+
const hook = (p: Page) => {
|
|
2980
|
+
if (hooked.has(p)) return;
|
|
2981
|
+
hooked.add(p);
|
|
2982
|
+
p.on('download', (d) => {
|
|
2983
|
+
const entry: FlowDownload = {
|
|
2984
|
+
name: d.suggestedFilename(),
|
|
2985
|
+
size: 0,
|
|
2986
|
+
page: p === extCtx?.popup ? 'popup' : p === extCtx?.main || p === videoPage ? 'page' : 'other',
|
|
2987
|
+
step: currentStep,
|
|
2988
|
+
};
|
|
2989
|
+
downloads.push(entry);
|
|
2990
|
+
if (downloads.length > MAX_DOWNLOADS) {
|
|
2991
|
+
entry.skipped = 'cap';
|
|
2992
|
+
pendingDownloads.push(d.cancel().catch(() => {}));
|
|
2993
|
+
return;
|
|
2994
|
+
}
|
|
2995
|
+
pendingDownloads.push(
|
|
2996
|
+
(async () => {
|
|
2997
|
+
const failure = await d.failure();
|
|
2998
|
+
// `??=`: once the run gave up waiting, the context closing cancels the
|
|
2999
|
+
// download — and 'canceled' would hide that it simply never finished.
|
|
3000
|
+
if (failure) {
|
|
3001
|
+
entry.error ??= failure;
|
|
3002
|
+
return;
|
|
3003
|
+
}
|
|
3004
|
+
const path = await d.path();
|
|
3005
|
+
const bytes = await readFile(path);
|
|
3006
|
+
entry.size = bytes.length;
|
|
3007
|
+
if (bytes.length > MAX_DOWNLOAD_BYTES) entry.skipped = 'cap';
|
|
3008
|
+
else entry.bytes = bytes;
|
|
3009
|
+
})().catch((err) => {
|
|
3010
|
+
entry.error ??= err instanceof Error ? err.message : String(err);
|
|
3011
|
+
}),
|
|
3012
|
+
);
|
|
3013
|
+
});
|
|
3014
|
+
};
|
|
3015
|
+
context.pages().forEach(hook);
|
|
3016
|
+
context.on('page', hook);
|
|
3017
|
+
};
|
|
3018
|
+
// F009.126 — every request the run's main browser makes, from the first step on.
|
|
3019
|
+
const seenRequests: SeenRequest[] = [];
|
|
3020
|
+
const watchRequests = (context: BrowserContext): void => {
|
|
3021
|
+
const byReq = new Map<object, SeenRequest>();
|
|
3022
|
+
context.on('request', (req) => {
|
|
3023
|
+
const r: SeenRequest = { method: req.method(), url: req.url(), status: null, type: req.resourceType() };
|
|
3024
|
+
byReq.set(req, r);
|
|
3025
|
+
seenRequests.push(r);
|
|
3026
|
+
});
|
|
3027
|
+
context.on('requestfinished', async (req) => {
|
|
3028
|
+
const r = byReq.get(req);
|
|
3029
|
+
if (r) r.status = (await req.response().catch(() => null))?.status() ?? null;
|
|
3030
|
+
});
|
|
3031
|
+
context.on('requestfailed', (req) => {
|
|
3032
|
+
const r = byReq.get(req);
|
|
3033
|
+
if (r) r.failed = req.failure()?.errorText ?? 'failed';
|
|
3034
|
+
});
|
|
3035
|
+
};
|
|
3036
|
+
const expectRequest = async (step: Extract<FlowStep, { type: 'expectRequest' }>, vars: Record<string, unknown>): Promise<null> => {
|
|
3037
|
+
const want = { url: subst(step.url, vars), match: step.match, method: step.method, status: step.status };
|
|
3038
|
+
const deadline = Date.now() + stepTimeout(step);
|
|
3039
|
+
for (;;) {
|
|
3040
|
+
if (seenRequests.some((r) => requestMatches(r, want))) return null;
|
|
3041
|
+
if (Date.now() >= deadline) break;
|
|
3042
|
+
await new Promise((res) => setTimeout(res, 100));
|
|
3043
|
+
}
|
|
3044
|
+
const seen = seenRequests
|
|
3045
|
+
.filter((r) => !/^(data|chrome-extension):/.test(r.url))
|
|
3046
|
+
.slice(-10)
|
|
3047
|
+
.map((r) => `${r.method} ${redactQuery(r.url)} → ${r.failed ? `failed (${r.failed})` : r.status ?? 'no response'}`);
|
|
3048
|
+
throw new Error(
|
|
3049
|
+
`no request matched ${want.method ? `${want.method.toUpperCase()} ` : ''}${want.url}${want.status ? ` → ${want.status}` : ''}. ` +
|
|
3050
|
+
(seen.length ? `Seen (last ${seen.length}): ${seen.join('; ')}.` : 'The run made no http(s) requests at all.'),
|
|
3051
|
+
);
|
|
3052
|
+
};
|
|
2862
3053
|
const readVideo = async (): Promise<Buffer | null> => {
|
|
2863
3054
|
if (!videoDir) return null;
|
|
2864
3055
|
try {
|
|
@@ -2912,8 +3103,11 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
|
|
|
2912
3103
|
async (page, context, ext) => {
|
|
2913
3104
|
videoPage = page;
|
|
2914
3105
|
collectFailures(page);
|
|
3106
|
+
watchRequests(context);
|
|
3107
|
+
watchDownloads(context);
|
|
2915
3108
|
if (ext && flow.extension && prepared) {
|
|
2916
3109
|
extCtx = { context, id: ext.id, main: page, popup: null };
|
|
3110
|
+
if (flow.extension.storage_local) await seedExtensionStorage(context, flow.extension.storage_local);
|
|
2917
3111
|
extensionInfo = {
|
|
2918
3112
|
id: ext.id,
|
|
2919
3113
|
path: flow.extension.path,
|
|
@@ -3012,6 +3206,22 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
|
|
|
3012
3206
|
} catch {
|
|
3013
3207
|
// page already closed — final_url stays null (not measured)
|
|
3014
3208
|
}
|
|
3209
|
+
// F009.128 — before the context (and the files) go away. A link download is a
|
|
3210
|
+
// navigation whose 'download' event fires only once its headers arrive, which can
|
|
3211
|
+
// be AFTER the last step returned; so wait for navigations still in flight (bounded)
|
|
3212
|
+
// and only then for the downloads they turned into. Runs with nothing in flight
|
|
3213
|
+
// pay nothing.
|
|
3214
|
+
// A navigation Chromium turns into a download is ABORTED as a request
|
|
3215
|
+
// (net::ERR_ABORTED) and only then raises 'download' — so count those too.
|
|
3216
|
+
const settleUntil = Date.now() + DOWNLOAD_SETTLE_MS;
|
|
3217
|
+
const navInFlight = () => seenRequests.some((r) => r.type === 'document' && r.status === null && !r.failed);
|
|
3218
|
+
const becameDownload = () => seenRequests.filter((r) => r.type === 'document' && (r.failed ?? '').includes('ERR_ABORTED')).length;
|
|
3219
|
+
while (Date.now() < settleUntil && (navInFlight() || downloads.length < becameDownload())) {
|
|
3220
|
+
await new Promise((res) => setTimeout(res, 100));
|
|
3221
|
+
}
|
|
3222
|
+
// Bounded: a download that never finishes must not hang the run. Whatever is
|
|
3223
|
+
// still unfinished is reported as such, never dropped.
|
|
3224
|
+
await finishDownloads(pendingDownloads, downloads, DOWNLOAD_FINISH_MS);
|
|
3015
3225
|
},
|
|
3016
3226
|
);
|
|
3017
3227
|
} catch (err) {
|
|
@@ -3019,7 +3229,7 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
|
|
|
3019
3229
|
// Keep the recording even on a crash — watching HOW it broke is the whole point.
|
|
3020
3230
|
const video = await readVideo();
|
|
3021
3231
|
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' };
|
|
3232
|
+
return { downloads, ...(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' };
|
|
3023
3233
|
}
|
|
3024
3234
|
|
|
3025
3235
|
const video = await readVideo();
|
|
@@ -3073,6 +3283,7 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
|
|
|
3073
3283
|
// context can never disagree about it.
|
|
3074
3284
|
reduced_motion: opts.reducedMotion ?? 'reduce',
|
|
3075
3285
|
...(extensionInfo ? { extension: extensionInfo } : {}),
|
|
3286
|
+
downloads,
|
|
3076
3287
|
};
|
|
3077
3288
|
}
|
|
3078
3289
|
|
package/src/mcp.ts
CHANGED
|
@@ -364,16 +364,17 @@ export const TOOLS = [
|
|
|
364
364
|
type: 'object',
|
|
365
365
|
required: ['path'],
|
|
366
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.',
|
|
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. F009.126 — storage_local:{key: JSON} is written to the extension\'s chrome.storage.local before step 0 (read back and compared), so a popup that keeps its login there starts signed in; needs the "storage" permission in the manifest. Pair with {type:"expectRequest", url, match?:"contains"|"regex", method?, status?} to assert a request the page, popup or extension worker made (fails listing what was seen).',
|
|
368
368
|
properties: {
|
|
369
369
|
path: { type: 'string' },
|
|
370
370
|
grant_host_access: { type: 'boolean' },
|
|
371
|
+
storage_local: { type: 'object', description: 'F009.126 — {key: JSON value} for chrome.storage.local, set before step 0.' },
|
|
371
372
|
},
|
|
372
373
|
},
|
|
373
374
|
steps: {
|
|
374
375
|
type: 'array',
|
|
375
376
|
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}}.',
|
|
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).' } } },
|
|
377
|
+
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).' }, method: { type: 'string', description: 'F009.126 — expectRequest: HTTP method to require, e.g. "POST".' }, status: { type: 'number', description: 'F009.126 — expectRequest: response status to require, e.g. 201.' } } },
|
|
377
378
|
},
|
|
378
379
|
},
|
|
379
380
|
},
|
package/src/service.ts
CHANGED
|
@@ -666,11 +666,16 @@ function parseAuthJson(raw: string | null | undefined): { source: string; adapte
|
|
|
666
666
|
/** NULL = the row predates the column, so we have no answer; `{}` = the run
|
|
667
667
|
* looked and found nothing. Malformed JSON is reported as unknown rather than
|
|
668
668
|
* as "clean": a parse failure must never read as an all-clear. */
|
|
669
|
-
function parseFindingsJson(raw: string | null | undefined): Record<string, unknown> | null {
|
|
669
|
+
function parseFindingsJson(raw: string | null | undefined, port?: number): Record<string, unknown> | null {
|
|
670
670
|
if (raw == null) return null;
|
|
671
671
|
try {
|
|
672
672
|
const v = JSON.parse(raw);
|
|
673
|
-
|
|
673
|
+
if (!v || typeof v !== 'object') return null;
|
|
674
|
+
// F009.128 — a stored download carries its storage key; a reader gets a url.
|
|
675
|
+
if (port !== undefined && Array.isArray(v.downloads)) {
|
|
676
|
+
v.downloads = v.downloads.map(({ key, ...d }: { key?: string }) => (key ? { ...d, url: artifactUrl(port, key) } : d));
|
|
677
|
+
}
|
|
678
|
+
return v as Record<string, unknown>;
|
|
674
679
|
} catch {
|
|
675
680
|
return null;
|
|
676
681
|
}
|
|
@@ -748,7 +753,7 @@ export function flowRunView(row: LensFlowRunRow, port: number) {
|
|
|
748
753
|
// and lens_list_flow_runs for the string and found neither — "the instrument
|
|
749
754
|
// measures right and nobody is told", which is the same shape as the probe
|
|
750
755
|
// that sat unwired to `fill` for a month.
|
|
751
|
-
findings: parseFindingsJson(row.findings_json),
|
|
756
|
+
findings: parseFindingsJson(row.findings_json, port),
|
|
752
757
|
steps,
|
|
753
758
|
// F255.5 — present only when the run was recorded. Same artifact mechanism as
|
|
754
759
|
// the step screenshots, so a consumer fetches it exactly the same way.
|
|
@@ -869,7 +874,7 @@ export function toCloudFlowResult(result: EngineFlowResult, row: LensFlowRunRow,
|
|
|
869
874
|
// (a CI runner with no daemon) is the consumer LEAST able to go and look
|
|
870
875
|
// somewhere else, so leaving them off this shape would be the gap at its
|
|
871
876
|
// worst.
|
|
872
|
-
findings: parseFindingsJson(row.findings_json),
|
|
877
|
+
findings: parseFindingsJson(row.findings_json, port),
|
|
873
878
|
steps: result.steps.map((s) => ({
|
|
874
879
|
index: s.index,
|
|
875
880
|
action: s.action,
|
package/src/storage.ts
CHANGED
|
@@ -118,6 +118,9 @@ export const artifactKeys = {
|
|
|
118
118
|
`${project}/flows/${flowRunId}/step-${stepIndex}.png`,
|
|
119
119
|
// F255.5 — the whole driven run as a video, alongside its step stills.
|
|
120
120
|
flowVideo: (project: string, flowRunId: string) => `${project}/flows/${flowRunId}/run.webm`,
|
|
121
|
+
// F009.128 — a file the run downloaded. Numbered so two files with one name both survive.
|
|
122
|
+
flowDownload: (project: string, flowRunId: string, n: number, name: string) =>
|
|
123
|
+
`${project}/flows/${flowRunId}/download-${n}-${name.replace(/[^\w.-]+/g, '_').slice(0, 120)}`,
|
|
121
124
|
// F074.63 — a vector export (PDF). Its own prefix, not runs/: an export is not
|
|
122
125
|
// a capture, carries no baseline and must never be diffed against one.
|
|
123
126
|
exportDoc: (project: string, exportId: string, ext: string) =>
|