cssprobe-cli 0.3.0 → 0.4.0

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.
Files changed (44) hide show
  1. package/AGENTS.md +59 -6
  2. package/CHANGELOG.md +21 -0
  3. package/README.md +111 -2
  4. package/dist/browser/frames.d.ts +15 -0
  5. package/dist/browser/frames.js +60 -0
  6. package/dist/browser/interactions.d.ts +49 -0
  7. package/dist/browser/interactions.js +96 -0
  8. package/dist/browser/launcher.d.ts +30 -2
  9. package/dist/browser/launcher.js +141 -34
  10. package/dist/browser/sampler.d.ts +43 -0
  11. package/dist/browser/sampler.js +111 -0
  12. package/dist/cli/commands.d.ts +3 -0
  13. package/dist/cli/commands.js +162 -4
  14. package/dist/cli/input.d.ts +6 -0
  15. package/dist/cli/input.js +24 -0
  16. package/dist/cli/program.js +171 -1
  17. package/dist/cli/session-ops.d.ts +10 -1
  18. package/dist/cli/session-ops.js +27 -56
  19. package/dist/cli/state-check.d.ts +17 -0
  20. package/dist/cli/state-check.js +43 -0
  21. package/dist/cli/url-guard.d.ts +2 -0
  22. package/dist/cli/url-guard.js +24 -0
  23. package/dist/daemon/daemon.js +162 -18
  24. package/dist/daemon/expect.d.ts +8 -0
  25. package/dist/daemon/expect.js +47 -0
  26. package/dist/daemon/flowRunner.d.ts +73 -0
  27. package/dist/daemon/flowRunner.js +177 -0
  28. package/dist/engine/evalSerializer.d.ts +1 -0
  29. package/dist/engine/evalSerializer.js +43 -0
  30. package/dist/help.json +156 -16
  31. package/dist/installer/skill-template.js +43 -0
  32. package/dist/scenario/assertions.d.ts +13 -0
  33. package/dist/scenario/assertions.js +111 -0
  34. package/dist/scenario/reporters.d.ts +13 -0
  35. package/dist/scenario/reporters.js +79 -0
  36. package/dist/scenario/runner.d.ts +10 -0
  37. package/dist/scenario/runner.js +244 -0
  38. package/dist/scenario/schema.d.ts +1210 -0
  39. package/dist/scenario/schema.js +145 -0
  40. package/dist/utils/screenshot.d.ts +1 -0
  41. package/dist/utils/screenshot.js +58 -0
  42. package/docs/guide/installation.md +1 -1
  43. package/llms.txt +1 -1
  44. package/package.json +1 -1
@@ -17,6 +17,11 @@ const launcher_1 = require("../browser/launcher");
17
17
  const analyzer_1 = require("../engine/analyzer");
18
18
  const renderer_1 = require("../engine/renderer");
19
19
  const dom_tree_1 = require("../engine/renderers/dom-tree");
20
+ const interactions_1 = require("../browser/interactions");
21
+ const evalSerializer_1 = require("../engine/evalSerializer");
22
+ const flowRunner_1 = require("./flowRunner");
23
+ const frames_1 = require("../browser/frames");
24
+ const sampler_1 = require("../browser/sampler");
20
25
  const AsyncFunction = Object.getPrototypeOf(async function () { }).constructor;
