cssprobe-cli 0.3.1 → 0.4.1

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 (40) hide show
  1. package/AGENTS.md +39 -7
  2. package/CHANGELOG.md +30 -0
  3. package/README.md +58 -5
  4. package/dist/browser/frames.d.ts +15 -0
  5. package/dist/browser/frames.js +60 -0
  6. package/dist/browser/interactions.d.ts +3 -2
  7. package/dist/browser/interactions.js +16 -4
  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 +93 -5
  14. package/dist/cli/input.d.ts +6 -0
  15. package/dist/cli/input.js +24 -0
  16. package/dist/cli/program.js +152 -1
  17. package/dist/cli/session-ops.d.ts +8 -0
  18. package/dist/cli/session-ops.js +24 -1
  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 +70 -20
  24. package/dist/daemon/expect.d.ts +8 -0
  25. package/dist/daemon/expect.js +47 -0
  26. package/dist/daemon/flowRunner.d.ts +13 -1
  27. package/dist/daemon/flowRunner.js +28 -3
  28. package/dist/engine/evalSerializer.js +2 -2
  29. package/dist/help.json +96 -17
  30. package/dist/scenario/assertions.d.ts +13 -0
  31. package/dist/scenario/assertions.js +111 -0
  32. package/dist/scenario/reporters.d.ts +13 -0
  33. package/dist/scenario/reporters.js +79 -0
  34. package/dist/scenario/runner.d.ts +10 -0
  35. package/dist/scenario/runner.js +251 -0
  36. package/dist/scenario/schema.d.ts +1270 -0
  37. package/dist/scenario/schema.js +151 -0
  38. package/docs/guide/installation.md +1 -1
  39. package/llms.txt +1 -1
  40. package/package.json +1 -1
@@ -9,11 +9,17 @@ const path_1 = __importDefault(require("path"));
9
9
  const os_1 = __importDefault(require("os"));
10
10
  const minimist_1 = require("./minimist");
11
11
  const command_1 = require("./command");
12
+ const input_1 = require("./input");
12
13
  const commands_1 = require("./commands");
13
14
  const output_1 = require("./output");
14
15
  const config_1 = require("../config/config");
15
16
  const session_1 = require("../daemon/session");
16
17
  const session_ops_1 = require("./session-ops");
18
+ const state_check_1 = require("./state-check");
19
+ const launcher_1 = require("../browser/launcher");
20
+ const schema_1 = require("../scenario/schema");
21
+ const runner_1 = require("../scenario/runner");
22
+ const reporters_1 = require("../scenario/reporters");
17
23
  const mcp_1 = require("../mcp");
18
24
  const globalOptions = ['json', 'raw', 'help', 'h', 'version', 'v', 'p', 'profile'];
19
25
  const booleanGlobalOptions = ['help', 'json', 'raw', 'version', 'v', 'h'];
