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.
- package/AGENTS.md +59 -6
- package/CHANGELOG.md +21 -0
- package/README.md +111 -2
- package/dist/browser/frames.d.ts +15 -0
- package/dist/browser/frames.js +60 -0
- package/dist/browser/interactions.d.ts +49 -0
- package/dist/browser/interactions.js +96 -0
- package/dist/browser/launcher.d.ts +30 -2
- package/dist/browser/launcher.js +141 -34
- package/dist/browser/sampler.d.ts +43 -0
- package/dist/browser/sampler.js +111 -0
- package/dist/cli/commands.d.ts +3 -0
- package/dist/cli/commands.js +162 -4
- package/dist/cli/input.d.ts +6 -0
- package/dist/cli/input.js +24 -0
- package/dist/cli/program.js +171 -1
- package/dist/cli/session-ops.d.ts +10 -1
- package/dist/cli/session-ops.js +27 -56
- package/dist/cli/state-check.d.ts +17 -0
- package/dist/cli/state-check.js +43 -0
- package/dist/cli/url-guard.d.ts +2 -0
- package/dist/cli/url-guard.js +24 -0
- package/dist/daemon/daemon.js +162 -18
- package/dist/daemon/expect.d.ts +8 -0
- package/dist/daemon/expect.js +47 -0
- package/dist/daemon/flowRunner.d.ts +73 -0
- package/dist/daemon/flowRunner.js +177 -0
- package/dist/engine/evalSerializer.d.ts +1 -0
- package/dist/engine/evalSerializer.js +43 -0
- package/dist/help.json +156 -16
- package/dist/installer/skill-template.js +43 -0
- package/dist/scenario/assertions.d.ts +13 -0
- package/dist/scenario/assertions.js +111 -0
- package/dist/scenario/reporters.d.ts +13 -0
- package/dist/scenario/reporters.js +79 -0
- package/dist/scenario/runner.d.ts +10 -0
- package/dist/scenario/runner.js +244 -0
- package/dist/scenario/schema.d.ts +1210 -0
- package/dist/scenario/schema.js +145 -0
- package/dist/utils/screenshot.d.ts +1 -0
- package/dist/utils/screenshot.js +58 -0
- package/docs/guide/installation.md +1 -1
- package/llms.txt +1 -1
- package/package.json +1 -1
package/dist/daemon/daemon.js
CHANGED
|
@@ -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
|
|
156
|
-
|
|
157
|
-
|
|
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
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|