21
26
  async function startDaemon(sessionName, clientInfo, options) {
22
27
  const sessionConfig = createSessionConfig(clientInfo, sessionName, options);
@@ -74,6 +79,18 @@ async function executeCommand(launcher, args) {
74
79
  const command = args._[0];
75
80
  const params = args._.slice(1);
76
81
  switch (command) {
82
+ case 'frames': {
83
+ const frames = (0, frames_1.describeFrames)(requirePage(launcher));
84
+ if (args.json)
85
+ return { text: JSON.stringify(frames, null, 2) };
86
+ return { text: (0, frames_1.formatFrames)(frames) };
87
+ }
88
+ case 'page-info': {
89
+ const page = requirePage(launcher);
90
+ return {
91
+ text: JSON.stringify({ url: page.url(), title: await page.title(), frames: (0, frames_1.describeFrames)(page).length }, null, 2),
92
+ };
93
+ }
77
94
  case 'goto': {
78
95
  const url = params[0] || 'about:blank';
79
96
  await launcher.open(url);
@@ -88,7 +105,7 @@ async function executeCommand(launcher, args) {
88
105
  downDepth: args.depth || 6,
89
106
  maxNodes: 60,
90
107
  upTo: 'html',
91
- });
108
+ }, (0, frames_1.resolveFrame)(requirePage(launcher), args.frame));
92
109
  const findings = (0, analyzer_1.analyze)(snapshot);
93
110
  if (args.json) {
94
111
  return { text: JSON.stringify((0, renderer_1.renderJSON)(snapshot, findings, args.brief), null, 2) };
@@ -106,7 +123,7 @@ async function executeCommand(launcher, args) {
106
123
  downDepth: args.depth || 6,
107
124
  maxNodes: 60,
108
125
  upTo: 'html',
109
- });
126
+ }, (0, frames_1.resolveFrame)(requirePage(launcher), args.frame));
110
127
  // Render tree only
111
128
  const lines = [];
112
129
  lines.push(`## DOM tree (${snapshot.downDepth} levels deep)`);
@@ -124,7 +141,7 @@ async function executeCommand(launcher, args) {
124
141
  downDepth: 6,
125
142
  maxNodes: 60,
126
143
  upTo: 'html',
127
- });
144
+ }, (0, frames_1.resolveFrame)(requirePage(launcher), args.frame));
128
145
  const findings = (0, analyzer_1.analyze)(snapshot);
129
146
  return { text: (0, renderer_1.renderMarkdown)(snapshot, findings, false, true) };
130
147
  }
@@ -137,7 +154,7 @@ async function executeCommand(launcher, args) {
137
154
  downDepth: 6,
138
155
  maxNodes: 60,
139
156
  upTo: 'html',
140
- });
157
+ }, (0, frames_1.resolveFrame)(requirePage(launcher), args.frame));
141
158
  const findings = (0, analyzer_1.analyze)(snapshot);
142
159
  const important = findings.filter(f => f.level === 'warning' || f.level === 'error');