@@ -52,15 +58,25 @@ async function program() {
52
58
  case 'close':
53
59
  await handleClose(command, rawArgs, output);
54
60
  return;
61
+ case 'doctor':
62
+ handleDoctor(command, rawArgs, output);
63
+ return;
64
+ case 'scenario':
65
+ await handleScenario(command, rawArgs, output);
66
+ return;
55
67
  case 'status':
56
68
  await handleStatus(output);
57
69
  return;
58
70
  case 'state-import':
59
71
  await handleStateImport(command, rawArgs, output);
60
72
  return;
73
+ case 'state-check':
74
+ handleStateCheck(command, rawArgs, output);
75
+ return;
61
76
  case 'state-save':
62
77
  await handleStateSave(command, rawArgs, output);
63
78
  return;
79
+ case 'frames':
64
80
  case 'inspect':
65
81
  case 'tree':
66
82
  case 'layout':
@@ -74,6 +90,8 @@ async function program() {
74
90
  case 'hover':
75
91
  case 'type':
76
92
  case 'wait-for':
93
+ case 'sample':
94
+ case 'wait-stable':
77
95
  await handleSessionCommand(commandName, command, rawArgs, output);
78
96
  return;
79
97
  case 'run':
@@ -148,6 +166,8 @@ async function handleOpen(command, args, output) {
148
166
  headed: !!parsed.headed,
149
167
  viewport,
150
168
  state: parsed.state,
169
+ expectUrl: parsed['expect-url'],
170
+ failOnLogin: !!parsed['fail-on-login'],
151
171
  });
152
172
  if (output.json) {
153
173
  console.log(JSON.stringify(result, null, 2));
@@ -183,11 +203,100 @@ async function handleClose(command, args, output) {
183
203
  output.error(e instanceof Error ? e.message : String(e));
184
204
  }
185
205
  }
206
+ async function handleScenario(command, args, output) {
207
+ try {
208
+ const cmdArgs = splitArgs(args);
209
+ const parsed = (0, command_1.parseCommand)(command, cmdArgs);
210
+ const file = path_1.default.resolve(parsed.file);
211
+ if (!fs_1.default.existsSync(file))
212
+ output.error(`Scenario suite not found: ${file}`);
213
+ const raw = JSON.parse(fs_1.default.readFileSync(file, 'utf-8'));
214
+ const validation = schema_1.suiteSchema.safeParse(raw);
215
+ if (!validation.success) {
216
+ const issues = validation.error.issues
217
+ .map(issue => `${issue.path.join('.') || '(root)'}: ${issue.message}`)
218
+ .join('\n');
219
+ output.error(`Invalid scenario suite:\n${issues}`);
220
+ }
221
+ let viewport;
222
+ const viewportStr = parsed.viewport;
223
+ if (viewportStr) {
224
+ const parts = viewportStr.split('x');
225
+ const width = parseInt(parts[0], 10);
226
+ const height = parseInt(parts[1], 10);
227
+ if (parts.length !== 2 || isNaN(width) || isNaN(height)) {
228
+ output.error('Invalid viewport format. Use WxH (e.g. 1280x720)');
229
+ }
230
+ viewport = { width, height };
231
+ }
232
+ const result = await (0, runner_1.runScenario)(validation.data, file, {
233
+ artifactsDir: parsed.artifacts,
234
+ junitPath: parsed.junit,
235
+ bail: !!parsed.bail,
236
+ headed: !!parsed.headed,
237
+ browser: parsed.browser,
238
+ viewport,
239
+ state: parsed.state,
240
+ });
241
+ if (output.json) {
242
+ console.log(JSON.stringify(result, null, 2));
243
+ }
244
+ else {
245
+ console.log((0, reporters_1.formatConsole)(result.suite, result.results));
246
+ console.log(`Artifacts: ${result.artifactsDir}`);
247
+ if (parsed.junit)
248
+ console.log(`JUnit: ${path_1.default.resolve(parsed.junit)}`);
249
+ }
250
+ if (!result.ok)
251
+ process.exit(1);
252
+ }
253
+ catch (e) {
254
+ output.error(e instanceof Error ? e.message : String(e));
255
+ }
256
+ }
257
+ function handleDoctor(command, args, output) {
258
+ try {
259
+ const cmdArgs = splitArgs(args);
260
+ const parsed = (0, command_1.parseCommand)(command, cmdArgs);
261
+ const diagnosis = (0, launcher_1.diagnoseBrowser)(parsed.engine);
262
+ if (output.json) {
263
+ console.log(JSON.stringify(diagnosis, null, 2));
264
+ return;
265
+ }
266
+ console.log(`Platform: ${diagnosis.platform}`);
267
+ console.log(`Cache roots: ${diagnosis.cacheRoots.join(', ')}`);
268
+ console.log(`Expected: chromium ${diagnosis.expected.chromium ?? 'unknown'}, headless-shell ${diagnosis.expected.headlessShell ?? 'unknown'} (playwright-core ${diagnosis.expected.playwrightVersion ?? 'unknown'})`);
269
+ if (diagnosis.installs.length) {
270
+ console.log('Installed:');
271
+ for (const install of diagnosis.installs) {
272
+ console.log(` ${install.kind}-${install.revision}${install.executable ? '' : ' (no executable)'} ${install.dir}`);
273
+ }
274
+ }
275
+ else {
276
+ console.log('Installed: none');
277
+ }
278
+ console.log(`Status: ${diagnosis.status}`);
279
+ if (diagnosis.hint)
280
+ console.log(`Hint: ${diagnosis.hint}`);
281
+ }
282
+ catch (e) {
283
+ output.error(e instanceof Error ? e.message : String(e));
284
+ }
285
+ }
186
286
  async function handleStatus(output) {
187
287
  try {
188
288
  const result = await (0, session_ops_1.getSessionStatus)();
289
+ let page = null;
290
+ if (result.alive) {
291
+ try {
292
+ page = await (0, session_ops_1.getPageInfo)();
293
+ }
294
+ catch {
295
+ page = null;
296
+ }
297
+ }
189
298
  if (output.json) {
190
- console.log(JSON.stringify(result, null, 2));
299
+ console.log(JSON.stringify({ ...result, page }, null, 2));
191
300
  return;
192
301
  }
193
302
  if (!result.config) {
@@ -198,7 +307,42 @@ async function handleStatus(output) {
198
307
  console.log(`Status: ${result.alive ? 'alive' : 'dead'}`);
199
308
  if (result.alive) {
200
309
  console.log(`Browser: ${result.config.browser.browserName}`);
310
+ if (page) {
311
+ console.log(`URL: ${page.url}`);
312
+ console.log(`Title: ${page.title}`);
313
+ console.log(`Frames: ${page.frames}`);
314
+ }
315
+ }
316
+ }
317
+ catch (e) {
318
+ output.error(e instanceof Error ? e.message : String(e));
319
+ }
320
+ }
321
+ function handleStateCheck(command, args, output) {
322
+ try {
323
+ const cmdArgs = splitArgs(args);
324
+ const parsed = (0, command_1.parseCommand)(command, cmdArgs);
325
+ const file = path_1.default.resolve(parsed.state ??
326
+ path_1.default.join(os_1.default.homedir(), '.cssprobe-cli', 'states', 'session.json'));
327
+ if (!fs_1.default.existsSync(file))
328
+ output.error(`State file not found: ${file}`);
329
+ const raw = JSON.parse(fs_1.default.readFileSync(file, 'utf-8'));
330
+ if (!raw || !Array.isArray(raw.cookies))
331
+ throw new Error(`Invalid state file (missing cookies): ${file}`);
332
+ const report = (0, state_check_1.analyzeState)(raw, parsed.url);
333
+ if (output.json) {
334
+ console.log(JSON.stringify({ file, ...report }, null, 2));
335
+ return;
336
+ }
337
+ console.log(`State: ${file}`);
338
+ console.log(`Cookies: ${report.cookies} (${report.sessionCookies} session)`);
339
+ console.log(`Domains: ${report.domains.join(', ') || '(none)'}`);
340
+ if (report.targetMatches !== null) {
341
+ console.log(`Target match: ${report.targetMatches ? 'yes' : 'no'}`);
201
342
  }
343
+ console.log(`Status: ${report.ok ? 'ok' : 'attention'}`);
344
+ for (const warning of report.warnings)
345
+ console.log(`Warning: ${warning}`);
202
346
  }
203
347
  catch (e) {
204
348
  output.error(e instanceof Error ? e.message : String(e));
@@ -217,6 +361,13 @@ async function handleRun(command, args, output) {
217
361
  async function handleSessionCommand(commandName, command, args, output) {
218
362
  try {
219
363
  const cmdArgs = splitArgs(args);
364
+ if (commandName === 'eval' || commandName === 'playwright') {
365
+ const input = cmdArgs._[1];
366
+ if (typeof input === 'string') {
367
+ cmdArgs._ = cmdArgs._.slice();
368
+ cmdArgs._[1] = (0, input_1.resolveInputArg)(input);
369
+ }
370
+ }
220
371
  const parsed = (0, command_1.parseCommand)(command, cmdArgs);
221
372
  const result = await (0, session_ops_1.runSessionCommand)(commandName, command, parsed, !!args.json);
222
373
  // Screenshot: save the base64 data URL to a file and report the path
@@ -11,6 +11,8 @@ export interface OpenOptions {
11
11
  height: number;
12
12
  };
13
13
  state?: string;
14
+ expectUrl?: string;
15
+ failOnLogin?: boolean;
14
16
  }
15
17
  export interface OpenResult {
16
18
  sessionId: string;
@@ -21,6 +23,12 @@ export interface OpenResult {
21
23
  height: number;
22
24
  };
23
25
  }
26
+ export interface PageInfo {
27
+ url: string;
28
+ title: string;
29
+ frames: number;
30
+ }
31
+ export declare function getPageInfo(): Promise<PageInfo>;
24
32
  export declare function openSession(opts: OpenOptions): Promise<OpenResult>;
25
33
  export declare function waitForSession(timeoutMs: number): Promise<Session | undefined>;
26
34
  export interface CloseResult {
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.saveScreenshotFile = void 0;
4
+ exports.getPageInfo = getPageInfo;
4
5
  exports.openSession = openSession;
5
6
  exports.waitForSession = waitForSession;
6
7
  exports.closeSession = closeSession;
@@ -8,8 +9,16 @@ exports.getSessionStatus = getSessionStatus;
8
9
  exports.buildDaemonArgs = buildDaemonArgs;
9
10
  exports.runSessionCommand = runSessionCommand;
10
11
  const session_1 = require("../daemon/session");
12
+ const url_guard_1 = require("./url-guard");
11
13
  var screenshot_1 = require("../utils/screenshot");
12
14
  Object.defineProperty(exports, "saveScreenshotFile", { enumerable: true, get: function () { return screenshot_1.saveScreenshotFile; } });
15
+ async function getPageInfo() {
16
+ const session = await (0, session_1.loadSession)();
17
+ if (!session)
18
+ throw new Error('No active session. Run: cssprobe-cli open <url>');
19
+ const result = await session.run({ _: ['page-info'] });
20
+ return JSON.parse(result.text);
21
+ }
13
22
  async function openSession(opts) {
14
23
  const url = opts.url || 'about:blank';
15
24
  const browser = opts.browser || 'chromium';
@@ -24,10 +33,24 @@ async function openSession(opts) {
24
33
  if (!session)
25
34
  throw new Error('Daemon failed to become ready within 5s. Check the daemon log for errors.');
26
35
  await session.run({ _: ['goto', url] });
36
+ const info = await getPageInfo();
37
+ const guardError = (() => {
38
+ if (opts.failOnLogin && (0, url_guard_1.looksLikeLoginUrl)(info.url)) {
39
+ return `Login required: page redirected to ${info.url}. Refresh the state file or log in manually.`;
40
+ }
41
+ if (opts.expectUrl && !(0, url_guard_1.matchesUrlPattern)(opts.expectUrl, info.url)) {
42
+ return `Expected URL to match ${opts.expectUrl}, got ${info.url}`;
43
+ }
44
+ return null;
45
+ })();
46
+ if (guardError) {
47
+ await closeSession();
48
+ throw new Error(guardError);
49
+ }
27
50
  return {
28
51
  sessionId: 'default',
29
52
  pid,
30
- url,
53
+ url: info.url,
31
54
  viewport: opts.viewport || { width: 1280, height: 720 },
32
55
  };
33
56
  }
@@ -0,0 +1,17 @@
1
+ export interface StorageCookie {
2
+ name: string;
3
+ domain: string;
4
+ expires?: number;
5
+ }
6
+ export interface StateReport {
7
+ cookies: number;
8
+ domains: string[];
9
+ expired: string[];
10
+ sessionCookies: number;
11
+ targetMatches: boolean | null;
12
+ warnings: string[];
13
+ ok: boolean;
14
+ }
15
+ export declare function analyzeState(state: {
16
+ cookies?: StorageCookie[];
17
+ }, targetUrl?: string, nowMs?: number): StateReport;
@@ -0,0 +1,43 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.analyzeState = analyzeState;
4
+ function normalizeDomain(domain) {
5
+ return domain.replace(/^\./, '').toLowerCase();
6
+ }
7
+ function analyzeState(state, targetUrl, nowMs = Date.now()) {
8
+ const cookies = state.cookies ?? [];
9
+ const expired = [];
10
+ let sessionCookies = 0;
11
+ for (const cookie of cookies) {
12
+ if (cookie.expires === undefined || cookie.expires <= 0) {
13
+ sessionCookies++;
14
+ }
15
+ else if (cookie.expires * 1000 <= nowMs) {
16
+ expired.push(cookie.name);
17
+ }
18
+ }
19
+ const domains = Array.from(new Set(cookies.map(cookie => normalizeDomain(cookie.domain)))).sort();
20
+ let targetMatches = null;
21
+ if (targetUrl) {
22
+ try {
23
+ const host = new URL(targetUrl).hostname.toLowerCase();
24
+ targetMatches = cookies.some(cookie => {
25
+ const domain = normalizeDomain(cookie.domain);
26
+ return host === domain || host.endsWith(`.${domain}`);
27
+ });
28
+ }
29
+ catch {
30
+ throw new Error(`Invalid --url value: ${targetUrl}`);
31
+ }
32
+ }
33
+ const warnings = [];
34
+ if (!cookies.length)
35
+ warnings.push('no cookies in state file');
36
+ if (expired.length)
37
+ warnings.push(`expired cookies: ${expired.join(', ')}`);
38
+ if (targetMatches === false)
39
+ warnings.push(`no cookies match ${targetUrl}`);
40
+ const ok = cookies.length > 0 && expired.length === 0 && targetMatches !== false;
41
+ return { cookies: cookies.length, domains, expired, sessionCookies, targetMatches, warnings, ok };
42
+ }
43
+ //# sourceMappingURL=state-check.js.map
@@ -0,0 +1,2 @@
1
+ export declare function looksLikeLoginUrl(raw: string): boolean;
2
+ export declare function matchesUrlPattern(pattern: string, raw: string): boolean;
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.looksLikeLoginUrl = looksLikeLoginUrl;
4
+ exports.matchesUrlPattern = matchesUrlPattern;
5
+ function looksLikeLoginUrl(raw) {
6
+ try {
7
+ const url = new URL(raw);
8
+ return /\/(?:s\/)?login(?:\/|$)/.test(url.pathname);
9
+ }
10
+ catch {
11
+ return false;
12
+ }
13
+ }
14
+ function matchesUrlPattern(pattern, raw) {
15
+ let regex;
16
+ try {
17
+ regex = new RegExp(pattern);
18
+ }
19
+ catch {
20
+ throw new Error(`Invalid --expect-url pattern: ${pattern}`);
21
+ }
22
+ return regex.test(raw);
23
+ }
24
+ //# sourceMappingURL=url-guard.js.map
@@ -20,6 +20,8 @@ const dom_tree_1 = require("../engine/renderers/dom-tree");
20
20
  const interactions_1 = require("../browser/interactions");
21
21
  const evalSerializer_1 = require("../engine/evalSerializer");
22
22
  const flowRunner_1 = require("./flowRunner");
23
+ const frames_1 = require("../browser/frames");
24
+ const sampler_1 = require("../browser/sampler");
23
25
  const AsyncFunction = Object.getPrototypeOf(async function () { }).constructor;
24
26
  async function startDaemon(sessionName, clientInfo, options) {
25
27
  const sessionConfig = createSessionConfig(clientInfo, sessionName, options);
@@ -77,6 +79,18 @@ async function executeCommand(launcher, args) {
77
79
  const command = args._[0];
78
80
  const params = args._.slice(1);
79
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
+ }
80
94
  case 'goto': {
81
95
  const url = params[0] || 'about:blank';
82
96
  await launcher.open(url);
@@ -91,7 +105,7 @@ async function executeCommand(launcher, args) {
91
105
  downDepth: args.depth || 6,
92
106
  maxNodes: 60,
93
107
  upTo: 'html',
94
- });
108
+ }, (0, frames_1.resolveFrame)(requirePage(launcher), args.frame));
95
109
  const findings = (0, analyzer_1.analyze)(snapshot);
96
110
  if (args.json) {
97
111
  return { text: JSON.stringify((0, renderer_1.renderJSON)(snapshot, findings, args.brief), null, 2) };
@@ -109,7 +123,7 @@ async function executeCommand(launcher, args) {
109
123
  downDepth: args.depth || 6,
110
124
  maxNodes: 60,
111
125
  upTo: 'html',
112
- });
126
+ }, (0, frames_1.resolveFrame)(requirePage(launcher), args.frame));
113
127
  // Render tree only
114
128
  const lines = [];
115
129
  lines.push(`## DOM tree (${snapshot.downDepth} levels deep)`);
@@ -127,7 +141,7 @@ async function executeCommand(launcher, args) {
127
141
  downDepth: 6,
128
142
  maxNodes: 60,
129
143
  upTo: 'html',
130
- });
144
+ }, (0, frames_1.resolveFrame)(requirePage(launcher), args.frame));
131
145
  const findings = (0, analyzer_1.analyze)(snapshot);
132
146
  return { text: (0, renderer_1.renderMarkdown)(snapshot, findings, false, true) };
133
147
  }
@@ -140,7 +154,7 @@ async function executeCommand(launcher, args) {
140
154
  downDepth: 6,
141
155
  maxNodes: 60,
142
156
  upTo: 'html',
143
- });
157
+ }, (0, frames_1.resolveFrame)(requirePage(launcher), args.frame));
144
158
  const findings = (0, analyzer_1.analyze)(snapshot);