143
160
  if (important.length === 0) {
@@ -152,11 +169,9 @@ async function executeCommand(launcher, args) {
152
169
  const css = params[0];
153
170
  if (!css)
154
171
  throw new Error('CSS is required for inject-css command');
155
- const page = launcher.getPage();
156
- if (!page)
157
- throw new Error('Browser is not open. Run: cssprobe-cli open <url>');
158
- await page.addStyleTag({ content: css });
159
- return { text: JSON.stringify({ success: true }) };
172
+ const frame = (0, frames_1.resolveFrame)(requirePage(launcher), args.frame);
173
+ await frame.addStyleTag({ content: css });
174
+ return { text: JSON.stringify({ success: true, frame: frame.name() || frame.url() }) };
160
175
  }
161
176
  case 'screenshot': {
162
177
  const page = launcher.getPage();
@@ -170,15 +185,25 @@ async function executeCommand(launcher, args) {
170
185
  const expression = params[0];
171
186
  if (!expression)
172
187
  throw new Error('Expression is required for eval command');
173
- const page = launcher.getPage();
174
- if (!page)
175
- throw new Error('Browser is not open. Run: cssprobe-cli open <url>');
176
- const result = await page.evaluate(expression);
177
- if (typeof result === 'string')
178
- return { text: result };
179
- if (result === undefined)
180
- return { text: 'undefined' };
181
- return { text: JSON.stringify(result, null, 2) };
188
+ const frame = (0, frames_1.resolveFrame)(requirePage(launcher), args.frame);
189
+ const handle = await frame.evaluateHandle(expression);
190
+ try {
191
+ const resolved = await handle.evaluateHandle((value) => Promise.resolve(value));
192
+ try {
193
+ const result = await resolved.evaluate(evalSerializer_1.serializeEvalResult);
194
+ if (typeof result === 'string')
195
+ return { text: result };
196
+ if (result === undefined)
197
+ return { text: 'undefined' };
198
+ return { text: JSON.stringify(result, null, 2) };
199
+ }
200
+ finally {
201
+ await resolved.dispose();
202
+ }
203
+ }
204
+ finally {
205
+ await handle.dispose();
206
+ }
182
207
  }
183
208
  case 'resize': {
184
209
  const width = parseInt(params[0], 10);
@@ -206,6 +231,111 @@ async function executeCommand(launcher, args) {
206
231
  return { text: JSON.stringify({ ok: true, value: null, note: 'call returned no value' }) };
207
232
  return { text: JSON.stringify(result, null, 2) };
208
233
  }
234
+ case 'click':
235
+ case 'hover': {
236
+ const selector = params[0];
237
+ if (!selector)
238
+ throw new Error(`Selector is required for ${command} command`);
239
+ const page = requirePage(launcher);
240
+ const opts = { nth: args.nth, force: args.force, timeout: args.timeout };
241
+ const result = command === 'click'
242
+ ? await (0, interactions_1.clickElement)(page, selector, opts)
243
+ : await (0, interactions_1.hoverElement)(page, selector, opts);
244
+ if (args.json) {
245
+ return { text: JSON.stringify({ ok: true, selector, matches: result.matches, nth: result.nth }, null, 2) };
246
+ }
247
+ return { text: formatTargetAction(command === 'click' ? 'Clicked' : 'Hovered', selector, result) };
248
+ }
249
+ case 'type': {
250
+ const selector = params[0];
251
+ const text = params[1];
252
+ if (!selector || text === undefined)
253
+ throw new Error('Usage: type <selector> <text>');
254
+ await (0, interactions_1.typeInto)(requirePage(launcher), selector, text, {
255
+ press: args.press,
256
+ sequential: args.sequential,
257
+ clear: args.clear,
258
+ delay: args.delay,
259
+ nth: args.nth,
260
+ timeout: args.timeout,
261
+ });
262
+ if (args.json) {
263
+ const payload = { ok: true, selector, length: text.length };
264
+ if (args.press)
265
+ payload.pressed = args.press;
266
+ return { text: JSON.stringify(payload, null, 2) };
267
+ }
268
+ const pressedNote = args.press ? ` then pressed ${args.press}` : '';
269
+ return { text: `Typed ${text.length} chars into ${selector}${pressedNote}` };
270
+ }
271
+ case 'wait-for': {
272
+ // selector is read from the parsed object: option flags are appended to
273
+ // the argv array after positional args, so params[0] is not reliable here.
274
+ const selector = args.selector;
275
+ const timeout = args.timeout ?? 30000;
276
+ const target = (0, interactions_1.resolveWaitTarget)(selector, {
277
+ js: args.js,
278
+ networkIdle: args['network-idle'],
279
+ state: args.state,
280
+ });
281
+ let elapsedMs;
282
+ try {
283
+ elapsedMs = await (0, interactions_1.waitForCondition)(requirePage(launcher), target, timeout);
284
+ }
285
+ catch (e) {
286
+ throw new Error(`wait-for failed for ${(0, interactions_1.describeWaitTarget)(target)} within ${timeout}ms: ${e.message}`);
287
+ }
288
+ if (args.json) {
289
+ const payload = {
290
+ ok: true,
291
+ waitedFor: target.kind === 'networkIdle' ? 'networkidle' : target.kind,
292
+ elapsedMs,
293
+ };
294
+ if (target.kind === 'selector')
295
+ payload.state = target.state;
296
+ return { text: JSON.stringify(payload, null, 2) };
297
+ }
298
+ const label = target.kind === 'selector' ? `${selector} (${target.state})` : (0, interactions_1.describeWaitTarget)(target);
299
+ return { text: `Wait satisfied: ${label} (${elapsedMs}ms)` };
300
+ }
301
+ case 'sample': {
302
+ const expression = params[0];
303
+ if (!expression)
304
+ throw new Error('Expression is required for sample command');
305
+ const interval = args.interval ?? 250;
306
+ const duration = args.duration ?? 5000;
307
+ if (duration <= 0)
308
+ throw new Error('--duration must be greater than 0');
309
+ if (interval <= 0)
310
+ throw new Error('--interval must be greater than 0');
311
+ const frame = (0, frames_1.resolveFrame)(requirePage(launcher), args.frame);
312
+ const points = await (0, sampler_1.sampleFrame)(frame, expression, {
313
+ intervalMs: interval,
314
+ durationMs: duration,
315
+ ignoreErrors: !!args['ignore-errors'],
316
+ });
317
+ const summary = (0, sampler_1.summarizeSamples)(points, args.key, 5000, args['tail-from']);
318
+ return { text: JSON.stringify({ expression, intervalMs: interval, summary, series: points }, null, 2) };
319
+ }
320
+ case 'wait-stable': {
321
+ const expression = params[0];
322
+ if (!expression)
323
+ throw new Error('Expression is required for wait-stable command');
324
+ const frame = (0, frames_1.resolveFrame)(requirePage(launcher), args.frame);
325
+ const result = await (0, sampler_1.waitForStableValue)(frame, expression, {
326
+ tolerance: args.tolerance,
327
+ windowMs: args.window,
328
+ timeoutMs: args.timeout,
329
+ intervalMs: args.interval,
330
+ });
331
+ return { text: JSON.stringify({ ok: true, expression, ...result }, null, 2) };
332
+ }
333
+ case 'run': {
334
+ const absPath = params[0];
335
+ if (!absPath)
336
+ throw new Error('Flow script path is required');
337
+ return await (0, flowRunner_1.runFlow)(launcher, absPath, { timeout: args.timeout, json: !!args.json });
338
+ }
209
339
  case 'state-save': {
210
340
  const context = launcher.getContext();
211
341
  if (!context)
@@ -217,6 +347,20 @@ async function executeCommand(launcher, args) {
217
347
  throw new Error(`Unknown command: ${command}`);
218
348
  }
219
349
  }
350
+ function requirePage(launcher) {
351
+ const page = launcher.getPage();
352
+ if (!page)
353
+ throw new Error('Browser is not open. Run: cssprobe-cli open <url>');
354
+ return page;
355
+ }
356
+ function formatTargetAction(verb, selector, result) {
357
+ const descriptor = result.descriptor
358
+ ? ` → ${result.descriptor.tag}${result.descriptor.classes.map(c => `.${c}`).join('')}${result.descriptor.text ? ` "${result.descriptor.text}"` : ''}`
359
+ : '';
360
+ const matchNote = result.matches === 1 ? '1 match' : `${result.matches} matches`;
361
+ const nthNote = result.matches > 1 ? `, used #${result.nth}` : '';
362
+ return `${verb} ${selector}${descriptor} (${matchNote}${nthNote})`;
363
+ }
220
364
  async function socketExists(socketPath) {
221
365
  try {
222
366
  const stat = await fs_1.default.promises.stat(socketPath);
@@ -0,0 +1,8 @@
1
+ export interface Matchers {
2
+ toBe(expected: unknown, label?: string): void;
3
+ toBeWithin(expected: number, tolerance: number, label?: string): void;
4
+ toMatch(pattern: RegExp | string, label?: string): void;
5
+ toHaveHash(hash: string, label?: string): void;
6
+ toBeSelected(index: number, label?: string): void;
7
+ }
8
+ export declare function expect(actual: unknown): Matchers;
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.expect = expect;
4
+ function labelSuffix(label) {
5
+ return label ? ` (${label})` : '';
6
+ }
7
+ function fail(label, message) {
8
+ throw new Error(`expect${labelSuffix(label)}: ${message}`);
9
+ }
10
+ function expect(actual) {
11
+ return {
12
+ toBe(expected, label) {
13
+ if (actual !== expected) {
14
+ fail(label, `expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}`);
15
+ }
16
+ },
17
+ toBeWithin(expected, tolerance, label) {
18
+ if (typeof actual !== 'number' || !Number.isFinite(actual)) {
19
+ fail(label, `expected a finite number near ${expected}, received ${JSON.stringify(actual)}`);
20
+ }
21
+ if (Math.abs(actual - expected) > tolerance) {
22
+ fail(label, `expected ${expected} ±${tolerance}, received ${actual}`);
23
+ }
24
+ },
25
+ toMatch(pattern, label) {
26
+ if (typeof actual !== 'string') {
27
+ fail(label, `expected a string, received ${JSON.stringify(actual)}`);
28
+ }
29
+ const regex = typeof pattern === 'string' ? new RegExp(pattern) : pattern;
30
+ if (!regex.test(actual)) {
31
+ fail(label, `expected ${JSON.stringify(actual)} to match ${regex}`);
32
+ }
33
+ },
34
+ toHaveHash(hash, label) {
35
+ const expected = hash.startsWith('#') ? hash : `#${hash}`;
36
+ if (actual !== expected) {
37
+ fail(label, `expected hash ${expected}, received ${JSON.stringify(actual)}`);
38
+ }
39
+ },
40
+ toBeSelected(index, label) {
41
+ if (actual !== index) {
42
+ fail(label, `expected selected index ${index}, received ${JSON.stringify(actual)}`);
43
+ }
44
+ },
45
+ };
46
+ }
47
+ //# sourceMappingURL=expect.js.map
@@ -0,0 +1,73 @@
1
+ import type { BrowserContext, Page } from 'playwright-core';
2
+ import type { BrowserLauncher } from '../browser/launcher';
3
+ import { type TargetResult, type TypeOptions, type WaitState } from '../browser/interactions';
4
+ import type { Finding, Snapshot } from '../engine/types';
5
+ import { type FrameInfo } from '../browser/frames';
6
+ import { type Matchers } from './expect';
7
+ export interface FlowPageError {
8
+ message: string;
9
+ stack?: string;
10
+ }
11
+ export interface FlowEnvelope {
12
+ ok: boolean;
13
+ logs: string[];
14
+ result?: unknown;
15
+ error?: string;
16
+ elapsedMs: number;
17
+ pageErrors?: FlowPageError[];
18
+ }
19
+ export interface WaitForOptions {
20
+ state?: WaitState;
21
+ timeout?: number;
22
+ }
23
+ export interface WaitOutcome {
24
+ waitedFor: 'selector' | 'js' | 'networkidle';
25
+ state?: WaitState;
26
+ elapsedMs: number;
27
+ }
28
+ export interface FlowApi {
29
+ page: Page;
30
+ context: BrowserContext;
31
+ frames(): FrameInfo[];
32
+ newPage(): Promise<Page>;
33
+ expect(actual: unknown): Matchers;
34
+ click(selector: string, opts?: {
35
+ nth?: number;
36
+ force?: boolean;
37
+ timeout?: number;
38
+ }): Promise<TargetResult>;
39
+ hover(selector: string, opts?: {
40
+ nth?: number;
41
+ force?: boolean;
42
+ timeout?: number;
43
+ }): Promise<TargetResult>;
44
+ type(selector: string, text: string, opts?: TypeOptions): Promise<void>;
45
+ waitFor(target: string | {
46
+ js: string;
47
+ timeout?: number;
48
+ } | {
49
+ networkIdle: true;
50
+ timeout?: number;
51
+ }, opts?: WaitForOptions): Promise<WaitOutcome>;
52
+ inspect(selector: string, opts?: {
53
+ format?: 'json' | 'markdown';
54
+ }): Promise<{
55
+ snapshot: Snapshot;
56
+ findings: Finding[];
57
+ } | string>;
58
+ screenshot(opts?: {
59
+ path?: string;
60
+ fullPage?: boolean;
61
+ }): Promise<string>;
62
+ log(...args: unknown[]): void;
63
+ }
64
+ type FlowFn = (api: FlowApi) => Promise<unknown> | unknown;
65
+ export declare function loadFlowScript(absPath: string): Promise<FlowFn>;
66
+ export declare function runFlow(launcher: BrowserLauncher, absPath: string, opts?: {
67
+ timeout?: number;
68
+ json?: boolean;
69
+ }): Promise<{
70
+ text: string;
71
+ exitCode: number;
72
+ }>;
73
+ export {};
@@ -0,0 +1,177 @@
1
+ "use strict";
2
+ // Flow script runner: loads a user script in the daemon process, injects the
3
+ // interaction API, captures console output and returns a result envelope.
4
+ var __importDefault = (this && this.__importDefault) || function (mod) {
5
+ return (mod && mod.__esModule) ? mod : { "default": mod };
6
+ };
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.loadFlowScript = loadFlowScript;
9
+ exports.runFlow = runFlow;
10
+ const path_1 = __importDefault(require("path"));
11
+ const util_1 = __importDefault(require("util"));
12
+ const url_1 = require("url");
13
+ const interactions_1 = require("../browser/interactions");
14
+ const analyzer_1 = require("../engine/analyzer");
15
+ const renderer_1 = require("../engine/renderer");
16
+ const evalSerializer_1 = require("../engine/evalSerializer");
17
+ const screenshot_1 = require("../utils/screenshot");
18
+ const frames_1 = require("../browser/frames");
19
+ const expect_1 = require("./expect");
20
+ const FLOW_DEFAULT_TIMEOUT = 300_000;
21
+ const CAPTURED_CONSOLE_METHODS = ['log', 'info', 'warn', 'error'];
22
+ // tsc compiles `import()` to `require()` under module=commonjs, which cannot
23
+ // load ESM scripts; a Function-constructed import keeps the native loader.
24
+ const nativeImport = new Function('p', 'return import(p)');
25
+ async function loadFlowScript(absPath) {
26
+ const mod = await nativeImport(`${(0, url_1.pathToFileURL)(absPath).href}?v=${Date.now()}`);
27
+ const fn = mod?.default;
28
+ if (typeof fn !== 'function')
29
+ throw new Error(`Flow script must default-export an async function: ${absPath}`);
30
+ return fn;
31
+ }
32
+ function captureConsole(logs) {
33
+ const originals = new Map();
34
+ for (const method of CAPTURED_CONSOLE_METHODS) {
35
+ originals.set(method, console[method]);
36
+ console[method] = (...args) => {
37
+ logs.push(`[${method}] ${util_1.default.format(...args)}`);
38
+ };
39
+ }
40
+ return () => {
41
+ for (const [method, original] of originals) {
42
+ console[method] = original;
43
+ }
44
+ };
45
+ }
46
+ async function withTimeout(promise, timeoutMs) {
47
+ if (!timeoutMs || timeoutMs <= 0)
48
+ return promise;
49
+ let timer;
50
+ try {
51
+ return await Promise.race([
52
+ promise,
53
+ new Promise((_, reject) => {
54
+ timer = setTimeout(() => reject(new Error(`Flow timed out after ${timeoutMs}ms (best-effort abort)`)), timeoutMs);
55
+ }),
56
+ ]);
57
+ }
58
+ finally {
59
+ clearTimeout(timer);
60
+ }
61
+ }
62
+ function requirePage(launcher) {
63
+ const page = launcher.getPage();
64
+ if (!page)
65
+ throw new Error('Browser is not open. Run: cssprobe-cli open <url>');
66
+ return page;
67
+ }
68
+ async function flowWaitFor(page, target, opts) {
69
+ if (typeof target === 'string') {
70
+ const state = opts?.state ?? 'visible';
71
+ const timeout = opts?.timeout ?? 30_000;
72
+ const elapsedMs = await (0, interactions_1.waitForCondition)(page, { kind: 'selector', selector: target, state }, timeout);
73
+ return { waitedFor: 'selector', state, elapsedMs };
74
+ }
75
+ if ('js' in target) {
76
+ const timeout = target.timeout ?? 30_000;
77
+ const elapsedMs = await (0, interactions_1.waitForCondition)(page, { kind: 'js', expression: target.js }, timeout);
78
+ return { waitedFor: 'js', elapsedMs };
79
+ }
80
+ if (target.networkIdle) {
81
+ const timeout = target.timeout ?? 30_000;
82
+ const elapsedMs = await (0, interactions_1.waitForCondition)(page, { kind: 'networkIdle' }, timeout);
83
+ return { waitedFor: 'networkidle', elapsedMs };
84
+ }
85
+ throw new Error('waitFor requires a selector string, { js }, or { networkIdle: true }');
86
+ }
87
+ function buildFlowApi(launcher, logs, baseDir) {
88
+ const page = requirePage(launcher);
89
+ return {
90
+ page,
91
+ click: (selector, opts) => (0, interactions_1.clickElement)(page, selector, opts),
92
+ hover: (selector, opts) => (0, interactions_1.hoverElement)(page, selector, opts),
93
+ type: (selector, text, opts) => (0, interactions_1.typeInto)(page, selector, text, opts),
94
+ waitFor: (target, opts) => flowWaitFor(page, target, opts),
95
+ inspect: async (selector, opts) => {
96
+ const snapshot = await launcher.collect({
97
+ rootSelector: selector,
98
+ downDepth: 6,
99
+ maxNodes: 60,
100
+ upTo: 'html',
101
+ });
102
+ const findings = (0, analyzer_1.analyze)(snapshot);
103
+ if (opts?.format === 'markdown')
104
+ return (0, renderer_1.renderMarkdown)(snapshot, findings);
105
+ return { snapshot, findings };
106
+ },
107
+ context: page.context(),
108
+ frames: () => (0, frames_1.describeFrames)(page),
109
+ newPage: () => page.context().newPage(),
110
+ expect: (actual) => (0, expect_1.expect)(actual),
111
+ screenshot: async (opts) => {
112
+ const buffer = await page.screenshot({ fullPage: opts?.fullPage });
113
+ const out = opts?.path ? path_1.default.resolve(baseDir, opts.path) : undefined;
114
+ return (0, screenshot_1.saveScreenshotFile)(`data:image/png;base64,${buffer.toString('base64')}`, out);
115
+ },
116
+ log: (...args) => {
117
+ logs.push(util_1.default.format(...args));
118
+ },
119
+ };
120
+ }
121
+ function renderEnvelope(env) {
122
+ const lines = [];
123
+ if (env.ok) {
124
+ lines.push(`Flow completed in ${env.elapsedMs}ms`);
125
+ if (env.result !== undefined)
126
+ lines.push(`Result: ${JSON.stringify(env.result, null, 2)}`);
127
+ }
128
+ else {
129
+ lines.push(`Flow failed in ${env.elapsedMs}ms: ${env.error}`);
130
+ }
131
+ if (env.pageErrors?.length) {
132
+ lines.push('', `Page errors (${env.pageErrors.length}):`);
133
+ lines.push(...env.pageErrors.map(error => ` - ${error.message}`));
134
+ }
135
+ if (env.logs.length) {
136
+ lines.push('', 'Logs:');
137
+ lines.push(...env.logs.map(line => ` ${line}`));
138
+ }
139
+ return lines.join('\n');
140
+ }
141
+ async function runFlow(launcher, absPath, opts = {}) {
142
+ const logs = [];
143
+ const started = Date.now();
144
+ const restore = captureConsole(logs);
145
+ const pageErrors = [];
146
+ const page = launcher.getPage();
147
+ const onPageError = (error) => {
148
+ pageErrors.push({
149
+ message: error.message,
150
+ stack: error.stack?.split('\n').slice(0, 6).join('\n'),
151
+ });
152
+ };
153
+ page?.on('pageerror', onPageError);
154
+ let env;
155
+ try {
156
+ const fn = await loadFlowScript(absPath);
157
+ const api = buildFlowApi(launcher, logs, path_1.default.dirname(absPath));
158
+ const result = await withTimeout(Promise.resolve(fn(api)), opts.timeout ?? FLOW_DEFAULT_TIMEOUT);
159
+ env = { ok: true, logs, elapsedMs: Date.now() - started };
160
+ if (result !== undefined)
161
+ env.result = (0, evalSerializer_1.serializeEvalResult)(result);
162
+ }
163
+ catch (e) {
164
+ env = { ok: false, logs, error: e.message, elapsedMs: Date.now() - started };
165
+ }
166
+ finally {
167
+ page?.off('pageerror', onPageError);
168
+ restore();
169
+ }
170
+ if (pageErrors.length)
171
+ env.pageErrors = pageErrors;
172
+ return {
173
+ text: opts.json ? JSON.stringify(env, null, 2) : renderEnvelope(env),
174
+ exitCode: env.ok ? 0 : 1,
175
+ };
176
+ }
177
+ //# sourceMappingURL=flowRunner.js.map
@@ -0,0 +1 @@
1
+ export declare function serializeEvalResult(value: unknown): unknown;
@@ -0,0 +1,43 @@
1
+ "use strict";
2
+ // Self-contained serializer for eval results.
3
+ // Runs inside the page via JSHandle.evaluate (function source is transferred),
4
+ // and is unit-testable in Node — keep it free of external references.
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.serializeEvalResult = serializeEvalResult;
7
+ function serializeEvalResult(value) {
8
+ const seen = new WeakSet();
9
+ const walk = (v, depth) => {
10
+ if (v === null || typeof v !== 'object') {
11
+ if (typeof v === 'function')
12
+ return `[Function: ${v.name || 'anonymous'}]`;
13
+ if (typeof v === 'bigint')
14
+ return `${v}n`;
15
+ return v;
16
+ }
17
+ if (typeof Node !== 'undefined' && v instanceof Node) {
18
+ if (v.nodeType === 1) {
19
+ const el = v;
20
+ return {
21
+ tag: el.tagName.toLowerCase(),
22
+ id: el.id,
23
+ classes: Array.from(el.classList),
24
+ text: (el.textContent ?? '').trim().slice(0, 120),
25
+ };
26
+ }
27
+ return `[${v.nodeName}]`;
28
+ }
29
+ if (seen.has(v))
30
+ return '[Circular]';
31
+ if (depth >= 6)
32
+ return '[Depth limit]';
33
+ seen.add(v);
34
+ if (Array.isArray(v))
35
+ return Array.from(v, item => (item === undefined ? null : walk(item, depth + 1)));
36
+ const out = {};
37
+ for (const [key, val] of Object.entries(v))
38
+ out[key] = val === undefined ? null : walk(val, depth + 1);
39
+ return out;
40
+ };
41
+ return walk(value, 0);
42
+ }
43
+ //# sourceMappingURL=evalSerializer.js.map