145
159
  const important = findings.filter(f => f.level === 'warning' || f.level === 'error');
146
160
  if (important.length === 0) {
@@ -155,11 +169,9 @@ async function executeCommand(launcher, args) {
155
169
  const css = params[0];
156
170
  if (!css)
157
171
  throw new Error('CSS is required for inject-css command');
158
- const page = launcher.getPage();
159
- if (!page)
160
- throw new Error('Browser is not open. Run: cssprobe-cli open <url>');
161
- await page.addStyleTag({ content: css });
162
- 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() }) };
163
175
  }
164
176
  case 'screenshot': {
165
177
  const page = launcher.getPage();
@@ -173,17 +185,21 @@ async function executeCommand(launcher, args) {
173
185
  const expression = params[0];
174
186
  if (!expression)
175
187
  throw new Error('Expression is required for eval command');
176
- const page = launcher.getPage();
177
- if (!page)
178
- throw new Error('Browser is not open. Run: cssprobe-cli open <url>');
179
- const handle = await page.evaluateHandle(expression);
188
+ const frame = (0, frames_1.resolveFrame)(requirePage(launcher), args.frame);
189
+ const handle = await frame.evaluateHandle(expression);
180
190
  try {
181
- const result = await handle.evaluate(evalSerializer_1.serializeEvalResult);
182
- if (typeof result === 'string')
183
- return { text: result };
184
- if (result === undefined)
185
- return { text: 'undefined' };
186
- return { text: JSON.stringify(result, null, 2) };
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
+ }
187
203
  }
188
204
  finally {
189
205
  await handle.dispose();
@@ -262,9 +278,11 @@ async function executeCommand(launcher, args) {
262
278
  networkIdle: args['network-idle'],
263
279
  state: args.state,
264
280
  });
281
+ const page = requirePage(launcher);
282
+ const waitHost = args.frame ? (0, frames_1.resolveFrame)(page, args.frame) : page;
265
283
  let elapsedMs;
266
284
  try {
267
- elapsedMs = await (0, interactions_1.waitForCondition)(requirePage(launcher), target, timeout);
285
+ elapsedMs = await (0, interactions_1.waitForCondition)(waitHost, target, timeout);
268
286
  }
269
287
  catch (e) {
270
288
  throw new Error(`wait-for failed for ${(0, interactions_1.describeWaitTarget)(target)} within ${timeout}ms: ${e.message}`);
@@ -282,6 +300,38 @@ async function executeCommand(launcher, args) {
282
300
  const label = target.kind === 'selector' ? `${selector} (${target.state})` : (0, interactions_1.describeWaitTarget)(target);
283
301
  return { text: `Wait satisfied: ${label} (${elapsedMs}ms)` };
284
302
  }
303
+ case 'sample': {
304
+ const expression = params[0];
305
+ if (!expression)
306
+ throw new Error('Expression is required for sample command');
307
+ const interval = args.interval ?? 250;
308
+ const duration = args.duration ?? 5000;
309
+ if (duration <= 0)
310
+ throw new Error('--duration must be greater than 0');
311
+ if (interval <= 0)
312
+ throw new Error('--interval must be greater than 0');
313
+ const frame = (0, frames_1.resolveFrame)(requirePage(launcher), args.frame);
314
+ const points = await (0, sampler_1.sampleFrame)(frame, expression, {
315
+ intervalMs: interval,
316
+ durationMs: duration,
317
+ ignoreErrors: !!args['ignore-errors'],
318
+ });
319
+ const summary = (0, sampler_1.summarizeSamples)(points, args.key, 5000, args['tail-from']);
320
+ return { text: JSON.stringify({ expression, intervalMs: interval, summary, series: points }, null, 2) };
321
+ }
322
+ case 'wait-stable': {
323
+ const expression = params[0];
324
+ if (!expression)
325
+ throw new Error('Expression is required for wait-stable command');
326
+ const frame = (0, frames_1.resolveFrame)(requirePage(launcher), args.frame);
327
+ const result = await (0, sampler_1.waitForStableValue)(frame, expression, {
328
+ tolerance: args.tolerance,
329
+ windowMs: args.window,
330
+ timeoutMs: args.timeout,
331
+ intervalMs: args.interval,
332
+ });
333
+ return { text: JSON.stringify({ ok: true, expression, ...result }, null, 2) };
334
+ }
285
335
  case 'run': {
286
336
  const absPath = params[0];
287
337
  if (!absPath)
@@ -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
@@ -1,17 +1,25 @@
1
- import type { Page } from 'playwright-core';
1
+ import type { BrowserContext, Page } from 'playwright-core';
2
2
  import type { BrowserLauncher } from '../browser/launcher';
3
3
  import { type TargetResult, type TypeOptions, type WaitState } from '../browser/interactions';
4
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
+ }
5
11
  export interface FlowEnvelope {
6
12
  ok: boolean;
7
13
  logs: string[];
8
14
  result?: unknown;
9
15
  error?: string;
10
16
  elapsedMs: number;
17
+ pageErrors?: FlowPageError[];
11
18
  }
12
19
  export interface WaitForOptions {
13
20
  state?: WaitState;
14
21
  timeout?: number;
22
+ frame?: string;
15
23
  }
16
24
  export interface WaitOutcome {
17
25
  waitedFor: 'selector' | 'js' | 'networkidle';
@@ -20,6 +28,10 @@ export interface WaitOutcome {
20
28
  }
21
29
  export interface FlowApi {
22
30
  page: Page;
31
+ context: BrowserContext;
32
+ frames(): FrameInfo[];
33
+ newPage(): Promise<Page>;
34
+ expect(actual: unknown): Matchers;
23
35
  click(selector: string, opts?: {
24
36
  nth?: number;
25
37
  force?: boolean;