prowl-tools 0.1.3 → 0.1.4

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/dist/lib.cjs CHANGED
@@ -37,12 +37,12 @@ __export(visual_exports, {
37
37
  ensureBaselineDir: () => ensureBaselineDir
38
38
  });
39
39
  async function compareScreenshots(baselinePath, currentPath, diffPath, threshold) {
40
- const baselineData = import_pngjs.PNG.sync.read(import_node_fs3.default.readFileSync(baselinePath));
41
- const currentData = import_pngjs.PNG.sync.read(import_node_fs3.default.readFileSync(currentPath));
40
+ const baselineData = import_pngjs.PNG.sync.read(import_node_fs5.default.readFileSync(baselinePath));
41
+ const currentData = import_pngjs.PNG.sync.read(import_node_fs5.default.readFileSync(currentPath));
42
42
  const { width, height } = baselineData;
43
43
  if (currentData.width !== width || currentData.height !== height) {
44
44
  const diff2 = new import_pngjs.PNG({ width, height });
45
- import_node_fs3.default.writeFileSync(diffPath, import_pngjs.PNG.sync.write(diff2));
45
+ import_node_fs5.default.writeFileSync(diffPath, import_pngjs.PNG.sync.write(diff2));
46
46
  return {
47
47
  match: false,
48
48
  diffPercentage: 1,
@@ -60,7 +60,7 @@ async function compareScreenshots(baselinePath, currentPath, diffPath, threshold
60
60
  );
61
61
  const totalPixels = width * height;
62
62
  const diffPercentage = diffPixels / totalPixels;
63
- import_node_fs3.default.writeFileSync(diffPath, import_pngjs.PNG.sync.write(diff));
63
+ import_node_fs5.default.writeFileSync(diffPath, import_pngjs.PNG.sync.write(diff));
64
64
  return {
65
65
  match: diffPercentage <= threshold,
66
66
  diffPercentage,
@@ -68,16 +68,16 @@ async function compareScreenshots(baselinePath, currentPath, diffPath, threshold
68
68
  };
69
69
  }
70
70
  function ensureBaselineDir(configDir) {
71
- const baselineDir = import_node_path3.default.join(configDir, "baselines");
72
- import_node_fs3.default.mkdirSync(baselineDir, { recursive: true });
71
+ const baselineDir = import_node_path5.default.join(configDir, "baselines");
72
+ import_node_fs5.default.mkdirSync(baselineDir, { recursive: true });
73
73
  return baselineDir;
74
74
  }
75
- var import_node_fs3, import_node_path3, import_pngjs, import_pixelmatch;
75
+ var import_node_fs5, import_node_path5, import_pngjs, import_pixelmatch;
76
76
  var init_visual = __esm({
77
77
  "src/runner/visual.ts"() {
78
78
  "use strict";
79
- import_node_fs3 = __toESM(require("fs"), 1);
80
- import_node_path3 = __toESM(require("path"), 1);
79
+ import_node_fs5 = __toESM(require("fs"), 1);
80
+ import_node_path5 = __toESM(require("path"), 1);
81
81
  import_pngjs = require("pngjs");
82
82
  import_pixelmatch = __toESM(require("pixelmatch"), 1);
83
83
  }
@@ -87,45 +87,573 @@ var init_visual = __esm({
87
87
  var src_exports = {};
88
88
  __export(src_exports, {
89
89
  DEFAULT_FLAKY_THRESHOLD: () => DEFAULT_FLAKY_THRESHOLD,
90
+ DEFAULT_REQUEST_TIMEOUT_MS: () => DEFAULT_REQUEST_TIMEOUT_MS,
91
+ SpawnMacHelperClient: () => SpawnMacHelperClient,
92
+ WEB_ONLY_STEP_TYPES: () => WEB_ONLY_STEP_TYPES,
90
93
  analyzePage: () => analyzePage,
94
+ assertStepsSupportedByTarget: () => assertStepsSupportedByTarget,
95
+ assertTargetAppAllowed: () => assertTargetAppAllowed,
91
96
  buildHealCandidates: () => buildHealCandidates,
97
+ closeMacSession: () => closeMacSession,
92
98
  clusterFailures: () => clusterFailures,
93
99
  computeFlakeScore: () => computeFlakeScore,
94
100
  configSchema: () => configSchema,
101
+ createMacDriver: () => createMacDriver,
95
102
  extractFailures: () => extractFailures,
96
103
  extractSelectorIntent: () => extractSelectorIntent,
97
104
  generateHunt: () => generateHunt,
98
105
  healSelector: () => healSelector,
99
106
  huntSchema: () => huntSchema,
100
107
  interpolateHunt: () => interpolateHunt,
108
+ launchMacSession: () => launchMacSession,
101
109
  listHunts: () => listHunts,
102
110
  loadConfig: () => loadConfig,
103
111
  loadHunt: () => loadHunt,
104
112
  loadHuntMeta: () => loadHuntMeta,
105
113
  loadHuntTags: () => loadHuntTags,
114
+ macdriverBuildInstructions: () => macdriverBuildInstructions,
115
+ parseMacSelector: () => parseMacSelector,
106
116
  rankFlaky: () => rankFlaky,
107
117
  readHistory: () => readHistory,
108
118
  readHuntHistory: () => readHuntHistory,
119
+ resolveHelperBinary: () => resolveHelperBinary,
109
120
  runHunt: () => runHunt,
110
121
  runSuite: () => runSuite,
111
122
  stepSchema: () => stepSchema,
112
- updateBacklogFromSuite: () => updateBacklogFromSuite
123
+ updateBacklogFromSuite: () => updateBacklogFromSuite,
124
+ webOnlyReason: () => webOnlyReason
113
125
  });
114
126
  module.exports = __toCommonJS(src_exports);
115
127
 
116
- // src/runner/index.ts
117
- var import_node_fs9 = __toESM(require("fs"), 1);
118
- var import_node_path9 = __toESM(require("path"), 1);
128
+ // src/browser/mac-driver.ts
129
+ var MAC_CAPABILITIES = /* @__PURE__ */ new Set([
130
+ "query",
131
+ "interact",
132
+ "wait",
133
+ "screenshot"
134
+ ]);
135
+ var STATUS_ITEM_SELECTOR = "statusitem";
136
+ var MENU_PREFIX = "menu=";
137
+ function unquote(value) {
138
+ const trimmed = value.trim();
139
+ const first = trimmed[0];
140
+ if ((first === '"' || first === "'") && trimmed.endsWith(first) && trimmed.length >= 2) {
141
+ return trimmed.slice(1, -1);
142
+ }
143
+ return trimmed;
144
+ }
145
+ function parseMacSelector(selector) {
146
+ const trimmed = selector.trim();
147
+ const idMatch = /^id=(.+)$/s.exec(trimmed);
148
+ if (idMatch) {
149
+ return { by: "id", value: unquote(idMatch[1]) };
150
+ }
151
+ const roleMatch = /^role=([A-Za-z][\w-]*)(?:\[name=(.+)\])?$/s.exec(trimmed);
152
+ if (roleMatch) {
153
+ const name = roleMatch[2] !== void 0 ? unquote(roleMatch[2]) : void 0;
154
+ return name !== void 0 && name.length > 0 ? { by: "role", role: roleMatch[1], name } : { by: "role", role: roleMatch[1] };
155
+ }
156
+ const labelMatch = /^label=(.+)$/s.exec(trimmed);
157
+ if (labelMatch) {
158
+ return { by: "label", value: unquote(labelMatch[1]) };
159
+ }
160
+ const textMatch = /^text=(.+)$/s.exec(trimmed);
161
+ if (textMatch) {
162
+ return { by: "text", value: unquote(textMatch[1]) };
163
+ }
164
+ return { by: "text", value: trimmed };
165
+ }
166
+ function unwrapMacTextSelector(selector) {
167
+ const trimmed = selector.trim();
168
+ if (!trimmed.startsWith("text=")) {
169
+ return null;
170
+ }
171
+ return unquote(trimmed.slice(5));
172
+ }
173
+ function num(value) {
174
+ return typeof value === "number" ? value : Number(value ?? 0);
175
+ }
176
+ function createMacDriver(client, options = {}) {
177
+ const unsupported = (verb) => new Error(`${verb} is not supported by the macOS target`);
178
+ const rejectUnsupported = (verb) => Promise.reject(unsupported(verb));
179
+ async function query(cmd, selector, extra) {
180
+ return client.request(cmd, { query: parseMacSelector(selector), ...extra });
181
+ }
182
+ async function clickSelector(selector) {
183
+ const trimmed = selector.trim();
184
+ if (trimmed.toLowerCase() === STATUS_ITEM_SELECTOR) {
185
+ await client.request("openMenu");
186
+ return;
187
+ }
188
+ if (trimmed.toLowerCase().startsWith(MENU_PREFIX)) {
189
+ await client.request("clickMenu", { title: trimmed.slice(MENU_PREFIX.length).trim() });
190
+ return;
191
+ }
192
+ await query("click", selector);
193
+ }
194
+ async function fillSelector(selector, value) {
195
+ if (selector.trim() === ":focus") {
196
+ await client.request("fill", { query: { by: "focused" }, value });
197
+ return;
198
+ }
199
+ await query("fill", selector, { value });
200
+ }
201
+ return {
202
+ capabilities: MAC_CAPABILITIES,
203
+ // navigation -----------------------------------------------------------
204
+ goto(_url, _options) {
205
+ return rejectUnsupported("navigate");
206
+ },
207
+ currentUrl() {
208
+ return `macos:${options.appLabel ?? ""}`;
209
+ },
210
+ // queries --------------------------------------------------------------
211
+ async count(selector) {
212
+ const result = await query("count", selector);
213
+ return num(result.count);
214
+ },
215
+ async textContent(selector) {
216
+ const result = await query("text", selector);
217
+ return result.text === void 0 || result.text === null ? null : String(result.text);
218
+ },
219
+ // interactions ---------------------------------------------------------
220
+ click: clickSelector,
221
+ clickFirst: clickSelector,
222
+ fill: fillSelector,
223
+ fillFirst: fillSelector,
224
+ async press(selector, key) {
225
+ await query("press", selector, { key });
226
+ },
227
+ selectOption() {
228
+ return rejectUnsupported("select");
229
+ },
230
+ selectOptionFirst() {
231
+ return rejectUnsupported("select");
232
+ },
233
+ async hover(selector) {
234
+ await query("hover", selector);
235
+ },
236
+ async scrollIntoView(selector) {
237
+ await query("scrollTo", selector);
238
+ },
239
+ setInputFiles() {
240
+ return rejectUnsupported("setInputFiles");
241
+ },
242
+ // semantic locators ----------------------------------------------------
243
+ async countByRole(role, name) {
244
+ const result = await client.request("count", { query: { by: "role", role, name } });
245
+ return num(result.count);
246
+ },
247
+ async clickFirstByRole(role, name) {
248
+ await client.request("click", { query: { by: "role", role, name } });
249
+ },
250
+ async countByLabel(label) {
251
+ const result = await client.request("count", { query: { by: "label", value: label } });
252
+ return num(result.count);
253
+ },
254
+ async fillFirstByLabel(label, value) {
255
+ await client.request("fill", { query: { by: "label", value: label }, value });
256
+ },
257
+ selectOptionFirstByLabel() {
258
+ return rejectUnsupported("select");
259
+ },
260
+ // waiting --------------------------------------------------------------
261
+ async waitForSelector(selector, waitOptions) {
262
+ const extra = waitOptions?.timeout !== void 0 ? { timeout: waitOptions.timeout / 1e3 } : void 0;
263
+ await query("waitFor", selector, extra);
264
+ },
265
+ waitForUrl() {
266
+ return rejectUnsupported("waitForUrl");
267
+ },
268
+ waitForNetworkIdle() {
269
+ return rejectUnsupported("waitForNetworkIdle");
270
+ },
271
+ // scripting & artifacts ------------------------------------------------
272
+ evaluate() {
273
+ return rejectUnsupported("evalScript");
274
+ },
275
+ async screenshot(screenshotOptions) {
276
+ await client.request("screenshot", { path: screenshotOptions.path });
277
+ },
278
+ // network / dialogs / downloads (all web-only) -------------------------
279
+ onResponse(_handler) {
280
+ throw unsupported("onResponse");
281
+ },
282
+ route(_url, _handler) {
283
+ return rejectUnsupported("mockRoute");
284
+ },
285
+ unroute() {
286
+ return rejectUnsupported("unmockRoute");
287
+ },
288
+ onDialog(_action) {
289
+ throw unsupported("onDialog");
290
+ },
291
+ waitForDownloadEvent() {
292
+ return rejectUnsupported("waitForDownload");
293
+ },
294
+ parseTextSelector(selector) {
295
+ return unwrapMacTextSelector(selector);
296
+ }
297
+ };
298
+ }
119
299
 
120
- // src/config/loader.ts
300
+ // src/browser/mac-helper.ts
301
+ var import_node_child_process = require("child_process");
121
302
  var import_node_fs = __toESM(require("fs"), 1);
122
303
  var import_node_path = __toESM(require("path"), 1);
304
+ var import_node_url = require("url");
305
+ var import_meta = {};
306
+ var HELPER_BINARY = "prowl-macdriver";
307
+ function macdriverBuildInstructions() {
308
+ return "The macOS target requires the experimental `prowl-macdriver` helper, which is not shipped in the npm package. Build it locally:\n cd macdriver && swift build -c release\nor point Prowl at a prebuilt binary via the PROWL_MACDRIVER_BIN environment variable.";
309
+ }
310
+ function getPackageRoot() {
311
+ let dir = import_node_path.default.dirname((0, import_node_url.fileURLToPath)(import_meta.url));
312
+ const root = import_node_path.default.parse(dir).root;
313
+ while (dir !== root) {
314
+ if (import_node_fs.default.existsSync(import_node_path.default.join(dir, "package.json"))) {
315
+ return dir;
316
+ }
317
+ dir = import_node_path.default.dirname(dir);
318
+ }
319
+ return root;
320
+ }
321
+ function resolveHelperBinary(env = process.env) {
322
+ const override = env.PROWL_MACDRIVER_BIN;
323
+ if (override) {
324
+ if (!import_node_fs.default.existsSync(override)) {
325
+ throw new Error(
326
+ `PROWL_MACDRIVER_BIN points at a missing file: ${override}
327
+ ${macdriverBuildInstructions()}`
328
+ );
329
+ }
330
+ return override;
331
+ }
332
+ const root = getPackageRoot();
333
+ const candidates = [
334
+ import_node_path.default.join(root, "macdriver", ".build", "release", HELPER_BINARY),
335
+ import_node_path.default.join(root, "macdriver", ".build", "debug", HELPER_BINARY)
336
+ ];
337
+ for (const candidate of candidates) {
338
+ if (import_node_fs.default.existsSync(candidate)) {
339
+ return candidate;
340
+ }
341
+ }
342
+ throw new Error(`Could not find the ${HELPER_BINARY} helper binary.
343
+ ${macdriverBuildInstructions()}`);
344
+ }
345
+ var DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
346
+ var SpawnMacHelperClient = class {
347
+ child;
348
+ pending = /* @__PURE__ */ new Map();
349
+ requestTimeoutMs;
350
+ stdoutBuffer = "";
351
+ stderrBuffer = "";
352
+ nextId = 1;
353
+ closed = false;
354
+ terminalError;
355
+ constructor(binaryPath, options = {}) {
356
+ this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
357
+ this.child = (0, import_node_child_process.spawn)(binaryPath, ["serve"], { stdio: ["pipe", "pipe", "pipe"] });
358
+ this.child.stdout?.setEncoding("utf-8");
359
+ this.child.stderr?.setEncoding("utf-8");
360
+ this.child.stdout?.on("data", (chunk) => this.onStdout(chunk));
361
+ this.child.stderr?.on("data", (chunk) => {
362
+ this.stderrBuffer = (this.stderrBuffer + chunk).slice(-4e3);
363
+ });
364
+ this.child.on("error", (error) => this.recordTerminalFailure(error));
365
+ this.child.on("exit", (code) => {
366
+ if (!this.closed) {
367
+ const detail = this.stderrBuffer.trim();
368
+ this.recordTerminalFailure(
369
+ new Error(`prowl-macdriver exited unexpectedly (code ${code ?? "null"})${detail ? `: ${detail}` : ""}`)
370
+ );
371
+ }
372
+ });
373
+ }
374
+ onStdout(chunk) {
375
+ this.stdoutBuffer += chunk;
376
+ let newlineIndex = this.stdoutBuffer.indexOf("\n");
377
+ while (newlineIndex !== -1) {
378
+ const line = this.stdoutBuffer.slice(0, newlineIndex).trim();
379
+ this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1);
380
+ if (line.length > 0) {
381
+ this.dispatch(line);
382
+ }
383
+ newlineIndex = this.stdoutBuffer.indexOf("\n");
384
+ }
385
+ }
386
+ dispatch(line) {
387
+ let message;
388
+ try {
389
+ message = JSON.parse(line);
390
+ } catch {
391
+ return;
392
+ }
393
+ const id = typeof message.id === "number" ? message.id : void 0;
394
+ if (id === void 0) {
395
+ return;
396
+ }
397
+ const pending = this.pending.get(id);
398
+ if (!pending) {
399
+ return;
400
+ }
401
+ this.pending.delete(id);
402
+ clearTimeout(pending.timer);
403
+ if (message.ok === true) {
404
+ pending.resolve(message.result ?? {});
405
+ } else {
406
+ pending.reject(new Error(typeof message.error === "string" ? message.error : "prowl-macdriver error"));
407
+ }
408
+ }
409
+ failAll(error) {
410
+ for (const pending of this.pending.values()) {
411
+ clearTimeout(pending.timer);
412
+ pending.reject(error);
413
+ }
414
+ this.pending.clear();
415
+ }
416
+ recordTerminalFailure(error) {
417
+ this.terminalError ??= error;
418
+ this.closed = true;
419
+ this.failAll(this.terminalError);
420
+ }
421
+ /** Number of in-flight requests awaiting a response (for teardown/tests). */
422
+ get pendingCount() {
423
+ return this.pending.size;
424
+ }
425
+ request(cmd, params = {}) {
426
+ if (this.terminalError) {
427
+ return Promise.reject(this.terminalError);
428
+ }
429
+ if (this.closed) {
430
+ return Promise.reject(new Error("prowl-macdriver client is closed"));
431
+ }
432
+ const id = this.nextId++;
433
+ const payload = JSON.stringify({ id, cmd, ...params });
434
+ return new Promise((resolve, reject) => {
435
+ const timer = setTimeout(() => {
436
+ if (this.pending.delete(id)) {
437
+ const shown = this.requestTimeoutMs >= 1e3 ? `${Math.round(this.requestTimeoutMs / 1e3)}s` : `${this.requestTimeoutMs}ms`;
438
+ reject(new Error(`prowl-macdriver request "${cmd}" timed out after ${shown}`));
439
+ }
440
+ }, this.requestTimeoutMs);
441
+ timer.unref?.();
442
+ this.pending.set(id, { cmd, resolve, reject, timer });
443
+ this.child.stdin?.write(payload + "\n", (error) => {
444
+ if (error && this.pending.delete(id)) {
445
+ clearTimeout(timer);
446
+ reject(error);
447
+ }
448
+ });
449
+ });
450
+ }
451
+ async close() {
452
+ if (this.closed) {
453
+ return;
454
+ }
455
+ this.closed = true;
456
+ try {
457
+ this.child.stdin?.write(JSON.stringify({ cmd: "shutdown" }) + "\n");
458
+ this.child.stdin?.end();
459
+ } catch {
460
+ }
461
+ await new Promise((resolve) => {
462
+ if (this.child.exitCode !== null || this.child.signalCode !== null) {
463
+ resolve();
464
+ return;
465
+ }
466
+ const timer = setTimeout(() => {
467
+ this.child.kill("SIGKILL");
468
+ resolve();
469
+ }, 2e3);
470
+ this.child.once("exit", () => {
471
+ clearTimeout(timer);
472
+ resolve();
473
+ });
474
+ });
475
+ this.failAll(new Error("prowl-macdriver client is closed"));
476
+ }
477
+ };
478
+ async function launchMacSession(options) {
479
+ const requestTimeoutMs = Math.max(options.timeoutMs ?? 1e4, DEFAULT_REQUEST_TIMEOUT_MS) + 5e3;
480
+ const client = options.clientFactory ? options.clientFactory() : new SpawnMacHelperClient(resolveHelperBinary(), { requestTimeoutMs });
481
+ const timeoutSeconds = (options.timeoutMs ?? 1e4) / 1e3;
482
+ try {
483
+ const trust = await client.request("check");
484
+ if (trust.trusted !== true) {
485
+ throw new Error(
486
+ "Prowl's macOS target is not trusted for Accessibility. Grant the hosting terminal/app permission in System Settings \u2192 Privacy & Security \u2192 Accessibility, then retry."
487
+ );
488
+ }
489
+ const launched = await client.request("launch", { app: options.app, timeout: timeoutSeconds });
490
+ const bundleId = String(launched.bundleId ?? options.app);
491
+ const driver = createMacDriver(client, { appLabel: bundleId });
492
+ return { client, driver, bundleId };
493
+ } catch (error) {
494
+ await client.close().catch(() => void 0);
495
+ throw error;
496
+ }
497
+ }
498
+ async function closeMacSession(session) {
499
+ try {
500
+ await session.client.request("quit");
501
+ } catch {
502
+ } finally {
503
+ await session.client.close();
504
+ }
505
+ }
506
+
507
+ // src/config/target.ts
508
+ var import_node_child_process2 = require("child_process");
509
+ var import_node_fs2 = __toESM(require("fs"), 1);
510
+ var import_node_path2 = __toESM(require("path"), 1);
511
+ var WEB_ONLY_STEP_TYPES = /* @__PURE__ */ new Set([
512
+ "navigate",
513
+ "waitForUrl",
514
+ "waitForNetworkIdle",
515
+ "mockRoute",
516
+ "unmockRoute",
517
+ "evalScript",
518
+ "runScript",
519
+ "onDialog",
520
+ "select",
521
+ "selectOption",
522
+ "setInputFiles",
523
+ "waitForDownload",
524
+ "scroll"
525
+ // directional scroll runs window.scrollBy (evaluate) — use scrollTo instead
526
+ ]);
527
+ function webOnlyReason(step) {
528
+ for (const type of WEB_ONLY_STEP_TYPES) {
529
+ if (type in step) {
530
+ return type;
531
+ }
532
+ }
533
+ if ("assert" in step) {
534
+ const assertion = step.assert;
535
+ if (assertion.urlIncludes !== void 0 || assertion.urlEquals !== void 0) {
536
+ return "assert (url)";
537
+ }
538
+ }
539
+ return null;
540
+ }
541
+ function assertStepsSupportedByTarget(steps, target) {
542
+ if (target !== "macos") {
543
+ return;
544
+ }
545
+ for (const step of steps) {
546
+ const reason = webOnlyReason(step);
547
+ if (reason) {
548
+ throw new Error(
549
+ `Step "${reason}" is not supported by the macOS target. It is web-only; use a portable step (click, fill, type, press, wait, assert visible, screenshot, etc.).`
550
+ );
551
+ }
552
+ if ("if" in step) {
553
+ assertStepsSupportedByTarget(step.if.then, target);
554
+ if (step.if.else) {
555
+ assertStepsSupportedByTarget(step.if.else, target);
556
+ }
557
+ }
558
+ if ("repeat" in step) {
559
+ assertStepsSupportedByTarget(step.repeat.steps, target);
560
+ }
561
+ }
562
+ }
563
+ function assertHuntAssertionsSupportedByTarget(assertions, target) {
564
+ if (target !== "macos" || !assertions || assertions.length === 0) {
565
+ return;
566
+ }
567
+ throw new Error(
568
+ "Hunt-level assertions are not supported by the macOS target. Use inline assert visible/notVisible steps instead."
569
+ );
570
+ }
571
+ function trimTrailingPathSeparators(value) {
572
+ return value.replace(/[\\/]+$/g, "");
573
+ }
574
+ function looksLikeMacosAppPath(app) {
575
+ const trimmed = trimTrailingPathSeparators(app);
576
+ return trimmed.includes("/") || trimmed.toLowerCase().endsWith(".app");
577
+ }
578
+ function normalizeAppPath(app) {
579
+ return import_node_path2.default.resolve(trimTrailingPathSeparators(app));
580
+ }
581
+ function parseBundleIdentifier(plist) {
582
+ const match = /<key>\s*CFBundleIdentifier\s*<\/key>\s*<string>\s*([^<]+?)\s*<\/string>/s.exec(plist);
583
+ return match?.[1]?.trim() || null;
584
+ }
585
+ function readBundleIdentifier(appPath) {
586
+ const infoPlistPath = import_node_path2.default.join(normalizeAppPath(appPath), "Contents", "Info.plist");
587
+ if (!import_node_fs2.default.existsSync(infoPlistPath)) {
588
+ return null;
589
+ }
590
+ try {
591
+ const parsed = parseBundleIdentifier(import_node_fs2.default.readFileSync(infoPlistPath, "utf-8"));
592
+ if (parsed) {
593
+ return parsed;
594
+ }
595
+ } catch {
596
+ }
597
+ if (process.platform !== "darwin" || !import_node_fs2.default.existsSync("/usr/bin/plutil")) {
598
+ return null;
599
+ }
600
+ try {
601
+ const output = (0, import_node_child_process2.execFileSync)(
602
+ "/usr/bin/plutil",
603
+ ["-extract", "CFBundleIdentifier", "raw", "-o", "-", infoPlistPath],
604
+ { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 1e3 }
605
+ );
606
+ return output.trim() || null;
607
+ } catch {
608
+ return null;
609
+ }
610
+ }
611
+ function macosAppAllowedIdentities(app) {
612
+ const identities = /* @__PURE__ */ new Set([app]);
613
+ if (looksLikeMacosAppPath(app)) {
614
+ const normalizedPath = normalizeAppPath(app);
615
+ identities.add(trimTrailingPathSeparators(app));
616
+ identities.add(normalizedPath);
617
+ const bundleName = import_node_path2.default.basename(normalizedPath).replace(/\.app$/i, "");
618
+ if (bundleName) {
619
+ identities.add(bundleName);
620
+ }
621
+ const bundleId = readBundleIdentifier(app);
622
+ if (bundleId) {
623
+ identities.add(bundleId);
624
+ }
625
+ }
626
+ return [...identities];
627
+ }
628
+ function assertTargetAppAllowed(allowedApps, app) {
629
+ if (allowedApps.length === 0) {
630
+ return;
631
+ }
632
+ const allowedIdentities = new Set(allowedApps.flatMap((allowedApp) => macosAppAllowedIdentities(allowedApp)));
633
+ if (macosAppAllowedIdentities(app).some((identity) => allowedIdentities.has(identity))) {
634
+ return;
635
+ }
636
+ throw new Error(
637
+ `Target app "${app}" is not in guardrails.allowedApps (${allowedApps.join(", ")}).`
638
+ );
639
+ }
640
+
641
+ // src/runner/index.ts
642
+ var import_node_fs11 = __toESM(require("fs"), 1);
643
+ var import_node_path11 = __toESM(require("path"), 1);
644
+
645
+ // src/config/loader.ts
646
+ var import_node_fs3 = __toESM(require("fs"), 1);
647
+ var import_node_path3 = __toESM(require("path"), 1);
123
648
  var import_yaml = __toESM(require("yaml"), 1);
124
649
  var import_dotenv = __toESM(require("dotenv"), 1);
125
650
 
126
651
  // src/config/schema.ts
127
652
  var import_zod = require("zod");
128
653
 
654
+ // src/types/index.ts
655
+ var SUPPORTED_BROWSER_ENGINES = ["chromium", "firefox", "webkit"];
656
+
129
657
  // src/config/hunt-name.ts
130
658
  var HUNT_NAME_PATTERN = /^[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)*$/;
131
659
  function isValidHuntName(name) {
@@ -140,15 +668,22 @@ function assertValidHuntName(name) {
140
668
  }
141
669
 
142
670
  // src/config/schema.ts
671
+ var webTargetSchema = import_zod.z.object({
672
+ type: import_zod.z.literal("web").optional(),
673
+ url: import_zod.z.string().min(1)
674
+ }).strict();
675
+ var macosTargetSchema = import_zod.z.object({
676
+ type: import_zod.z.literal("macos"),
677
+ app: import_zod.z.string().min(1)
678
+ }).strict();
679
+ var targetSchema = import_zod.z.union([macosTargetSchema, webTargetSchema]);
143
680
  var configSchema = import_zod.z.object({
144
- target: import_zod.z.object({
145
- url: import_zod.z.string().min(1)
146
- }),
681
+ target: targetSchema,
147
682
  browser: import_zod.z.object({
148
683
  headless: import_zod.z.boolean().optional(),
149
684
  slowMo: import_zod.z.number().optional(),
150
685
  timeout: import_zod.z.number().optional(),
151
- engine: import_zod.z.enum(["chromium", "firefox", "webkit"]).optional(),
686
+ engine: import_zod.z.enum(SUPPORTED_BROWSER_ENGINES).optional(),
152
687
  channel: import_zod.z.enum([
153
688
  "chromium",
154
689
  "chrome",
@@ -180,6 +715,7 @@ var configSchema = import_zod.z.object({
180
715
  guardrails: import_zod.z.object({
181
716
  maxSteps: import_zod.z.number().optional(),
182
717
  allowedDomains: import_zod.z.array(import_zod.z.string()).optional(),
718
+ allowedApps: import_zod.z.array(import_zod.z.string()).optional(),
183
719
  forbiddenSelectors: import_zod.z.array(import_zod.z.string()).optional(),
184
720
  selfHealing: import_zod.z.boolean().optional()
185
721
  }).optional(),
@@ -427,9 +963,11 @@ var huntSchema = import_zod.z.object({
427
963
  }).strict();
428
964
 
429
965
  // src/config/loader.ts
966
+ var DEFAULT_WEB_URL = "http://localhost:3000";
430
967
  var DEFAULT_CONFIG = {
431
968
  target: {
432
- url: "http://localhost:3000"
969
+ type: "web",
970
+ url: DEFAULT_WEB_URL
433
971
  },
434
972
  browser: {
435
973
  headless: true,
@@ -453,6 +991,7 @@ var DEFAULT_CONFIG = {
453
991
  guardrails: {
454
992
  maxSteps: 50,
455
993
  allowedDomains: ["localhost", "127.0.0.1", "0.0.0.0"],
994
+ allowedApps: [],
456
995
  forbiddenSelectors: ["[data-danger]", ".delete-btn"],
457
996
  selfHealing: false
458
997
  },
@@ -479,12 +1018,12 @@ function findConfigPath(startDir) {
479
1018
  let current = startDir;
480
1019
  while (current) {
481
1020
  for (const dir of [CONFIG_DIR, LEGACY_CONFIG_DIR]) {
482
- const candidate = import_node_path.default.join(current, dir, "config.yml");
483
- if (import_node_fs.default.existsSync(candidate)) {
1021
+ const candidate = import_node_path3.default.join(current, dir, "config.yml");
1022
+ if (import_node_fs3.default.existsSync(candidate)) {
484
1023
  return candidate;
485
1024
  }
486
1025
  }
487
- const parent = import_node_path.default.dirname(current);
1026
+ const parent = import_node_path3.default.dirname(current);
488
1027
  if (parent === current) {
489
1028
  break;
490
1029
  }
@@ -510,11 +1049,18 @@ function resolveViewport(value) {
510
1049
  }
511
1050
  return value;
512
1051
  }
1052
+ function resolveTarget(target) {
1053
+ if (target && target.type === "macos") {
1054
+ return { type: "macos", app: target.app };
1055
+ }
1056
+ return {
1057
+ type: "web",
1058
+ url: target?.url ?? DEFAULT_WEB_URL
1059
+ };
1060
+ }
513
1061
  function mergeConfig(partial) {
514
1062
  return {
515
- target: {
516
- url: partial.target?.url ?? DEFAULT_CONFIG.target.url
517
- },
1063
+ target: resolveTarget(partial.target),
518
1064
  browser: {
519
1065
  headless: partial.browser?.headless ?? DEFAULT_CONFIG.browser.headless,
520
1066
  slowMo: partial.browser?.slowMo ?? DEFAULT_CONFIG.browser.slowMo,
@@ -538,6 +1084,7 @@ function mergeConfig(partial) {
538
1084
  guardrails: {
539
1085
  maxSteps: partial.guardrails?.maxSteps ?? DEFAULT_CONFIG.guardrails.maxSteps,
540
1086
  allowedDomains: partial.guardrails?.allowedDomains ?? DEFAULT_CONFIG.guardrails.allowedDomains,
1087
+ allowedApps: partial.guardrails?.allowedApps ?? DEFAULT_CONFIG.guardrails.allowedApps,
541
1088
  forbiddenSelectors: partial.guardrails?.forbiddenSelectors ?? DEFAULT_CONFIG.guardrails.forbiddenSelectors,
542
1089
  selfHealing: partial.guardrails?.selfHealing ?? DEFAULT_CONFIG.guardrails.selfHealing
543
1090
  },
@@ -564,56 +1111,58 @@ function ensureAllowedDomain(allowed, urlValue) {
564
1111
  return allowed;
565
1112
  }
566
1113
  function loadConfig(configPath) {
567
- const resolvedPath = configPath ? import_node_path.default.resolve(configPath) : findConfigPath(process.cwd());
1114
+ const resolvedPath = configPath ? import_node_path3.default.resolve(configPath) : findConfigPath(process.cwd());
568
1115
  if (!resolvedPath) {
569
1116
  throw new Error("Could not find .prowl/config.yml. Run `prowl init` first.");
570
1117
  }
571
- if (!import_node_fs.default.existsSync(resolvedPath)) {
1118
+ if (!import_node_fs3.default.existsSync(resolvedPath)) {
572
1119
  throw new Error(`Config file not found at ${resolvedPath}`);
573
1120
  }
574
- const configDir = import_node_path.default.dirname(resolvedPath);
575
- if (import_node_path.default.basename(configDir) === LEGACY_CONFIG_DIR) {
1121
+ const configDir = import_node_path3.default.dirname(resolvedPath);
1122
+ if (import_node_path3.default.basename(configDir) === LEGACY_CONFIG_DIR) {
576
1123
  warnLegacyConfigDir();
577
1124
  }
578
- import_dotenv.default.config({ path: import_node_path.default.join(configDir, ".env"), override: false });
579
- const raw = import_node_fs.default.readFileSync(resolvedPath, "utf-8");
1125
+ import_dotenv.default.config({ path: import_node_path3.default.join(configDir, ".env"), override: false });
1126
+ const raw = import_node_fs3.default.readFileSync(resolvedPath, "utf-8");
580
1127
  const parsed = import_yaml.default.parse(raw) ?? {};
581
1128
  const validated = configSchema.parse(parsed);
582
1129
  const config = mergeConfig(validated);
583
- config.guardrails.allowedDomains = ensureAllowedDomain(
584
- config.guardrails.allowedDomains,
585
- config.target.url
586
- );
1130
+ if (config.target.type === "web") {
1131
+ config.guardrails.allowedDomains = ensureAllowedDomain(
1132
+ config.guardrails.allowedDomains,
1133
+ config.target.url
1134
+ );
1135
+ }
587
1136
  return { config, configPath: resolvedPath, configDir };
588
1137
  }
589
1138
  function loadHunt(huntName, configDir) {
590
1139
  assertValidHuntName(huntName);
591
- const huntPath = import_node_path.default.join(configDir, "hunts", `${huntName}.yml`);
592
- if (!import_node_fs.default.existsSync(huntPath)) {
1140
+ const huntPath = import_node_path3.default.join(configDir, "hunts", `${huntName}.yml`);
1141
+ if (!import_node_fs3.default.existsSync(huntPath)) {
593
1142
  throw new Error(`Hunt file not found: ${huntPath}`);
594
1143
  }
595
- const raw = import_node_fs.default.readFileSync(huntPath, "utf-8");
1144
+ const raw = import_node_fs3.default.readFileSync(huntPath, "utf-8");
596
1145
  const parsed = import_yaml.default.parse(raw) ?? {};
597
1146
  const validated = huntSchema.parse(parsed);
598
1147
  return validated;
599
1148
  }
600
1149
  function loadHuntTags(huntName, configDir) {
601
1150
  assertValidHuntName(huntName);
602
- const huntPath = import_node_path.default.join(configDir, "hunts", `${huntName}.yml`);
603
- if (!import_node_fs.default.existsSync(huntPath)) {
1151
+ const huntPath = import_node_path3.default.join(configDir, "hunts", `${huntName}.yml`);
1152
+ if (!import_node_fs3.default.existsSync(huntPath)) {
604
1153
  return [];
605
1154
  }
606
- const raw = import_node_fs.default.readFileSync(huntPath, "utf-8");
1155
+ const raw = import_node_fs3.default.readFileSync(huntPath, "utf-8");
607
1156
  const parsed = import_yaml.default.parse(raw) ?? {};
608
1157
  return Array.isArray(parsed.tags) ? parsed.tags : [];
609
1158
  }
610
1159
  function loadHuntMeta(huntName, configDir) {
611
1160
  assertValidHuntName(huntName);
612
- const huntPath = import_node_path.default.join(configDir, "hunts", `${huntName}.yml`);
613
- if (!import_node_fs.default.existsSync(huntPath)) {
1161
+ const huntPath = import_node_path3.default.join(configDir, "hunts", `${huntName}.yml`);
1162
+ if (!import_node_fs3.default.existsSync(huntPath)) {
614
1163
  return { tags: [] };
615
1164
  }
616
- const raw = import_node_fs.default.readFileSync(huntPath, "utf-8");
1165
+ const raw = import_node_fs3.default.readFileSync(huntPath, "utf-8");
617
1166
  const parsed = import_yaml.default.parse(raw) ?? {};
618
1167
  return {
619
1168
  description: typeof parsed.description === "string" ? parsed.description : void 0,
@@ -621,24 +1170,24 @@ function loadHuntMeta(huntName, configDir) {
621
1170
  };
622
1171
  }
623
1172
  function listHunts(configDir) {
624
- const huntsDir = import_node_path.default.join(configDir, "hunts");
625
- if (!import_node_fs.default.existsSync(huntsDir)) {
1173
+ const huntsDir = import_node_path3.default.join(configDir, "hunts");
1174
+ if (!import_node_fs3.default.existsSync(huntsDir)) {
626
1175
  return [];
627
1176
  }
628
- const stats = import_node_fs.default.statSync(huntsDir);
1177
+ const stats = import_node_fs3.default.statSync(huntsDir);
629
1178
  if (!stats.isDirectory()) {
630
1179
  throw new Error(`Hunts path is not a directory: ${huntsDir}`);
631
1180
  }
632
1181
  const results = [];
633
1182
  function scanDir(dir) {
634
- const entries = import_node_fs.default.readdirSync(dir, { withFileTypes: true });
1183
+ const entries = import_node_fs3.default.readdirSync(dir, { withFileTypes: true });
635
1184
  for (const entry of entries) {
636
1185
  if (entry.isFile() && entry.name.endsWith(".yml")) {
637
- const fullPath = import_node_path.default.join(dir, entry.name);
638
- const relative = import_node_path.default.relative(huntsDir, fullPath);
1186
+ const fullPath = import_node_path3.default.join(dir, entry.name);
1187
+ const relative = import_node_path3.default.relative(huntsDir, fullPath);
639
1188
  results.push(relative.replace(/\.yml$/, ""));
640
1189
  } else if (entry.isDirectory()) {
641
- scanDir(import_node_path.default.join(dir, entry.name));
1190
+ scanDir(import_node_path3.default.join(dir, entry.name));
642
1191
  }
643
1192
  }
644
1193
  }
@@ -691,7 +1240,7 @@ function generateRandomVars(randomSource) {
691
1240
  const hex = randomBytes(4).toString("hex");
692
1241
  const firstIndex = Math.floor(random() * RANDOM_FIRST_NAMES.length);
693
1242
  const lastIndex = Math.floor(random() * RANDOM_LAST_NAMES.length);
694
- const num = Math.floor(random() * 9e3) + 1e3;
1243
+ const num2 = Math.floor(random() * 9e3) + 1e3;
695
1244
  const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
696
1245
  let text = "";
697
1246
  for (let i = 0; i < 8; i++) {
@@ -700,7 +1249,7 @@ function generateRandomVars(randomSource) {
700
1249
  return {
701
1250
  RANDOM_EMAIL: `prowl_${hex}@test.com`,
702
1251
  RANDOM_NAME: `${RANDOM_FIRST_NAMES[firstIndex]} ${RANDOM_LAST_NAMES[lastIndex]}`,
703
- RANDOM_NUMBER: String(num),
1252
+ RANDOM_NUMBER: String(num2),
704
1253
  RANDOM_UUID: randomUUID(),
705
1254
  RANDOM_TEXT: text
706
1255
  };
@@ -1012,81 +1561,223 @@ function interpolateHunt(hunt, env, randomVars = generateRandomVars()) {
1012
1561
  };
1013
1562
  }
1014
1563
 
1015
- // src/browser/controller.ts
1016
- var import_node_fs2 = __toESM(require("fs"), 1);
1017
- var import_node_path2 = __toESM(require("path"), 1);
1564
+ // src/browser/playwright-driver.ts
1565
+ var import_node_fs4 = __toESM(require("fs"), 1);
1566
+ var import_node_path4 = __toESM(require("path"), 1);
1018
1567
  var import_playwright = require("playwright");
1019
1568
  var ENGINES = { chromium: import_playwright.chromium, firefox: import_playwright.firefox, webkit: import_playwright.webkit };
1020
1569
  async function launchBrowser(options) {
1021
- const engine = ENGINES[options.engine ?? "chromium"];
1570
+ const engineName = options.engine ?? "chromium";
1571
+ const engine = Object.prototype.hasOwnProperty.call(ENGINES, engineName) ? ENGINES[engineName] : void 0;
1572
+ if (!engine) {
1573
+ throw new Error(
1574
+ `Unsupported browser engine "${String(engineName)}". Available engines: ${Object.keys(ENGINES).join(", ")}.`
1575
+ );
1576
+ }
1022
1577
  const browser = await engine.launch({
1023
1578
  headless: options.headless,
1024
1579
  slowMo: options.slowMo,
1025
1580
  channel: options.channel
1026
1581
  });
1027
- const contextOptions = {};
1028
- if (options.viewport) {
1029
- contextOptions.viewport = options.viewport;
1030
- }
1031
- if (options.storageStatePath) {
1032
- if (import_node_fs2.default.existsSync(options.storageStatePath)) {
1033
- contextOptions.storageState = options.storageStatePath;
1034
- } else {
1035
- console.warn(`Auth state file not found: ${options.storageStatePath}. Run "prowl login" to create it.`);
1582
+ try {
1583
+ const contextOptions = {};
1584
+ if (options.viewport) {
1585
+ contextOptions.viewport = options.viewport;
1036
1586
  }
1587
+ if (options.storageStatePath) {
1588
+ if (import_node_fs4.default.existsSync(options.storageStatePath)) {
1589
+ contextOptions.storageState = options.storageStatePath;
1590
+ } else {
1591
+ console.warn(`Auth state file not found: ${options.storageStatePath}. Run "prowl login" to create it.`);
1592
+ }
1593
+ }
1594
+ if (options.recordHar) {
1595
+ contextOptions.recordHar = { path: import_node_path4.default.join(options.runDir, "network.har") };
1596
+ }
1597
+ const context = await browser.newContext(contextOptions);
1598
+ const page = await context.newPage();
1599
+ page.setDefaultTimeout(options.timeout);
1600
+ page.setDefaultNavigationTimeout(options.timeout);
1601
+ let tracePath;
1602
+ if (options.trace) {
1603
+ tracePath = import_node_path4.default.join(options.runDir, "trace.zip");
1604
+ await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
1605
+ }
1606
+ return { browser, context, page, tracePath };
1607
+ } catch (error) {
1608
+ try {
1609
+ await browser.close();
1610
+ } catch (closeError) {
1611
+ console.warn(`Failed to close browser after setup error: ${formatError(closeError)}`);
1612
+ }
1613
+ throw error;
1037
1614
  }
1038
- if (options.recordHar) {
1039
- contextOptions.recordHar = { path: import_node_path2.default.join(options.runDir, "network.har") };
1040
- }
1041
- const context = await browser.newContext(contextOptions);
1042
- const page = await context.newPage();
1043
- page.setDefaultTimeout(options.timeout);
1044
- page.setDefaultNavigationTimeout(options.timeout);
1045
- let tracePath;
1046
- if (options.trace) {
1047
- tracePath = import_node_path2.default.join(options.runDir, "trace.zip");
1048
- await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
1049
- }
1050
- return { browser, context, page, tracePath };
1051
1615
  }
1052
1616
  async function closeBrowser(session) {
1053
- if (session.tracePath) {
1054
- await session.context.tracing.stop({ path: session.tracePath });
1055
- }
1056
- await session.context.close();
1057
- await session.browser.close();
1058
- }
1059
-
1060
- // src/runner/steps.ts
1061
- var import_node_fs4 = __toESM(require("fs"), 1);
1062
- var import_node_path4 = __toESM(require("path"), 1);
1063
-
1064
- // src/browser/actions.ts
1065
- async function clickElement(page, selector) {
1066
- await page.locator(selector).click();
1067
- }
1068
- async function fillElement(page, selector, value) {
1069
- await page.locator(selector).fill(value);
1070
- }
1071
- async function pressKey(page, selector, key) {
1072
- await page.locator(selector).press(key);
1617
+ try {
1618
+ if (session.tracePath) {
1619
+ await session.context.tracing.stop({ path: session.tracePath });
1620
+ }
1621
+ await session.context.close();
1622
+ } finally {
1623
+ await session.browser.close();
1624
+ }
1625
+ }
1626
+ var ALL_CAPABILITIES = /* @__PURE__ */ new Set([
1627
+ "navigate",
1628
+ "query",
1629
+ "interact",
1630
+ "wait",
1631
+ "screenshot",
1632
+ "evaluate",
1633
+ "response",
1634
+ "route",
1635
+ "dialog",
1636
+ "files",
1637
+ "download"
1638
+ ]);
1639
+ function formatError(error) {
1640
+ return error instanceof Error ? error.message : String(error);
1073
1641
  }
1074
- async function selectOption(page, selector, value) {
1075
- await page.locator(selector).selectOption(value);
1642
+ function unwrapTextSelector(value) {
1643
+ const trimmed = value.trim();
1644
+ if (!trimmed.startsWith("text=")) {
1645
+ return null;
1646
+ }
1647
+ const raw = trimmed.slice(5);
1648
+ const first = raw[0];
1649
+ if (first === '"' || first === "'") {
1650
+ const unquoted = raw.slice(1);
1651
+ return unquoted.endsWith(first) ? unquoted.slice(0, -1) : unquoted;
1652
+ }
1653
+ return raw;
1076
1654
  }
1077
- function setupDialogHandler(page, action) {
1078
- page.once("dialog", async (dialog) => {
1079
- if (action === "accept") {
1080
- await dialog.accept();
1081
- } else {
1082
- await dialog.dismiss();
1655
+ function createPlaywrightDriver(page) {
1656
+ return {
1657
+ capabilities: ALL_CAPABILITIES,
1658
+ async goto(url, options) {
1659
+ if (options?.waitUntil !== void 0) {
1660
+ await page.goto(url, { waitUntil: options.waitUntil });
1661
+ } else {
1662
+ await page.goto(url);
1663
+ }
1664
+ },
1665
+ currentUrl() {
1666
+ return page.url();
1667
+ },
1668
+ count(selector) {
1669
+ return page.locator(selector).count();
1670
+ },
1671
+ textContent(selector) {
1672
+ return page.locator(selector).textContent();
1673
+ },
1674
+ async click(selector) {
1675
+ await page.locator(selector).click();
1676
+ },
1677
+ async clickFirst(selector) {
1678
+ await page.locator(selector).first().click();
1679
+ },
1680
+ async fill(selector, value) {
1681
+ await page.locator(selector).fill(value);
1682
+ },
1683
+ async fillFirst(selector, value) {
1684
+ await page.locator(selector).first().fill(value);
1685
+ },
1686
+ async press(selector, key) {
1687
+ await page.locator(selector).press(key);
1688
+ },
1689
+ async selectOption(selector, value) {
1690
+ await page.locator(selector).selectOption(value);
1691
+ },
1692
+ async selectOptionFirst(selector, value) {
1693
+ await page.locator(selector).first().selectOption(value);
1694
+ },
1695
+ async hover(selector) {
1696
+ await page.locator(selector).hover();
1697
+ },
1698
+ async scrollIntoView(selector) {
1699
+ await page.locator(selector).scrollIntoViewIfNeeded();
1700
+ },
1701
+ async setInputFiles(selector, files) {
1702
+ await page.locator(selector).setInputFiles(files);
1703
+ },
1704
+ countByRole(role, name) {
1705
+ return page.getByRole(role, { name }).count();
1706
+ },
1707
+ async clickFirstByRole(role, name) {
1708
+ await page.getByRole(role, { name }).first().click();
1709
+ },
1710
+ countByLabel(label) {
1711
+ return page.getByLabel(label, { exact: true }).count();
1712
+ },
1713
+ async fillFirstByLabel(label, value) {
1714
+ await page.getByLabel(label, { exact: true }).first().fill(value);
1715
+ },
1716
+ async selectOptionFirstByLabel(label, value) {
1717
+ await page.getByLabel(label, { exact: true }).first().selectOption(value);
1718
+ },
1719
+ async waitForSelector(selector, options) {
1720
+ await page.waitForSelector(selector, { timeout: options?.timeout });
1721
+ },
1722
+ async waitForUrl(predicate, options) {
1723
+ await page.waitForURL((url) => predicate(url.toString()), { timeout: options?.timeout });
1724
+ },
1725
+ async waitForNetworkIdle(options) {
1726
+ await page.waitForLoadState("networkidle", { timeout: options?.timeout });
1727
+ },
1728
+ evaluate(pageFunction, arg) {
1729
+ const raw = page.evaluate;
1730
+ const result = arg === void 0 ? raw.call(page, pageFunction) : raw.call(page, pageFunction, arg);
1731
+ return result;
1732
+ },
1733
+ async screenshot(options) {
1734
+ await page.screenshot({ path: options.path, fullPage: options.fullPage });
1735
+ },
1736
+ onResponse(handler) {
1737
+ page.on("response", handler);
1738
+ },
1739
+ async route(url, handler) {
1740
+ await page.route(url, async (pwRoute) => {
1741
+ try {
1742
+ await handler({
1743
+ fulfill: (response) => pwRoute.fulfill(response)
1744
+ });
1745
+ } catch (error) {
1746
+ try {
1747
+ await pwRoute.abort("failed");
1748
+ } catch (abortError) {
1749
+ throw new Error(
1750
+ `Route handler failed for ${url}: ${formatError(error)}. Route abort also failed: ${formatError(abortError)}`
1751
+ );
1752
+ }
1753
+ throw new Error(`Route handler failed for ${url}: ${formatError(error)}`);
1754
+ }
1755
+ });
1756
+ },
1757
+ async unroute(url) {
1758
+ await page.unroute(url);
1759
+ },
1760
+ onDialog(action) {
1761
+ page.once("dialog", (dialog) => {
1762
+ const response = action === "accept" ? dialog.accept() : dialog.dismiss();
1763
+ response.catch((error) => {
1764
+ console.warn(`Failed to ${action} dialog: ${formatError(error)}`);
1765
+ });
1766
+ });
1767
+ },
1768
+ waitForDownloadEvent(options) {
1769
+ return page.waitForEvent("download", { timeout: options?.timeout });
1770
+ },
1771
+ parseTextSelector(selector) {
1772
+ return unwrapTextSelector(selector);
1083
1773
  }
1084
- });
1085
- }
1086
- async function setInputFiles(page, selector, files) {
1087
- await page.locator(selector).setInputFiles(files);
1774
+ };
1088
1775
  }
1089
1776
 
1777
+ // src/runner/steps.ts
1778
+ var import_node_fs6 = __toESM(require("fs"), 1);
1779
+ var import_node_path6 = __toESM(require("path"), 1);
1780
+
1090
1781
  // src/runner/healing.ts
1091
1782
  var INTERACTIVE_TAGS = ["button", "a", "input", "select", "textarea"];
1092
1783
  function extractSelectorIntent(selector) {
@@ -1124,12 +1815,12 @@ function buildHealCandidates(selector) {
1124
1815
  }
1125
1816
  return candidates;
1126
1817
  }
1127
- async function healSelector(page, selector, options) {
1818
+ async function healSelector(probe, selector, options) {
1128
1819
  if (!options.enabled) return null;
1129
1820
  for (const candidate of buildHealCandidates(selector)) {
1130
1821
  let count;
1131
1822
  try {
1132
- const locator = page.locator(candidate.selector);
1823
+ const locator = probe.locator(candidate.selector);
1133
1824
  count = await locator.count();
1134
1825
  } catch {
1135
1826
  continue;
@@ -1141,74 +1832,104 @@ async function healSelector(page, selector, options) {
1141
1832
  return null;
1142
1833
  }
1143
1834
 
1144
- // src/runner/steps.ts
1835
+ // src/runner/policy.ts
1145
1836
  var ALWAYS_ALLOWED_PROTOCOLS = ["about:", "data:"];
1146
- function unwrapTextSelector(value) {
1147
- const trimmed = value.trim();
1148
- if (trimmed.startsWith('text="') && trimmed.endsWith('"')) {
1149
- return trimmed.slice(6, -1);
1150
- }
1151
- if (trimmed.startsWith("text='") && trimmed.endsWith("'")) {
1152
- return trimmed.slice(6, -1);
1153
- }
1154
- if (trimmed.startsWith("text=")) {
1155
- return trimmed.slice(5);
1156
- }
1157
- return null;
1158
- }
1159
- function matchesForbiddenPattern(selector, forbidden) {
1160
- const selectorText = unwrapTextSelector(selector);
1161
- if (selectorText === null) {
1162
- return false;
1837
+ function createRunPolicy(driver, options) {
1838
+ const { forbiddenSelectors, allowedDomains, maxSteps, selfHealing } = options;
1839
+ const allowedApps = options.allowedApps ?? [];
1840
+ function matchesForbiddenPattern(selector, forbidden) {
1841
+ const selectorText = driver.parseTextSelector(selector);
1842
+ if (selectorText === null) {
1843
+ return false;
1844
+ }
1845
+ const forbiddenText = driver.parseTextSelector(forbidden);
1846
+ if (forbiddenText !== null) {
1847
+ return selectorText.includes(forbiddenText);
1848
+ }
1849
+ return selectorText.includes(forbidden);
1163
1850
  }
1164
- const forbiddenText = unwrapTextSelector(forbidden);
1165
- if (forbiddenText !== null) {
1166
- return selectorText.includes(forbiddenText);
1851
+ function isForbiddenSelector(selector) {
1852
+ return forbiddenSelectors.some(
1853
+ (forbidden) => selector.includes(forbidden) || matchesForbiddenPattern(selector, forbidden)
1854
+ );
1167
1855
  }
1168
- return selectorText.includes(forbidden);
1169
- }
1170
- function isForbiddenSelector(selector, forbiddenSelectors) {
1171
- return forbiddenSelectors.some(
1172
- (forbidden) => selector.includes(forbidden) || matchesForbiddenPattern(selector, forbidden)
1173
- );
1174
- }
1175
- function assertAllowedSelector(selector, forbiddenSelectors) {
1176
- if (isForbiddenSelector(selector, forbiddenSelectors)) {
1177
- throw new Error(`Forbidden selector: ${selector}`);
1856
+ function assertAllowedSelector(selector) {
1857
+ if (isForbiddenSelector(selector)) {
1858
+ throw new Error(`Forbidden selector: ${selector}`);
1859
+ }
1178
1860
  }
1179
- }
1180
- async function resolveActionSelector(context, selector) {
1181
- assertAllowedSelector(selector, context.forbiddenSelectors);
1182
- if (!context.selfHealing) {
1183
- return { selector };
1861
+ function assertWithinMaxSteps(stepCount, huntName) {
1862
+ if (stepCount > maxSteps) {
1863
+ if (huntName) {
1864
+ throw new Error(`Hunt "${huntName}" has ${stepCount} steps. Max allowed is ${maxSteps}.`);
1865
+ }
1866
+ throw new Error(`Hunt has ${stepCount} steps. Max allowed is ${maxSteps}.`);
1867
+ }
1184
1868
  }
1185
- let matched = false;
1186
- try {
1187
- matched = await context.page.locator(selector).count() > 0;
1188
- } catch {
1189
- return { selector };
1869
+ function ensureUrlAllowed(urlValue) {
1870
+ for (const protocol of ALWAYS_ALLOWED_PROTOCOLS) {
1871
+ if (urlValue.startsWith(protocol)) {
1872
+ return;
1873
+ }
1874
+ }
1875
+ let url;
1876
+ try {
1877
+ url = new URL(urlValue);
1878
+ } catch {
1879
+ throw new Error(`Navigation target is not a valid absolute URL: ${urlValue}`);
1880
+ }
1881
+ if (!allowedDomains.includes(url.hostname)) {
1882
+ throw new Error(`Navigation to disallowed domain: ${url.hostname}`);
1883
+ }
1190
1884
  }
1191
- if (matched) {
1192
- return { selector };
1885
+ function ensureAppAllowed(app) {
1886
+ if (!allowedApps.includes(app)) {
1887
+ throw new Error(`Interaction with disallowed app: ${app}`);
1888
+ }
1193
1889
  }
1194
- const healed = await healSelector(context.page, selector, { enabled: true });
1195
- if (!healed) {
1196
- return { selector };
1890
+ function ensureLocationAllowed(activeDriver) {
1891
+ if (activeDriver.capabilities.has("navigate")) {
1892
+ ensureUrlAllowed(activeDriver.currentUrl());
1893
+ }
1197
1894
  }
1198
- assertAllowedSelector(healed.selector, context.forbiddenSelectors);
1199
- console.warn(
1200
- `Self-healed selector: "${selector}" \u2192 "${healed.selector}" (${healed.strategy}). Update your hunt to use a stable selector.`
1201
- );
1202
- return { selector: healed.selector, healedFrom: healed.healedFrom };
1203
- }
1204
- function assertWithinMaxSteps(stepCount, maxSteps, huntName) {
1205
- if (stepCount > maxSteps) {
1206
- if (huntName) {
1207
- throw new Error(`Hunt "${huntName}" has ${stepCount} steps. Max allowed is ${maxSteps}.`);
1895
+ const healProbe = {
1896
+ locator: (selector) => ({ count: () => driver.count(selector) })
1897
+ };
1898
+ async function resolveActionSelector(selector) {
1899
+ assertAllowedSelector(selector);
1900
+ if (!selfHealing) {
1901
+ return { selector };
1902
+ }
1903
+ let matched = false;
1904
+ try {
1905
+ matched = await driver.count(selector) > 0;
1906
+ } catch {
1907
+ return { selector };
1208
1908
  }
1209
- throw new Error(`Hunt has ${stepCount} steps. Max allowed is ${maxSteps}.`);
1909
+ if (matched) {
1910
+ return { selector };
1911
+ }
1912
+ const healed = await healSelector(healProbe, selector, { enabled: true });
1913
+ if (!healed) {
1914
+ return { selector };
1915
+ }
1916
+ assertAllowedSelector(healed.selector);
1917
+ console.warn(
1918
+ `Self-healed selector: "${selector}" \u2192 "${healed.selector}" (${healed.strategy}). Update your hunt to use a stable selector.`
1919
+ );
1920
+ return { selector: healed.selector, healedFrom: healed.healedFrom };
1210
1921
  }
1922
+ return {
1923
+ assertWithinMaxSteps,
1924
+ ensureUrlAllowed,
1925
+ ensureAppAllowed,
1926
+ ensureLocationAllowed,
1927
+ assertAllowedSelector,
1928
+ resolveActionSelector
1929
+ };
1211
1930
  }
1931
+
1932
+ // src/runner/steps.ts
1212
1933
  function getStepType(step) {
1213
1934
  if ("navigate" in step) return "navigate";
1214
1935
  if ("click" in step) return "click";
@@ -1312,17 +2033,6 @@ function applyRuntimeVars(step, vars) {
1312
2033
  function isExplicitFillStep(value) {
1313
2034
  return typeof value.selector === "string" && typeof value.value === "string";
1314
2035
  }
1315
- function ensureAllowedUrl(urlValue, allowedDomains) {
1316
- for (const protocol of ALWAYS_ALLOWED_PROTOCOLS) {
1317
- if (urlValue.startsWith(protocol)) {
1318
- return;
1319
- }
1320
- }
1321
- const url = new URL(urlValue);
1322
- if (!allowedDomains.includes(url.hostname)) {
1323
- throw new Error(`Navigation to disallowed domain: ${url.hostname}`);
1324
- }
1325
- }
1326
2036
  function resolveNavigationTarget(targetUrl, value) {
1327
2037
  try {
1328
2038
  return new URL(value, targetUrl).toString();
@@ -1349,56 +2059,50 @@ function getSinglePair(value, stepType) {
1349
2059
  }
1350
2060
  return entries[0];
1351
2061
  }
1352
- async function clickByTextWithFallback(page, text, forbiddenSelectors) {
2062
+ async function clickByTextWithFallback(driver, policy, text) {
1353
2063
  const roleSelector = `role=button[name="${escapeForAttribute(text)}"]`;
1354
- assertAllowedSelector(roleSelector, forbiddenSelectors);
1355
- const button = page.getByRole("button", { name: text });
1356
- if (await button.count()) {
1357
- await button.first().click();
2064
+ policy.assertAllowedSelector(roleSelector);
2065
+ if (await driver.countByRole("button", text)) {
2066
+ await driver.clickFirstByRole("button", text);
1358
2067
  return roleSelector;
1359
2068
  }
1360
2069
  const selector = exactTextSelector(text);
1361
- assertAllowedSelector(selector, forbiddenSelectors);
1362
- await page.locator(selector).first().click();
2070
+ policy.assertAllowedSelector(selector);
2071
+ await driver.clickFirst(selector);
1363
2072
  return selector;
1364
2073
  }
1365
- async function fillByLabelOrPlaceholder(page, label, value, forbiddenSelectors) {
2074
+ async function fillByLabelOrPlaceholder(driver, policy, label, value) {
1366
2075
  const labelSelector = `label="${escapeForAttribute(label)}"`;
1367
- assertAllowedSelector(labelSelector, forbiddenSelectors);
1368
- const byLabel = page.getByLabel(label, { exact: true });
1369
- if (await byLabel.count()) {
1370
- await byLabel.first().fill(value);
2076
+ policy.assertAllowedSelector(labelSelector);
2077
+ if (await driver.countByLabel(label)) {
2078
+ await driver.fillFirstByLabel(label, value);
1371
2079
  return labelSelector;
1372
2080
  }
1373
2081
  const placeholder = `input[placeholder="${escapeForAttribute(label)}"], textarea[placeholder="${escapeForAttribute(label)}"]`;
1374
- assertAllowedSelector(placeholder, forbiddenSelectors);
1375
- const byPlaceholder = page.locator(placeholder);
1376
- if (await byPlaceholder.count()) {
1377
- await byPlaceholder.first().fill(value);
2082
+ policy.assertAllowedSelector(placeholder);
2083
+ if (await driver.count(placeholder)) {
2084
+ await driver.fillFirst(placeholder, value);
1378
2085
  return placeholder;
1379
2086
  }
1380
2087
  throw new Error(`Could not resolve fill shorthand for "${label}"`);
1381
2088
  }
1382
- async function selectByLabelOrFallback(page, label, value, forbiddenSelectors) {
2089
+ async function selectByLabelOrFallback(driver, policy, label, value) {
1383
2090
  const labelSelector = `label="${escapeForAttribute(label)}"`;
1384
- assertAllowedSelector(labelSelector, forbiddenSelectors);
1385
- const byLabel = page.getByLabel(label, { exact: true });
1386
- if (await byLabel.count()) {
1387
- await byLabel.first().selectOption(value);
2091
+ policy.assertAllowedSelector(labelSelector);
2092
+ if (await driver.countByLabel(label)) {
2093
+ await driver.selectOptionFirstByLabel(label, value);
1388
2094
  return labelSelector;
1389
2095
  }
1390
2096
  const ariaSelector = `select[aria-label="${escapeForAttribute(label)}"]`;
1391
- assertAllowedSelector(ariaSelector, forbiddenSelectors);
1392
- const byAria = page.locator(ariaSelector);
1393
- if (await byAria.count()) {
1394
- await byAria.first().selectOption(value);
2097
+ policy.assertAllowedSelector(ariaSelector);
2098
+ if (await driver.count(ariaSelector)) {
2099
+ await driver.selectOptionFirst(ariaSelector, value);
1395
2100
  return ariaSelector;
1396
2101
  }
1397
2102
  const placeholderSelector = `select[placeholder="${escapeForAttribute(label)}"]`;
1398
- assertAllowedSelector(placeholderSelector, forbiddenSelectors);
1399
- const byPlaceholder = page.locator(placeholderSelector);
1400
- if (await byPlaceholder.count()) {
1401
- await byPlaceholder.first().selectOption(value);
2103
+ policy.assertAllowedSelector(placeholderSelector);
2104
+ if (await driver.count(placeholderSelector)) {
2105
+ await driver.selectOptionFirst(placeholderSelector, value);
1402
2106
  return placeholderSelector;
1403
2107
  }
1404
2108
  throw new Error(`Could not resolve select shorthand for "${label}"`);
@@ -1526,11 +2230,11 @@ function toVisibilitySelector(value) {
1526
2230
  if (looksLikeSelector(value)) return value;
1527
2231
  return textContainsSelector(value);
1528
2232
  }
1529
- async function runInlineAssert(page, assertion, forbiddenSelectors) {
2233
+ async function runInlineAssert(driver, policy, assertion) {
1530
2234
  if (assertion.visible !== void 0) {
1531
2235
  const selector = toVisibilitySelector(assertion.visible);
1532
- assertAllowedSelector(selector, forbiddenSelectors);
1533
- const count = await page.locator(selector).count();
2236
+ policy.assertAllowedSelector(selector);
2237
+ const count = await driver.count(selector);
1534
2238
  if (count === 0) {
1535
2239
  throw new Error(`Expected visible: ${assertion.visible}`);
1536
2240
  }
@@ -1538,22 +2242,22 @@ async function runInlineAssert(page, assertion, forbiddenSelectors) {
1538
2242
  }
1539
2243
  if (assertion.notVisible !== void 0) {
1540
2244
  const selector = toVisibilitySelector(assertion.notVisible);
1541
- assertAllowedSelector(selector, forbiddenSelectors);
1542
- const count = await page.locator(selector).count();
2245
+ policy.assertAllowedSelector(selector);
2246
+ const count = await driver.count(selector);
1543
2247
  if (count > 0) {
1544
2248
  throw new Error(`Expected not visible: ${assertion.notVisible}`);
1545
2249
  }
1546
2250
  return `notVisible:${assertion.notVisible}`;
1547
2251
  }
1548
2252
  if (assertion.urlIncludes !== void 0) {
1549
- const current = page.url();
2253
+ const current = driver.currentUrl();
1550
2254
  if (!current.includes(assertion.urlIncludes)) {
1551
2255
  throw new Error(`URL did not include ${assertion.urlIncludes}`);
1552
2256
  }
1553
2257
  return `urlIncludes:${assertion.urlIncludes}`;
1554
2258
  }
1555
2259
  if (assertion.urlEquals !== void 0) {
1556
- const current = page.url();
2260
+ const current = driver.currentUrl();
1557
2261
  if (current !== assertion.urlEquals) {
1558
2262
  throw new Error(`URL did not equal ${assertion.urlEquals}`);
1559
2263
  }
@@ -1562,7 +2266,7 @@ async function runInlineAssert(page, assertion, forbiddenSelectors) {
1562
2266
  throw new Error("assert step is missing an assertion type");
1563
2267
  }
1564
2268
  function screenshotPath(screenshotsDir, fileName) {
1565
- return import_node_path4.default.join(screenshotsDir, fileName);
2269
+ return import_node_path6.default.join(screenshotsDir, fileName);
1566
2270
  }
1567
2271
  function stepPath(prefix, index) {
1568
2272
  return prefix ? `${prefix}.${index}` : `${index}`;
@@ -1570,8 +2274,8 @@ function stepPath(prefix, index) {
1570
2274
  function isWaitForDownloadStep(step) {
1571
2275
  return step !== void 0 && "waitForDownload" in step;
1572
2276
  }
1573
- function armDownloadListener(page, timeout) {
1574
- const downloadPromise = page.waitForEvent("download", { timeout });
2277
+ function armDownloadListener(driver, timeout) {
2278
+ const downloadPromise = driver.waitForDownloadEvent({ timeout });
1575
2279
  void downloadPromise.catch(() => void 0);
1576
2280
  return downloadPromise;
1577
2281
  }
@@ -1579,14 +2283,14 @@ function validateDownloadFilename(suggestedFilename) {
1579
2283
  const safeFilename = suggestedFilename.trim();
1580
2284
  const allowedFilenamePattern = /^[^<>:"/\\|?*]+$/;
1581
2285
  const hasControlCharacter = Array.from(safeFilename).some((char) => char.charCodeAt(0) < 32);
1582
- if (safeFilename.length === 0 || safeFilename !== suggestedFilename || safeFilename !== import_node_path4.default.basename(safeFilename) || safeFilename.includes("..") || /[/\\]/.test(safeFilename) || hasControlCharacter || !allowedFilenamePattern.test(safeFilename)) {
2286
+ if (safeFilename.length === 0 || safeFilename !== suggestedFilename || safeFilename !== import_node_path6.default.basename(safeFilename) || safeFilename.includes("..") || /[/\\]/.test(safeFilename) || hasControlCharacter || !allowedFilenamePattern.test(safeFilename)) {
1583
2287
  throw new Error(`Invalid download filename: "${suggestedFilename}"`);
1584
2288
  }
1585
2289
  return safeFilename;
1586
2290
  }
1587
- async function captureScreenshot(page, filePath) {
2291
+ async function captureScreenshot(taker, filePath) {
1588
2292
  try {
1589
- await page.screenshot({ path: filePath, fullPage: true });
2293
+ await taker.screenshot({ path: filePath, fullPage: true });
1590
2294
  } catch (error) {
1591
2295
  const message = error instanceof Error ? error.message : "Screenshot failed";
1592
2296
  throw new Error(`Failed to capture screenshot at ${filePath}: ${message}`);
@@ -1605,608 +2309,784 @@ async function executeNestedSteps(context, overrides) {
1605
2309
  }
1606
2310
  return result;
1607
2311
  }
1608
- async function executeSteps(context) {
1609
- const screenshotsDir = import_node_path4.default.join(context.runDir, "screenshots");
1610
- import_node_fs4.default.mkdirSync(screenshotsDir, { recursive: true });
1611
- const currentHuntName = context.huntStack?.[context.huntStack.length - 1];
1612
- assertWithinMaxSteps(context.steps.length, context.maxSteps, currentHuntName);
1613
- const results = [];
1614
- const screenshots = [];
1615
- const runStartedAtMs = context.runStartedAtMs ?? Date.now();
1616
- context.runStartedAtMs = runStartedAtMs;
1617
- const addScreenshot = async (fileName) => {
1618
- const fullPath = screenshotPath(screenshotsDir, fileName);
1619
- await captureScreenshot(context.page, fullPath);
1620
- const relative = import_node_path4.default.join("screenshots", fileName);
1621
- screenshots.push(relative);
1622
- return relative;
1623
- };
1624
- for (let index = 0; index < context.steps.length; index += 1) {
1625
- const currentStepPath = stepPath(context.stepPathPrefix, index);
1626
- if (Date.now() - runStartedAtMs > context.maxTotalTimeMs) {
1627
- results.push({
1628
- type: "timeout",
1629
- status: "fail",
1630
- durationMs: 0,
1631
- error: `Max total time exceeded (${context.maxTotalTimeMs}ms)`
1632
- });
1633
- return { results, screenshots, failed: true, error: "Max total time exceeded" };
1634
- }
1635
- const runtimeVars = context.runtimeVars ?? /* @__PURE__ */ new Map();
1636
- context.runtimeVars = runtimeVars;
1637
- let step = context.steps[index];
1638
- if (runtimeVars.size > 0) {
1639
- step = applyRuntimeVars(step, runtimeVars);
2312
+ function unknownStep() {
2313
+ throw new Error("Unknown step type");
2314
+ }
2315
+ var STEP_HANDLERS = {
2316
+ navigate: {
2317
+ capabilities: ["navigate"],
2318
+ run: async (h) => {
2319
+ if (!("navigate" in h.step)) unknownStep();
2320
+ const destination = resolveNavigationTarget(h.context.targetUrl, h.step.navigate);
2321
+ h.policy.ensureUrlAllowed(destination);
2322
+ await h.driver.goto(destination);
2323
+ h.policy.ensureLocationAllowed(h.driver);
2324
+ return { kind: "result", result: { type: "navigate", status: "pass", durationMs: Date.now() - h.stepStart } };
1640
2325
  }
1641
- const nextStep = context.steps[index + 1];
1642
- if (!isWaitForDownloadStep(step) && context.pendingDownload === void 0 && isWaitForDownloadStep(nextStep)) {
1643
- context.pendingDownload = armDownloadListener(
1644
- context.page,
1645
- nextStep.waitForDownload?.timeout ?? 3e4
1646
- );
1647
- }
1648
- const stepStart = Date.now();
1649
- const stepType = getStepType(step);
1650
- let stepResult = null;
1651
- try {
1652
- if ("navigate" in step) {
1653
- const destination = resolveNavigationTarget(context.targetUrl, step.navigate);
1654
- ensureAllowedUrl(destination, context.allowedDomains);
1655
- await context.page.goto(destination);
1656
- ensureAllowedUrl(context.page.url(), context.allowedDomains);
1657
- stepResult = { type: "navigate", status: "pass", durationMs: Date.now() - stepStart };
1658
- } else if ("click" in step) {
1659
- let selector;
1660
- let healedFrom;
1661
- if (typeof step.click === "string") {
1662
- selector = await clickByTextWithFallback(
1663
- context.page,
1664
- step.click,
1665
- context.forbiddenSelectors
1666
- );
1667
- } else {
1668
- const resolved = await resolveActionSelector(context, step.click.selector);
1669
- await clickElement(context.page, resolved.selector);
1670
- selector = resolved.selector;
1671
- healedFrom = resolved.healedFrom;
1672
- }
1673
- ensureAllowedUrl(context.page.url(), context.allowedDomains);
1674
- stepResult = {
2326
+ },
2327
+ click: {
2328
+ capabilities: ["interact", "query"],
2329
+ run: async (h) => {
2330
+ if (!("click" in h.step)) unknownStep();
2331
+ let selector;
2332
+ let healedFrom;
2333
+ if (typeof h.step.click === "string") {
2334
+ selector = await clickByTextWithFallback(h.driver, h.policy, h.step.click);
2335
+ } else {
2336
+ const resolved = await h.policy.resolveActionSelector(h.step.click.selector);
2337
+ await h.driver.click(resolved.selector);
2338
+ selector = resolved.selector;
2339
+ healedFrom = resolved.healedFrom;
2340
+ }
2341
+ h.policy.ensureLocationAllowed(h.driver);
2342
+ return {
2343
+ kind: "result",
2344
+ result: {
1675
2345
  type: "click",
1676
2346
  status: "pass",
1677
- durationMs: Date.now() - stepStart,
2347
+ durationMs: Date.now() - h.stepStart,
1678
2348
  selector,
1679
2349
  ...healedFrom ? { healedFrom } : {}
1680
- };
1681
- } else if ("fill" in step) {
1682
- let selector;
1683
- let value;
1684
- let healedFrom;
1685
- if (isExplicitFillStep(step.fill)) {
1686
- const resolved = await resolveActionSelector(context, step.fill.selector);
1687
- await fillElement(context.page, resolved.selector, step.fill.value);
1688
- selector = resolved.selector;
1689
- healedFrom = resolved.healedFrom;
1690
- value = step.fill.value;
1691
- } else {
1692
- const [label, shorthandValue] = getSinglePair(step.fill, "fill");
1693
- selector = await fillByLabelOrPlaceholder(
1694
- context.page,
1695
- label,
1696
- shorthandValue,
1697
- context.forbiddenSelectors
1698
- );
1699
- value = shorthandValue;
1700
2350
  }
1701
- ensureAllowedUrl(context.page.url(), context.allowedDomains);
1702
- stepResult = {
2351
+ };
2352
+ }
2353
+ },
2354
+ fill: {
2355
+ capabilities: ["interact", "query"],
2356
+ run: async (h) => {
2357
+ if (!("fill" in h.step)) unknownStep();
2358
+ let selector;
2359
+ let value;
2360
+ let healedFrom;
2361
+ if (isExplicitFillStep(h.step.fill)) {
2362
+ const resolved = await h.policy.resolveActionSelector(h.step.fill.selector);
2363
+ await h.driver.fill(resolved.selector, h.step.fill.value);
2364
+ selector = resolved.selector;
2365
+ healedFrom = resolved.healedFrom;
2366
+ value = h.step.fill.value;
2367
+ } else {
2368
+ const [label, shorthandValue] = getSinglePair(h.step.fill, "fill");
2369
+ selector = await fillByLabelOrPlaceholder(h.driver, h.policy, label, shorthandValue);
2370
+ value = shorthandValue;
2371
+ }
2372
+ h.policy.ensureLocationAllowed(h.driver);
2373
+ return {
2374
+ kind: "result",
2375
+ result: {
1703
2376
  type: "fill",
1704
2377
  status: "pass",
1705
- durationMs: Date.now() - stepStart,
2378
+ durationMs: Date.now() - h.stepStart,
1706
2379
  selector,
1707
- value: context.redactedFillSteps.has(currentStepPath) ? "[REDACTED]" : value,
2380
+ value: h.context.redactedFillSteps.has(h.stepPath) ? "[REDACTED]" : value,
1708
2381
  ...healedFrom ? { healedFrom } : {}
1709
- };
1710
- } else if ("type" in step) {
1711
- assertAllowedSelector(":focus", context.forbiddenSelectors);
1712
- await fillElement(context.page, ":focus", step.type);
1713
- ensureAllowedUrl(context.page.url(), context.allowedDomains);
1714
- stepResult = {
2382
+ }
2383
+ };
2384
+ }
2385
+ },
2386
+ type: {
2387
+ capabilities: ["interact"],
2388
+ run: async (h) => {
2389
+ if (!("type" in h.step)) unknownStep();
2390
+ h.policy.assertAllowedSelector(":focus");
2391
+ await h.driver.fill(":focus", h.step.type);
2392
+ h.policy.ensureLocationAllowed(h.driver);
2393
+ return {
2394
+ kind: "result",
2395
+ result: {
1715
2396
  type: "type",
1716
2397
  status: "pass",
1717
- durationMs: Date.now() - stepStart,
2398
+ durationMs: Date.now() - h.stepStart,
1718
2399
  selector: ":focus",
1719
- value: context.redactedFillSteps.has(currentStepPath) ? "[REDACTED]" : step.type
1720
- };
1721
- } else if ("selectOption" in step) {
1722
- const resolved = await resolveActionSelector(context, step.selectOption.selector);
1723
- await selectOption(context.page, resolved.selector, step.selectOption.value);
1724
- ensureAllowedUrl(context.page.url(), context.allowedDomains);
1725
- stepResult = {
2400
+ value: h.context.redactedFillSteps.has(h.stepPath) ? "[REDACTED]" : h.step.type
2401
+ }
2402
+ };
2403
+ }
2404
+ },
2405
+ selectOption: {
2406
+ capabilities: ["navigate", "interact", "query"],
2407
+ run: async (h) => {
2408
+ if (!("selectOption" in h.step)) unknownStep();
2409
+ const resolved = await h.policy.resolveActionSelector(h.step.selectOption.selector);
2410
+ await h.driver.selectOption(resolved.selector, h.step.selectOption.value);
2411
+ h.policy.ensureLocationAllowed(h.driver);
2412
+ return {
2413
+ kind: "result",
2414
+ result: {
1726
2415
  type: "selectOption",
1727
2416
  status: "pass",
1728
- durationMs: Date.now() - stepStart,
2417
+ durationMs: Date.now() - h.stepStart,
1729
2418
  selector: resolved.selector,
1730
- value: step.selectOption.value,
2419
+ value: h.step.selectOption.value,
1731
2420
  ...resolved.healedFrom ? { healedFrom: resolved.healedFrom } : {}
1732
- };
1733
- } else if ("select" in step) {
1734
- const [label, value] = getSinglePair(step.select, "select");
1735
- const selector = await selectByLabelOrFallback(
1736
- context.page,
1737
- label,
1738
- value,
1739
- context.forbiddenSelectors
1740
- );
1741
- ensureAllowedUrl(context.page.url(), context.allowedDomains);
1742
- stepResult = {
2421
+ }
2422
+ };
2423
+ }
2424
+ },
2425
+ select: {
2426
+ capabilities: ["navigate", "interact", "query"],
2427
+ run: async (h) => {
2428
+ if (!("select" in h.step)) unknownStep();
2429
+ const [label, value] = getSinglePair(h.step.select, "select");
2430
+ const selector = await selectByLabelOrFallback(h.driver, h.policy, label, value);
2431
+ h.policy.ensureLocationAllowed(h.driver);
2432
+ return {
2433
+ kind: "result",
2434
+ result: {
1743
2435
  type: "select",
1744
2436
  status: "pass",
1745
- durationMs: Date.now() - stepStart,
2437
+ durationMs: Date.now() - h.stepStart,
1746
2438
  selector,
1747
2439
  value
1748
- };
1749
- } else if ("onDialog" in step) {
1750
- setupDialogHandler(context.page, step.onDialog.action);
1751
- stepResult = {
2440
+ }
2441
+ };
2442
+ }
2443
+ },
2444
+ onDialog: {
2445
+ capabilities: ["dialog"],
2446
+ run: async (h) => {
2447
+ if (!("onDialog" in h.step)) unknownStep();
2448
+ h.driver.onDialog(h.step.onDialog.action);
2449
+ return {
2450
+ kind: "result",
2451
+ result: {
1752
2452
  type: "onDialog",
1753
2453
  status: "pass",
1754
- durationMs: Date.now() - stepStart,
1755
- value: step.onDialog.action
1756
- };
1757
- } else if ("setInputFiles" in step) {
1758
- const resolvedInput = await resolveActionSelector(context, step.setInputFiles.selector);
1759
- const rawFiles = step.setInputFiles.files;
1760
- const resolveFile = (f) => import_node_path4.default.isAbsolute(f) ? f : import_node_path4.default.join(context.configDir, f);
1761
- const resolvedFiles = Array.isArray(rawFiles) ? rawFiles.map(resolveFile) : resolveFile(rawFiles);
1762
- await setInputFiles(context.page, resolvedInput.selector, resolvedFiles);
1763
- ensureAllowedUrl(context.page.url(), context.allowedDomains);
1764
- const filesLabel = Array.isArray(rawFiles) ? rawFiles.join(", ") : rawFiles;
1765
- stepResult = {
2454
+ durationMs: Date.now() - h.stepStart,
2455
+ value: h.step.onDialog.action
2456
+ }
2457
+ };
2458
+ }
2459
+ },
2460
+ setInputFiles: {
2461
+ capabilities: ["navigate", "interact", "query", "files"],
2462
+ run: async (h) => {
2463
+ if (!("setInputFiles" in h.step)) unknownStep();
2464
+ const resolvedInput = await h.policy.resolveActionSelector(h.step.setInputFiles.selector);
2465
+ const rawFiles = h.step.setInputFiles.files;
2466
+ const resolveFile = (f) => import_node_path6.default.isAbsolute(f) ? f : import_node_path6.default.join(h.context.configDir, f);
2467
+ const resolvedFiles = Array.isArray(rawFiles) ? rawFiles.map(resolveFile) : resolveFile(rawFiles);
2468
+ await h.driver.setInputFiles(resolvedInput.selector, resolvedFiles);
2469
+ h.policy.ensureLocationAllowed(h.driver);
2470
+ const filesLabel = Array.isArray(rawFiles) ? rawFiles.join(", ") : rawFiles;
2471
+ return {
2472
+ kind: "result",
2473
+ result: {
1766
2474
  type: "setInputFiles",
1767
2475
  status: "pass",
1768
- durationMs: Date.now() - stepStart,
2476
+ durationMs: Date.now() - h.stepStart,
1769
2477
  selector: resolvedInput.selector,
1770
2478
  ...resolvedInput.healedFrom ? { healedFrom: resolvedInput.healedFrom } : {},
1771
2479
  value: filesLabel
1772
- };
1773
- } else if ("runHunt" in step) {
1774
- const huntName = typeof step.runHunt === "string" ? step.runHunt : step.runHunt.name;
1775
- const overrideVars = typeof step.runHunt === "string" ? void 0 : step.runHunt.vars;
1776
- const stack = context.huntStack ?? [];
1777
- if (stack.includes(huntName)) {
1778
- throw new Error(`Circular hunt dependency: ${[...stack, huntName].join(" \u2192 ")}`);
1779
- }
1780
- const subHunt = loadHunt(huntName, context.configDir);
1781
- if (overrideVars) {
1782
- subHunt.vars = { ...subHunt.vars, ...overrideVars };
1783
- }
1784
- const {
1785
- hunt: interpolatedSubHunt,
1786
- redactedFillSteps: subRedacted,
1787
- randomVars
1788
- } = interpolateHunt(
1789
- subHunt,
1790
- process.env,
1791
- context.randomVars
1792
- );
1793
- assertWithinMaxSteps(interpolatedSubHunt.steps.length, context.maxSteps, huntName);
1794
- const subResult = await executeNestedSteps(context, {
1795
- steps: interpolatedSubHunt.steps,
1796
- redactedFillSteps: subRedacted,
1797
- randomVars,
1798
- stepPathPrefix: void 0,
1799
- huntStack: [...stack, huntName],
1800
- onStep: context.onStep
1801
- });
1802
- for (const sr of subResult.results) {
1803
- results.push({ ...sr, type: `${huntName} > ${sr.type}` });
1804
- }
1805
- screenshots.push(...subResult.screenshots);
1806
- if (subResult.failed) {
1807
- return {
1808
- results,
1809
- screenshots,
1810
- failed: true,
1811
- error: `Sub-hunt "${huntName}" failed: ${subResult.error}`
1812
- };
1813
2480
  }
1814
- stepResult = {
1815
- type: "runHunt",
1816
- status: "pass",
1817
- durationMs: Date.now() - stepStart,
1818
- value: huntName
1819
- };
1820
- } else if ("press" in step) {
1821
- const resolved = await resolveActionSelector(context, step.press.selector);
1822
- await pressKey(context.page, resolved.selector, step.press.key);
1823
- ensureAllowedUrl(context.page.url(), context.allowedDomains);
1824
- stepResult = {
2481
+ };
2482
+ }
2483
+ },
2484
+ runHunt: {
2485
+ capabilities: [],
2486
+ run: async (h) => {
2487
+ if (!("runHunt" in h.step)) unknownStep();
2488
+ const huntName = typeof h.step.runHunt === "string" ? h.step.runHunt : h.step.runHunt.name;
2489
+ const overrideVars = typeof h.step.runHunt === "string" ? void 0 : h.step.runHunt.vars;
2490
+ const stack = h.context.huntStack ?? [];
2491
+ if (stack.includes(huntName)) {
2492
+ throw new Error(`Circular hunt dependency: ${[...stack, huntName].join(" \u2192 ")}`);
2493
+ }
2494
+ const subHunt = loadHunt(huntName, h.context.configDir);
2495
+ if (overrideVars) {
2496
+ subHunt.vars = { ...subHunt.vars, ...overrideVars };
2497
+ }
2498
+ const {
2499
+ hunt: interpolatedSubHunt,
2500
+ redactedFillSteps: subRedacted,
2501
+ randomVars
2502
+ } = interpolateHunt(subHunt, process.env, h.context.randomVars);
2503
+ const subTargetType = h.driver.capabilities.has("navigate") ? "web" : "macos";
2504
+ assertStepsSupportedByTarget(interpolatedSubHunt.steps, subTargetType);
2505
+ assertHuntAssertionsSupportedByTarget(interpolatedSubHunt.assertions, subTargetType);
2506
+ h.policy.assertWithinMaxSteps(interpolatedSubHunt.steps.length, huntName);
2507
+ const subResult = await h.executeNested({
2508
+ steps: interpolatedSubHunt.steps,
2509
+ redactedFillSteps: subRedacted,
2510
+ randomVars,
2511
+ stepPathPrefix: void 0,
2512
+ huntStack: [...stack, huntName],
2513
+ onStep: h.context.onStep
2514
+ });
2515
+ for (const sr of subResult.results) {
2516
+ h.results.push({ ...sr, type: `${huntName} > ${sr.type}` });
2517
+ }
2518
+ h.screenshots.push(...subResult.screenshots);
2519
+ if (subResult.failed) {
2520
+ return { kind: "abort", error: `Sub-hunt "${huntName}" failed: ${subResult.error}` };
2521
+ }
2522
+ return {
2523
+ kind: "result",
2524
+ result: { type: "runHunt", status: "pass", durationMs: Date.now() - h.stepStart, value: huntName }
2525
+ };
2526
+ }
2527
+ },
2528
+ press: {
2529
+ capabilities: ["interact", "query"],
2530
+ run: async (h) => {
2531
+ if (!("press" in h.step)) unknownStep();
2532
+ const resolved = await h.policy.resolveActionSelector(h.step.press.selector);
2533
+ await h.driver.press(resolved.selector, h.step.press.key);
2534
+ h.policy.ensureLocationAllowed(h.driver);
2535
+ return {
2536
+ kind: "result",
2537
+ result: {
1825
2538
  type: "press",
1826
2539
  status: "pass",
1827
- durationMs: Date.now() - stepStart,
2540
+ durationMs: Date.now() - h.stepStart,
1828
2541
  selector: resolved.selector,
1829
2542
  ...resolved.healedFrom ? { healedFrom: resolved.healedFrom } : {}
1830
- };
1831
- } else if ("assert" in step) {
1832
- const value = await runInlineAssert(context.page, step.assert, context.forbiddenSelectors);
1833
- stepResult = {
1834
- type: "assert",
1835
- status: "pass",
1836
- durationMs: Date.now() - stepStart,
1837
- value
1838
- };
1839
- } else if ("wait" in step) {
1840
- const text = typeof step.wait === "string" ? step.wait : step.wait.for;
1841
- const timeout = typeof step.wait === "string" ? void 0 : step.wait.timeout;
1842
- const selector = `text=${escapeForText(text)}`;
1843
- assertAllowedSelector(selector, context.forbiddenSelectors);
1844
- await context.page.waitForSelector(selector, { timeout });
1845
- stepResult = {
1846
- type: "wait",
1847
- status: "pass",
1848
- durationMs: Date.now() - stepStart,
1849
- selector
1850
- };
1851
- } else if ("waitForSelector" in step) {
1852
- assertAllowedSelector(step.waitForSelector.selector, context.forbiddenSelectors);
1853
- await context.page.waitForSelector(step.waitForSelector.selector, {
1854
- timeout: step.waitForSelector.timeout
1855
- });
1856
- stepResult = {
2543
+ }
2544
+ };
2545
+ }
2546
+ },
2547
+ assert: {
2548
+ capabilities: ["query"],
2549
+ run: async (h) => {
2550
+ if (!("assert" in h.step)) unknownStep();
2551
+ const value = await runInlineAssert(h.driver, h.policy, h.step.assert);
2552
+ return {
2553
+ kind: "result",
2554
+ result: { type: "assert", status: "pass", durationMs: Date.now() - h.stepStart, value }
2555
+ };
2556
+ }
2557
+ },
2558
+ wait: {
2559
+ capabilities: ["wait"],
2560
+ run: async (h) => {
2561
+ if (!("wait" in h.step)) unknownStep();
2562
+ const text = typeof h.step.wait === "string" ? h.step.wait : h.step.wait.for;
2563
+ const timeout = typeof h.step.wait === "string" ? void 0 : h.step.wait.timeout;
2564
+ const selector = `text=${escapeForText(text)}`;
2565
+ h.policy.assertAllowedSelector(selector);
2566
+ await h.driver.waitForSelector(selector, { timeout });
2567
+ return {
2568
+ kind: "result",
2569
+ result: { type: "wait", status: "pass", durationMs: Date.now() - h.stepStart, selector }
2570
+ };
2571
+ }
2572
+ },
2573
+ waitForSelector: {
2574
+ capabilities: ["wait"],
2575
+ run: async (h) => {
2576
+ if (!("waitForSelector" in h.step)) unknownStep();
2577
+ h.policy.assertAllowedSelector(h.step.waitForSelector.selector);
2578
+ await h.driver.waitForSelector(h.step.waitForSelector.selector, {
2579
+ timeout: h.step.waitForSelector.timeout
2580
+ });
2581
+ return {
2582
+ kind: "result",
2583
+ result: {
1857
2584
  type: "waitForSelector",
1858
2585
  status: "pass",
1859
- durationMs: Date.now() - stepStart,
1860
- selector: step.waitForSelector.selector
1861
- };
1862
- } else if ("waitForUrl" in step) {
1863
- await context.page.waitForURL(
1864
- (url) => url.toString().includes(step.waitForUrl.value),
1865
- { timeout: step.waitForUrl.timeout }
1866
- );
1867
- ensureAllowedUrl(context.page.url(), context.allowedDomains);
1868
- stepResult = {
1869
- type: "waitForUrl",
1870
- status: "pass",
1871
- durationMs: Date.now() - stepStart,
1872
- value: step.waitForUrl.value
1873
- };
1874
- } else if ("waitForNetworkIdle" in step) {
1875
- await context.page.waitForLoadState("networkidle", {
1876
- timeout: step.waitForNetworkIdle.timeout
1877
- });
1878
- stepResult = {
1879
- type: "waitForNetworkIdle",
1880
- status: "pass",
1881
- durationMs: Date.now() - stepStart
1882
- };
1883
- } else if ("hover" in step) {
1884
- const resolved = await resolveActionSelector(context, step.hover.selector);
1885
- await context.page.locator(resolved.selector).hover();
1886
- ensureAllowedUrl(context.page.url(), context.allowedDomains);
1887
- stepResult = {
2586
+ durationMs: Date.now() - h.stepStart,
2587
+ selector: h.step.waitForSelector.selector
2588
+ }
2589
+ };
2590
+ }
2591
+ },
2592
+ waitForUrl: {
2593
+ capabilities: ["navigate", "wait"],
2594
+ run: async (h) => {
2595
+ if (!("waitForUrl" in h.step)) unknownStep();
2596
+ const value = h.step.waitForUrl.value;
2597
+ await h.driver.waitForUrl((url) => url.includes(value), { timeout: h.step.waitForUrl.timeout });
2598
+ h.policy.ensureLocationAllowed(h.driver);
2599
+ return {
2600
+ kind: "result",
2601
+ result: { type: "waitForUrl", status: "pass", durationMs: Date.now() - h.stepStart, value }
2602
+ };
2603
+ }
2604
+ },
2605
+ waitForNetworkIdle: {
2606
+ capabilities: ["wait"],
2607
+ run: async (h) => {
2608
+ if (!("waitForNetworkIdle" in h.step)) unknownStep();
2609
+ await h.driver.waitForNetworkIdle({ timeout: h.step.waitForNetworkIdle.timeout });
2610
+ return {
2611
+ kind: "result",
2612
+ result: { type: "waitForNetworkIdle", status: "pass", durationMs: Date.now() - h.stepStart }
2613
+ };
2614
+ }
2615
+ },
2616
+ hover: {
2617
+ capabilities: ["interact", "query"],
2618
+ run: async (h) => {
2619
+ if (!("hover" in h.step)) unknownStep();
2620
+ const resolved = await h.policy.resolveActionSelector(h.step.hover.selector);
2621
+ await h.driver.hover(resolved.selector);
2622
+ h.policy.ensureLocationAllowed(h.driver);
2623
+ return {
2624
+ kind: "result",
2625
+ result: {
1888
2626
  type: "hover",
1889
2627
  status: "pass",
1890
- durationMs: Date.now() - stepStart,
2628
+ durationMs: Date.now() - h.stepStart,
1891
2629
  selector: resolved.selector,
1892
2630
  ...resolved.healedFrom ? { healedFrom: resolved.healedFrom } : {}
1893
- };
1894
- } else if ("scroll" in step) {
1895
- const amount = step.scroll.amount ?? 500;
1896
- const scrollMap = {
1897
- up: [0, -amount],
1898
- down: [0, amount],
1899
- left: [-amount, 0],
1900
- right: [amount, 0]
1901
- };
1902
- const [x, y] = scrollMap[step.scroll.direction];
1903
- await context.page.evaluate(([sx, sy]) => window.scrollBy(sx, sy), [x, y]);
1904
- stepResult = {
2631
+ }
2632
+ };
2633
+ }
2634
+ },
2635
+ scroll: {
2636
+ capabilities: ["evaluate"],
2637
+ run: async (h) => {
2638
+ if (!("scroll" in h.step)) unknownStep();
2639
+ const amount = h.step.scroll.amount ?? 500;
2640
+ const scrollMap = {
2641
+ up: [0, -amount],
2642
+ down: [0, amount],
2643
+ left: [-amount, 0],
2644
+ right: [amount, 0]
2645
+ };
2646
+ const [x, y] = scrollMap[h.step.scroll.direction];
2647
+ await h.driver.evaluate(([sx, sy]) => window.scrollBy(sx, sy), [x, y]);
2648
+ return {
2649
+ kind: "result",
2650
+ result: {
1905
2651
  type: "scroll",
1906
2652
  status: "pass",
1907
- durationMs: Date.now() - stepStart,
1908
- value: `${step.scroll.direction} ${amount}px`
1909
- };
1910
- } else if ("scrollTo" in step) {
1911
- const resolved = await resolveActionSelector(context, step.scrollTo.selector);
1912
- await context.page.locator(resolved.selector).scrollIntoViewIfNeeded();
1913
- stepResult = {
2653
+ durationMs: Date.now() - h.stepStart,
2654
+ value: `${h.step.scroll.direction} ${amount}px`
2655
+ }
2656
+ };
2657
+ }
2658
+ },
2659
+ scrollTo: {
2660
+ capabilities: ["interact", "query"],
2661
+ run: async (h) => {
2662
+ if (!("scrollTo" in h.step)) unknownStep();
2663
+ const resolved = await h.policy.resolveActionSelector(h.step.scrollTo.selector);
2664
+ await h.driver.scrollIntoView(resolved.selector);
2665
+ return {
2666
+ kind: "result",
2667
+ result: {
1914
2668
  type: "scrollTo",
1915
2669
  status: "pass",
1916
- durationMs: Date.now() - stepStart,
2670
+ durationMs: Date.now() - h.stepStart,
1917
2671
  selector: resolved.selector,
1918
2672
  ...resolved.healedFrom ? { healedFrom: resolved.healedFrom } : {}
2673
+ }
2674
+ };
2675
+ }
2676
+ },
2677
+ screenshot: {
2678
+ capabilities: ["screenshot"],
2679
+ run: async (h) => {
2680
+ if (!("screenshot" in h.step)) unknownStep();
2681
+ const name = h.step.screenshot.name ?? `manual_step_${h.index + 1}.png`;
2682
+ if (/[/\\]|\.\./.test(name)) {
2683
+ throw new Error(`Invalid screenshot name: "${name}" must not contain path separators or ".."`);
2684
+ }
2685
+ const fileName = name.endsWith(".png") ? name : `${name}.png`;
2686
+ const relative = await h.addScreenshot(fileName);
2687
+ return {
2688
+ kind: "result",
2689
+ result: { type: "screenshot", status: "pass", durationMs: Date.now() - h.stepStart, screenshot: relative }
2690
+ };
2691
+ }
2692
+ },
2693
+ if: {
2694
+ capabilities: ["query"],
2695
+ run: async (h) => {
2696
+ if (!("if" in h.step)) unknownStep();
2697
+ const condition = h.step.if;
2698
+ const selector = condition.visible ?? condition.notVisible;
2699
+ h.policy.assertAllowedSelector(selector);
2700
+ const count = await h.driver.count(selector);
2701
+ const conditionMet = condition.visible !== void 0 ? count > 0 : count === 0;
2702
+ if (conditionMet) {
2703
+ const subResult = await h.executeNested({
2704
+ steps: condition.then,
2705
+ stepPathPrefix: `${h.stepPath}.if.then`
2706
+ });
2707
+ for (const sr of subResult.results) {
2708
+ h.results.push({ ...sr, type: `if > ${sr.type}` });
2709
+ }
2710
+ h.screenshots.push(...subResult.screenshots);
2711
+ if (subResult.failed) {
2712
+ return { kind: "abort", error: subResult.error };
2713
+ }
2714
+ return {
2715
+ kind: "result",
2716
+ result: {
2717
+ type: "if",
2718
+ status: "pass",
2719
+ durationMs: Date.now() - h.stepStart,
2720
+ value: `condition met, executed ${condition.then.length} steps`
2721
+ }
1919
2722
  };
1920
- } else if ("screenshot" in step) {
1921
- const name = step.screenshot.name ?? `manual_step_${index + 1}.png`;
1922
- if (/[/\\]|\.\./.test(name)) {
1923
- throw new Error(`Invalid screenshot name: "${name}" must not contain path separators or ".."`);
2723
+ }
2724
+ if (condition.else && condition.else.length > 0) {
2725
+ const subResult = await h.executeNested({
2726
+ steps: condition.else,
2727
+ stepPathPrefix: `${h.stepPath}.if.else`
2728
+ });
2729
+ for (const sr of subResult.results) {
2730
+ h.results.push({ ...sr, type: `if > ${sr.type}` });
1924
2731
  }
1925
- const fileName = name.endsWith(".png") ? name : `${name}.png`;
1926
- const relative = await addScreenshot(fileName);
1927
- stepResult = {
1928
- type: "screenshot",
2732
+ h.screenshots.push(...subResult.screenshots);
2733
+ if (subResult.failed) {
2734
+ return { kind: "abort", error: subResult.error };
2735
+ }
2736
+ return {
2737
+ kind: "result",
2738
+ result: {
2739
+ type: "if",
2740
+ status: "pass",
2741
+ durationMs: Date.now() - h.stepStart,
2742
+ value: `condition not met, executed ${condition.else.length} else steps`
2743
+ }
2744
+ };
2745
+ }
2746
+ return {
2747
+ kind: "result",
2748
+ result: {
2749
+ type: "if",
1929
2750
  status: "pass",
1930
- durationMs: Date.now() - stepStart,
1931
- screenshot: relative
1932
- };
1933
- } else if ("if" in step) {
1934
- const condition = step.if;
1935
- const selector = condition.visible ?? condition.notVisible;
1936
- assertAllowedSelector(selector, context.forbiddenSelectors);
1937
- const count = await context.page.locator(selector).count();
1938
- const conditionMet = condition.visible !== void 0 ? count > 0 : count === 0;
1939
- if (conditionMet) {
1940
- const subResult = await executeNestedSteps(context, {
1941
- steps: condition.then,
1942
- stepPathPrefix: `${currentStepPath}.if.then`
2751
+ durationMs: Date.now() - h.stepStart,
2752
+ value: "condition not met, skipped"
2753
+ }
2754
+ };
2755
+ }
2756
+ },
2757
+ repeat: {
2758
+ capabilities: ["query"],
2759
+ run: async (h) => {
2760
+ if (!("repeat" in h.step)) unknownStep();
2761
+ const repeat = h.step.repeat;
2762
+ let totalSubSteps = 0;
2763
+ if (repeat.times !== void 0) {
2764
+ const totalPlanned = repeat.times * repeat.steps.length;
2765
+ if (totalPlanned + totalSubSteps > h.context.maxSteps) {
2766
+ throw new Error(`Repeat exceeded maxSteps guardrail (${h.context.maxSteps})`);
2767
+ }
2768
+ for (let i = 0; i < repeat.times; i++) {
2769
+ totalSubSteps += repeat.steps.length;
2770
+ const subResult = await h.executeNested({
2771
+ steps: repeat.steps,
2772
+ stepPathPrefix: `${h.stepPath}.repeat.steps`
1943
2773
  });
1944
2774
  for (const sr of subResult.results) {
1945
- results.push({ ...sr, type: `if > ${sr.type}` });
2775
+ h.results.push({ ...sr, type: `repeat[${i}] > ${sr.type}` });
1946
2776
  }
1947
- screenshots.push(...subResult.screenshots);
2777
+ h.screenshots.push(...subResult.screenshots);
1948
2778
  if (subResult.failed) {
1949
- return {
1950
- results,
1951
- screenshots,
1952
- failed: true,
1953
- error: subResult.error
1954
- };
1955
- }
1956
- stepResult = {
1957
- type: "if",
1958
- status: "pass",
1959
- durationMs: Date.now() - stepStart,
1960
- value: `condition met, executed ${condition.then.length} steps`
1961
- };
1962
- } else {
1963
- if (condition.else && condition.else.length > 0) {
1964
- const subResult = await executeNestedSteps(context, {
1965
- steps: condition.else,
1966
- stepPathPrefix: `${currentStepPath}.if.else`
1967
- });
1968
- for (const sr of subResult.results) {
1969
- results.push({ ...sr, type: `if > ${sr.type}` });
1970
- }
1971
- screenshots.push(...subResult.screenshots);
1972
- if (subResult.failed) {
1973
- return {
1974
- results,
1975
- screenshots,
1976
- failed: true,
1977
- error: subResult.error
1978
- };
1979
- }
1980
- stepResult = {
1981
- type: "if",
1982
- status: "pass",
1983
- durationMs: Date.now() - stepStart,
1984
- value: `condition not met, executed ${condition.else.length} else steps`
1985
- };
1986
- } else {
1987
- stepResult = {
1988
- type: "if",
1989
- status: "pass",
1990
- durationMs: Date.now() - stepStart,
1991
- value: "condition not met, skipped"
1992
- };
2779
+ return { kind: "abort", error: subResult.error };
1993
2780
  }
1994
2781
  }
1995
- } else if ("repeat" in step) {
1996
- const repeat = step.repeat;
1997
- let totalSubSteps = 0;
1998
- if (repeat.times !== void 0) {
1999
- const totalPlanned = repeat.times * repeat.steps.length;
2000
- if (totalPlanned + totalSubSteps > context.maxSteps) {
2001
- throw new Error(`Repeat exceeded maxSteps guardrail (${context.maxSteps})`);
2002
- }
2003
- for (let i = 0; i < repeat.times; i++) {
2004
- totalSubSteps += repeat.steps.length;
2005
- const subResult = await executeNestedSteps(context, {
2006
- steps: repeat.steps,
2007
- stepPathPrefix: `${currentStepPath}.repeat.steps`
2008
- });
2009
- for (const sr of subResult.results) {
2010
- results.push({ ...sr, type: `repeat[${i}] > ${sr.type}` });
2011
- }
2012
- screenshots.push(...subResult.screenshots);
2013
- if (subResult.failed) {
2014
- return {
2015
- results,
2016
- screenshots,
2017
- failed: true,
2018
- error: subResult.error
2019
- };
2020
- }
2782
+ } else if (repeat.while !== void 0) {
2783
+ const maxIter = repeat.maxIterations;
2784
+ const whileSelector = repeat.while.visible ?? repeat.while.notVisible;
2785
+ h.policy.assertAllowedSelector(whileSelector);
2786
+ for (let i = 0; i < maxIter; i++) {
2787
+ const whileCount = await h.driver.count(whileSelector);
2788
+ const shouldContinue = repeat.while.visible !== void 0 ? whileCount > 0 : whileCount === 0;
2789
+ if (!shouldContinue) break;
2790
+ totalSubSteps += repeat.steps.length;
2791
+ if (totalSubSteps > h.context.maxSteps) {
2792
+ throw new Error(`Repeat exceeded maxSteps guardrail (${h.context.maxSteps})`);
2021
2793
  }
2022
- } else if (repeat.while !== void 0) {
2023
- const maxIter = repeat.maxIterations;
2024
- const whileSelector = repeat.while.visible ?? repeat.while.notVisible;
2025
- assertAllowedSelector(whileSelector, context.forbiddenSelectors);
2026
- for (let i = 0; i < maxIter; i++) {
2027
- const whileCount = await context.page.locator(whileSelector).count();
2028
- const shouldContinue = repeat.while.visible !== void 0 ? whileCount > 0 : whileCount === 0;
2029
- if (!shouldContinue) break;
2030
- totalSubSteps += repeat.steps.length;
2031
- if (totalSubSteps > context.maxSteps) {
2032
- throw new Error(`Repeat exceeded maxSteps guardrail (${context.maxSteps})`);
2033
- }
2034
- const subResult = await executeNestedSteps(context, {
2035
- steps: repeat.steps,
2036
- stepPathPrefix: `${currentStepPath}.repeat.steps`
2037
- });
2038
- for (const sr of subResult.results) {
2039
- results.push({ ...sr, type: `repeat[${i}] > ${sr.type}` });
2040
- }
2041
- screenshots.push(...subResult.screenshots);
2042
- if (subResult.failed) {
2043
- return {
2044
- results,
2045
- screenshots,
2046
- failed: true,
2047
- error: subResult.error
2048
- };
2049
- }
2050
- }
2051
- }
2052
- stepResult = {
2053
- type: "repeat",
2054
- status: "pass",
2055
- durationMs: Date.now() - stepStart
2056
- };
2057
- } else if ("mockRoute" in step) {
2058
- const mock = step.mockRoute;
2059
- const mocks = context.activeMocks ?? /* @__PURE__ */ new Map();
2060
- context.activeMocks = mocks;
2061
- let responseBody;
2062
- if (mock.response.body !== void 0) {
2063
- responseBody = mock.response.body;
2064
- } else {
2065
- const responseFile = mock.response.file;
2066
- if (!responseFile) {
2067
- throw new Error("mock.response must include either body or file");
2794
+ const subResult = await h.executeNested({
2795
+ steps: repeat.steps,
2796
+ stepPathPrefix: `${h.stepPath}.repeat.steps`
2797
+ });
2798
+ for (const sr of subResult.results) {
2799
+ h.results.push({ ...sr, type: `repeat[${i}] > ${sr.type}` });
2068
2800
  }
2069
- const candidateFilePath = import_node_path4.default.isAbsolute(responseFile) ? responseFile : import_node_path4.default.join(context.configDir, responseFile);
2070
- const resolvedConfigDir = import_node_path4.default.resolve(context.configDir);
2071
- const resolvedFilePath = import_node_path4.default.resolve(candidateFilePath);
2072
- const relativePath = import_node_path4.default.relative(resolvedConfigDir, resolvedFilePath);
2073
- const isWithinConfigDir = relativePath === "" || relativePath !== ".." && !relativePath.startsWith(`..${import_node_path4.default.sep}`) && !import_node_path4.default.isAbsolute(relativePath);
2074
- if (!isWithinConfigDir) {
2075
- throw new Error("mock.response.file must resolve within config directory");
2801
+ h.screenshots.push(...subResult.screenshots);
2802
+ if (subResult.failed) {
2803
+ return { kind: "abort", error: subResult.error };
2076
2804
  }
2077
- responseBody = await import_node_fs4.default.promises.readFile(resolvedFilePath, "utf-8");
2078
2805
  }
2079
- const contentType = mock.response.contentType ?? "application/json";
2080
- const status = mock.response.status;
2081
- await context.page.route(mock.url, (route) => {
2082
- route.fulfill({
2083
- status,
2084
- contentType,
2085
- body: responseBody
2086
- });
2087
- });
2088
- mocks.set(mock.url, async () => {
2089
- await context.page.unroute(mock.url);
2090
- });
2091
- stepResult = {
2092
- type: "mockRoute",
2093
- status: "pass",
2094
- durationMs: Date.now() - stepStart,
2095
- value: mock.url
2096
- };
2097
- } else if ("unmockRoute" in step) {
2098
- const url = typeof step.unmockRoute === "string" ? step.unmockRoute : step.unmockRoute.url;
2099
- const mocks = context.activeMocks;
2100
- if (!mocks || !mocks.has(url)) {
2101
- throw new Error(`No active mock for URL: ${url}`);
2806
+ }
2807
+ return {
2808
+ kind: "result",
2809
+ result: { type: "repeat", status: "pass", durationMs: Date.now() - h.stepStart }
2810
+ };
2811
+ }
2812
+ },
2813
+ mockRoute: {
2814
+ capabilities: ["route"],
2815
+ run: async (h) => {
2816
+ if (!("mockRoute" in h.step)) unknownStep();
2817
+ const mock = h.step.mockRoute;
2818
+ const mocks = h.context.activeMocks ?? /* @__PURE__ */ new Map();
2819
+ h.context.activeMocks = mocks;
2820
+ let responseBody;
2821
+ if (mock.response.body !== void 0) {
2822
+ responseBody = mock.response.body;
2823
+ } else {
2824
+ const responseFile = mock.response.file;
2825
+ if (!responseFile) {
2826
+ throw new Error("mock.response must include either body or file");
2102
2827
  }
2103
- const cleanup = mocks.get(url);
2104
- await cleanup();
2105
- mocks.delete(url);
2106
- stepResult = {
2107
- type: "unmockRoute",
2108
- status: "pass",
2109
- durationMs: Date.now() - stepStart,
2110
- value: url
2111
- };
2112
- } else if ("evalScript" in step) {
2113
- const expression = typeof step.evalScript === "string" ? step.evalScript : step.evalScript.expression;
2114
- const result = await context.page.evaluate(expression);
2115
- const resultStr = String(result);
2116
- if (typeof step.evalScript !== "string" && step.evalScript.as) {
2117
- runtimeVars.set(step.evalScript.as, resultStr);
2828
+ const candidateFilePath = import_node_path6.default.isAbsolute(responseFile) ? responseFile : import_node_path6.default.join(h.context.configDir, responseFile);
2829
+ const resolvedConfigDir = import_node_path6.default.resolve(h.context.configDir);
2830
+ const resolvedFilePath = import_node_path6.default.resolve(candidateFilePath);
2831
+ const relativePath = import_node_path6.default.relative(resolvedConfigDir, resolvedFilePath);
2832
+ const isWithinConfigDir = relativePath === "" || relativePath !== ".." && !relativePath.startsWith(`..${import_node_path6.default.sep}`) && !import_node_path6.default.isAbsolute(relativePath);
2833
+ if (!isWithinConfigDir) {
2834
+ throw new Error("mock.response.file must resolve within config directory");
2118
2835
  }
2119
- stepResult = {
2836
+ responseBody = await import_node_fs6.default.promises.readFile(resolvedFilePath, "utf-8");
2837
+ }
2838
+ const contentType = mock.response.contentType ?? "application/json";
2839
+ const status = mock.response.status;
2840
+ await h.driver.route(mock.url, async (route) => {
2841
+ await route.fulfill({
2842
+ status,
2843
+ contentType,
2844
+ body: responseBody
2845
+ });
2846
+ });
2847
+ mocks.set(mock.url, async () => {
2848
+ await h.driver.unroute(mock.url);
2849
+ });
2850
+ return {
2851
+ kind: "result",
2852
+ result: { type: "mockRoute", status: "pass", durationMs: Date.now() - h.stepStart, value: mock.url }
2853
+ };
2854
+ }
2855
+ },
2856
+ unmockRoute: {
2857
+ capabilities: ["route"],
2858
+ run: async (h) => {
2859
+ if (!("unmockRoute" in h.step)) unknownStep();
2860
+ const url = typeof h.step.unmockRoute === "string" ? h.step.unmockRoute : h.step.unmockRoute.url;
2861
+ const mocks = h.context.activeMocks;
2862
+ if (!mocks || !mocks.has(url)) {
2863
+ throw new Error(`No active mock for URL: ${url}`);
2864
+ }
2865
+ const cleanup = mocks.get(url);
2866
+ await cleanup();
2867
+ mocks.delete(url);
2868
+ return {
2869
+ kind: "result",
2870
+ result: { type: "unmockRoute", status: "pass", durationMs: Date.now() - h.stepStart, value: url }
2871
+ };
2872
+ }
2873
+ },
2874
+ evalScript: {
2875
+ capabilities: ["evaluate"],
2876
+ run: async (h) => {
2877
+ if (!("evalScript" in h.step)) unknownStep();
2878
+ const expression = typeof h.step.evalScript === "string" ? h.step.evalScript : h.step.evalScript.expression;
2879
+ const result = await h.driver.evaluate(expression);
2880
+ const resultStr = String(result);
2881
+ if (typeof h.step.evalScript !== "string" && h.step.evalScript.as) {
2882
+ h.runtimeVars.set(h.step.evalScript.as, resultStr);
2883
+ }
2884
+ return {
2885
+ kind: "result",
2886
+ result: {
2120
2887
  type: "evalScript",
2121
2888
  status: "pass",
2122
- durationMs: Date.now() - stepStart,
2889
+ durationMs: Date.now() - h.stepStart,
2123
2890
  value: resultStr.length > 200 ? resultStr.slice(0, 200) + "\u2026" : resultStr
2124
- };
2125
- } else if ("runScript" in step) {
2126
- const filePath = import_node_path4.default.isAbsolute(step.runScript.file) ? step.runScript.file : import_node_path4.default.join(context.configDir, step.runScript.file);
2127
- const fileContents = import_node_fs4.default.readFileSync(filePath, "utf-8");
2128
- await context.page.evaluate(fileContents);
2129
- stepResult = {
2891
+ }
2892
+ };
2893
+ }
2894
+ },
2895
+ runScript: {
2896
+ capabilities: ["evaluate"],
2897
+ run: async (h) => {
2898
+ if (!("runScript" in h.step)) unknownStep();
2899
+ const filePath = import_node_path6.default.isAbsolute(h.step.runScript.file) ? h.step.runScript.file : import_node_path6.default.join(h.context.configDir, h.step.runScript.file);
2900
+ const fileContents = import_node_fs6.default.readFileSync(filePath, "utf-8");
2901
+ await h.driver.evaluate(fileContents);
2902
+ return {
2903
+ kind: "result",
2904
+ result: {
2130
2905
  type: "runScript",
2131
2906
  status: "pass",
2132
- durationMs: Date.now() - stepStart,
2133
- value: step.runScript.file
2907
+ durationMs: Date.now() - h.stepStart,
2908
+ value: h.step.runScript.file
2909
+ }
2910
+ };
2911
+ }
2912
+ },
2913
+ assertScreenshot: {
2914
+ capabilities: ["screenshot"],
2915
+ run: async (h) => {
2916
+ if (!("assertScreenshot" in h.step)) unknownStep();
2917
+ const { compareScreenshots: compareScreenshots2, ensureBaselineDir: ensureBaselineDir2 } = await Promise.resolve().then(() => (init_visual(), visual_exports));
2918
+ const name = h.step.assertScreenshot.name;
2919
+ const threshold = h.step.assertScreenshot.threshold ?? 0.1;
2920
+ const baselineDir = ensureBaselineDir2(h.context.configDir);
2921
+ const baselinePath = import_node_path6.default.join(baselineDir, `${name}.png`);
2922
+ const currentScreenshotPath = import_node_path6.default.join(h.context.runDir, "screenshots", `${name}-current.png`);
2923
+ import_node_fs6.default.mkdirSync(import_node_path6.default.dirname(currentScreenshotPath), { recursive: true });
2924
+ await h.driver.screenshot({ path: currentScreenshotPath, fullPage: true });
2925
+ h.screenshots.push(import_node_path6.default.join("screenshots", `${name}-current.png`));
2926
+ if (!import_node_fs6.default.existsSync(baselinePath)) {
2927
+ import_node_fs6.default.copyFileSync(currentScreenshotPath, baselinePath);
2928
+ return {
2929
+ kind: "result",
2930
+ result: { type: "assertScreenshot", status: "pass", durationMs: Date.now() - h.stepStart, value: "baseline created" }
2134
2931
  };
2135
- } else if ("assertScreenshot" in step) {
2136
- const { compareScreenshots: compareScreenshots2, ensureBaselineDir: ensureBaselineDir2 } = await Promise.resolve().then(() => (init_visual(), visual_exports));
2137
- const name = step.assertScreenshot.name;
2138
- const threshold = step.assertScreenshot.threshold ?? 0.1;
2139
- const baselineDir = ensureBaselineDir2(context.configDir);
2140
- const baselinePath = import_node_path4.default.join(baselineDir, `${name}.png`);
2141
- const currentScreenshotPath = import_node_path4.default.join(context.runDir, "screenshots", `${name}-current.png`);
2142
- import_node_fs4.default.mkdirSync(import_node_path4.default.dirname(currentScreenshotPath), { recursive: true });
2143
- await context.page.screenshot({ path: currentScreenshotPath, fullPage: true });
2144
- screenshots.push(import_node_path4.default.join("screenshots", `${name}-current.png`));
2145
- if (!import_node_fs4.default.existsSync(baselinePath)) {
2146
- import_node_fs4.default.copyFileSync(currentScreenshotPath, baselinePath);
2147
- stepResult = {
2932
+ }
2933
+ const diffPath = import_node_path6.default.join(h.context.runDir, "screenshots", `${name}-diff.png`);
2934
+ const comparison = await compareScreenshots2(baselinePath, currentScreenshotPath, diffPath, threshold);
2935
+ if (comparison.match) {
2936
+ return {
2937
+ kind: "result",
2938
+ result: {
2148
2939
  type: "assertScreenshot",
2149
2940
  status: "pass",
2150
- durationMs: Date.now() - stepStart,
2151
- value: "baseline created"
2152
- };
2153
- } else {
2154
- const diffPath = import_node_path4.default.join(context.runDir, "screenshots", `${name}-diff.png`);
2155
- const comparison = await compareScreenshots2(baselinePath, currentScreenshotPath, diffPath, threshold);
2156
- if (comparison.match) {
2157
- stepResult = {
2158
- type: "assertScreenshot",
2159
- status: "pass",
2160
- durationMs: Date.now() - stepStart,
2161
- value: `diff: ${(comparison.diffPercentage * 100).toFixed(2)}%`
2162
- };
2163
- } else {
2164
- screenshots.push(import_node_path4.default.join("screenshots", `${name}-diff.png`));
2165
- throw new Error(
2166
- `Visual regression: ${(comparison.diffPercentage * 100).toFixed(2)}% diff exceeds threshold ${(threshold * 100).toFixed(0)}%`
2167
- );
2941
+ durationMs: Date.now() - h.stepStart,
2942
+ value: `diff: ${(comparison.diffPercentage * 100).toFixed(2)}%`
2168
2943
  }
2169
- }
2170
- } else if ("copyText" in step) {
2171
- assertAllowedSelector(step.copyText.selector, context.forbiddenSelectors);
2172
- const text = await context.page.locator(step.copyText.selector).textContent();
2173
- if (text === null) {
2174
- throw new Error(`No text content found for selector: ${step.copyText.selector}`);
2175
- }
2176
- runtimeVars.set(step.copyText.as, text);
2177
- stepResult = {
2944
+ };
2945
+ }
2946
+ h.screenshots.push(import_node_path6.default.join("screenshots", `${name}-diff.png`));
2947
+ throw new Error(
2948
+ `Visual regression: ${(comparison.diffPercentage * 100).toFixed(2)}% diff exceeds threshold ${(threshold * 100).toFixed(0)}%`
2949
+ );
2950
+ }
2951
+ },
2952
+ copyText: {
2953
+ capabilities: ["query"],
2954
+ run: async (h) => {
2955
+ if (!("copyText" in h.step)) unknownStep();
2956
+ h.policy.assertAllowedSelector(h.step.copyText.selector);
2957
+ const text = await h.driver.textContent(h.step.copyText.selector);
2958
+ if (text === null) {
2959
+ throw new Error(`No text content found for selector: ${h.step.copyText.selector}`);
2960
+ }
2961
+ h.runtimeVars.set(h.step.copyText.as, text);
2962
+ return {
2963
+ kind: "result",
2964
+ result: {
2178
2965
  type: "copyText",
2179
2966
  status: "pass",
2180
- durationMs: Date.now() - stepStart,
2181
- selector: step.copyText.selector,
2967
+ durationMs: Date.now() - h.stepStart,
2968
+ selector: h.step.copyText.selector,
2182
2969
  value: "[REDACTED]"
2183
- };
2184
- } else if ("waitForDownload" in step) {
2185
- const opts = step.waitForDownload;
2186
- const downloadPromise = context.pendingDownload ?? armDownloadListener(
2187
- context.page,
2188
- opts?.timeout ?? 3e4
2189
- );
2190
- context.pendingDownload = void 0;
2191
- const download = await downloadPromise;
2192
- const suggestedFilename = validateDownloadFilename(download.suggestedFilename());
2193
- if (opts?.filename !== void 0 && suggestedFilename !== opts.filename) {
2194
- throw new Error(
2195
- `Download filename mismatch: expected "${opts.filename}", got "${suggestedFilename}"`
2196
- );
2197
2970
  }
2198
- const savePath = import_node_path4.default.join(context.runDir, suggestedFilename);
2199
- await download.saveAs(savePath);
2200
- stepResult = {
2971
+ };
2972
+ }
2973
+ },
2974
+ waitForDownload: {
2975
+ capabilities: ["download"],
2976
+ run: async (h) => {
2977
+ if (!("waitForDownload" in h.step)) unknownStep();
2978
+ const opts = h.step.waitForDownload;
2979
+ const downloadPromise = h.context.pendingDownload ?? armDownloadListener(h.driver, opts?.timeout ?? 3e4);
2980
+ h.context.pendingDownload = void 0;
2981
+ const download = await downloadPromise;
2982
+ const suggestedFilename = validateDownloadFilename(download.suggestedFilename());
2983
+ if (opts?.filename !== void 0 && suggestedFilename !== opts.filename) {
2984
+ throw new Error(
2985
+ `Download filename mismatch: expected "${opts.filename}", got "${suggestedFilename}"`
2986
+ );
2987
+ }
2988
+ const savePath = import_node_path6.default.join(h.context.runDir, suggestedFilename);
2989
+ await download.saveAs(savePath);
2990
+ return {
2991
+ kind: "result",
2992
+ result: {
2201
2993
  type: "waitForDownload",
2202
2994
  status: "pass",
2203
- durationMs: Date.now() - stepStart,
2995
+ durationMs: Date.now() - h.stepStart,
2204
2996
  value: suggestedFilename
2205
- };
2206
- }
2207
- if (!stepResult) {
2997
+ }
2998
+ };
2999
+ }
3000
+ }
3001
+ };
3002
+ async function executeSteps(context) {
3003
+ let driver = context.driver;
3004
+ if (!driver) {
3005
+ if (!context.page) {
3006
+ throw new Error("executeSteps requires a driver or a Playwright page");
3007
+ }
3008
+ driver = createPlaywrightDriver(context.page);
3009
+ }
3010
+ const policy = createRunPolicy(driver, {
3011
+ forbiddenSelectors: context.forbiddenSelectors,
3012
+ allowedDomains: context.allowedDomains,
3013
+ allowedApps: context.allowedApps,
3014
+ maxSteps: context.maxSteps,
3015
+ selfHealing: context.selfHealing
3016
+ });
3017
+ const screenshotsDir = import_node_path6.default.join(context.runDir, "screenshots");
3018
+ import_node_fs6.default.mkdirSync(screenshotsDir, { recursive: true });
3019
+ const currentHuntName = context.huntStack?.[context.huntStack.length - 1];
3020
+ policy.assertWithinMaxSteps(context.steps.length, currentHuntName);
3021
+ const results = [];
3022
+ const screenshots = [];
3023
+ const runStartedAtMs = context.runStartedAtMs ?? Date.now();
3024
+ context.runStartedAtMs = runStartedAtMs;
3025
+ const addScreenshot = async (fileName) => {
3026
+ const fullPath = screenshotPath(screenshotsDir, fileName);
3027
+ await captureScreenshot(driver, fullPath);
3028
+ const relative = import_node_path6.default.join("screenshots", fileName);
3029
+ screenshots.push(relative);
3030
+ return relative;
3031
+ };
3032
+ const executeNested = (overrides) => executeNestedSteps(context, { driver, ...overrides });
3033
+ for (let index = 0; index < context.steps.length; index += 1) {
3034
+ const currentStepPath = stepPath(context.stepPathPrefix, index);
3035
+ if (Date.now() - runStartedAtMs > context.maxTotalTimeMs) {
3036
+ results.push({
3037
+ type: "timeout",
3038
+ status: "fail",
3039
+ durationMs: 0,
3040
+ error: `Max total time exceeded (${context.maxTotalTimeMs}ms)`
3041
+ });
3042
+ return { results, screenshots, failed: true, error: "Max total time exceeded" };
3043
+ }
3044
+ const runtimeVars = context.runtimeVars ?? /* @__PURE__ */ new Map();
3045
+ context.runtimeVars = runtimeVars;
3046
+ let step = context.steps[index];
3047
+ if (runtimeVars.size > 0) {
3048
+ step = applyRuntimeVars(step, runtimeVars);
3049
+ }
3050
+ const nextStep = context.steps[index + 1];
3051
+ if (!isWaitForDownloadStep(step) && context.pendingDownload === void 0 && isWaitForDownloadStep(nextStep)) {
3052
+ context.pendingDownload = armDownloadListener(
3053
+ driver,
3054
+ nextStep.waitForDownload?.timeout ?? 3e4
3055
+ );
3056
+ }
3057
+ const stepStart = Date.now();
3058
+ const stepType = getStepType(step);
3059
+ let stepResult = null;
3060
+ try {
3061
+ const handler = STEP_HANDLERS[stepType];
3062
+ if (!handler) {
2208
3063
  throw new Error("Unknown step type");
2209
3064
  }
3065
+ for (const capability of handler.capabilities) {
3066
+ if (!driver.capabilities.has(capability)) {
3067
+ throw new Error(
3068
+ `Driver does not support capability "${capability}" required by step "${stepType}"`
3069
+ );
3070
+ }
3071
+ }
3072
+ const outcome = await handler.run({
3073
+ driver,
3074
+ policy,
3075
+ context,
3076
+ step,
3077
+ index,
3078
+ stepPath: currentStepPath,
3079
+ stepStart,
3080
+ runtimeVars,
3081
+ results,
3082
+ screenshots,
3083
+ addScreenshot,
3084
+ executeNested
3085
+ });
3086
+ if (outcome.kind === "abort") {
3087
+ return { results, screenshots, failed: true, error: outcome.error };
3088
+ }
3089
+ stepResult = outcome.result;
2210
3090
  if (context.screenshotsMode === "all" && stepResult.type !== "screenshot") {
2211
3091
  const fileName = `step_${index + 1}.png`;
2212
3092
  await addScreenshot(fileName);
@@ -2233,12 +3113,12 @@ async function executeSteps(context) {
2233
3113
  return { results, screenshots, failed: false };
2234
3114
  }
2235
3115
  async function captureFinalScreenshot(page, runDir) {
2236
- const screenshotsDir = import_node_path4.default.join(runDir, "screenshots");
2237
- import_node_fs4.default.mkdirSync(screenshotsDir, { recursive: true });
3116
+ const screenshotsDir = import_node_path6.default.join(runDir, "screenshots");
3117
+ import_node_fs6.default.mkdirSync(screenshotsDir, { recursive: true });
2238
3118
  const fileName = "final.png";
2239
3119
  const filePath = screenshotPath(screenshotsDir, fileName);
2240
3120
  await captureScreenshot(page, filePath);
2241
- return import_node_path4.default.join("screenshots", fileName);
3121
+ return import_node_path6.default.join("screenshots", fileName);
2242
3122
  }
2243
3123
 
2244
3124
  // src/runner/assertions.ts
@@ -2398,18 +3278,18 @@ function captureTraceCorrelation(response, headerName, sink, redactionValues = [
2398
3278
  }
2399
3279
 
2400
3280
  // src/reporter/result.ts
2401
- var import_node_fs5 = __toESM(require("fs"), 1);
2402
- var import_node_path5 = __toESM(require("path"), 1);
3281
+ var import_node_fs7 = __toESM(require("fs"), 1);
3282
+ var import_node_path7 = __toESM(require("path"), 1);
2403
3283
  function writeResult(runDir, result) {
2404
3284
  const fileName = "result.json";
2405
- const fullPath = import_node_path5.default.join(runDir, fileName);
2406
- import_node_fs5.default.writeFileSync(fullPath, JSON.stringify(result, null, 2));
3285
+ const fullPath = import_node_path7.default.join(runDir, fileName);
3286
+ import_node_fs7.default.writeFileSync(fullPath, JSON.stringify(result, null, 2));
2407
3287
  return fileName;
2408
3288
  }
2409
3289
 
2410
3290
  // src/reporter/summary.ts
2411
- var import_node_fs6 = __toESM(require("fs"), 1);
2412
- var import_node_path6 = __toESM(require("path"), 1);
3291
+ var import_node_fs8 = __toESM(require("fs"), 1);
3292
+ var import_node_path8 = __toESM(require("path"), 1);
2413
3293
  function escapeMd(text) {
2414
3294
  return text.replace(/([|`*_{}[\]()#+\-!\\])/g, "\\$1");
2415
3295
  }
@@ -2487,15 +3367,15 @@ function writeSummary(runDir, result) {
2487
3367
  }
2488
3368
  }
2489
3369
  const fileName = "summary.md";
2490
- const fullPath = import_node_path6.default.join(runDir, fileName);
2491
- import_node_fs6.default.writeFileSync(fullPath, `${lines.join("\n")}
3370
+ const fullPath = import_node_path8.default.join(runDir, fileName);
3371
+ import_node_fs8.default.writeFileSync(fullPath, `${lines.join("\n")}
2492
3372
  `);
2493
3373
  return fileName;
2494
3374
  }
2495
3375
 
2496
3376
  // src/reporter/junit.ts
2497
- var import_node_fs7 = __toESM(require("fs"), 1);
2498
- var import_node_path7 = __toESM(require("path"), 1);
3377
+ var import_node_fs9 = __toESM(require("fs"), 1);
3378
+ var import_node_path9 = __toESM(require("path"), 1);
2499
3379
  function escapeXml(text) {
2500
3380
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
2501
3381
  }
@@ -2539,8 +3419,8 @@ function writeJunit(runDir, result) {
2539
3419
  lines.push(" </testsuite>");
2540
3420
  lines.push("</testsuites>");
2541
3421
  const fileName = "junit.xml";
2542
- const fullPath = import_node_path7.default.join(runDir, fileName);
2543
- import_node_fs7.default.writeFileSync(fullPath, `${lines.join("\n")}
3422
+ const fullPath = import_node_path9.default.join(runDir, fileName);
3423
+ import_node_fs9.default.writeFileSync(fullPath, `${lines.join("\n")}
2544
3424
  `);
2545
3425
  return fileName;
2546
3426
  }
@@ -2574,15 +3454,15 @@ function timestamp(prefix) {
2574
3454
  }
2575
3455
 
2576
3456
  // src/runner/history.ts
2577
- var import_node_fs8 = __toESM(require("fs"), 1);
2578
- var import_node_path8 = __toESM(require("path"), 1);
3457
+ var import_node_fs10 = __toESM(require("fs"), 1);
3458
+ var import_node_path10 = __toESM(require("path"), 1);
2579
3459
  var HISTORY_FILE = "history.json";
2580
3460
  var LOCK_FILE_SUFFIX = ".lock";
2581
3461
  var LOCK_RETRY_MS = 10;
2582
3462
  var LOCK_TIMEOUT_MS = 5e3;
2583
3463
  var SLEEP_BUFFER = new Int32Array(new SharedArrayBuffer(4));
2584
3464
  function historyPath(configDir) {
2585
- return import_node_path8.default.join(configDir, HISTORY_FILE);
3465
+ return import_node_path10.default.join(configDir, HISTORY_FILE);
2586
3466
  }
2587
3467
  function isHistoryEntry(value) {
2588
3468
  if (!value || typeof value !== "object") {
@@ -2593,11 +3473,11 @@ function isHistoryEntry(value) {
2593
3473
  }
2594
3474
  function readHistory(configDir) {
2595
3475
  const filePath = historyPath(configDir);
2596
- if (!import_node_fs8.default.existsSync(filePath)) {
3476
+ if (!import_node_fs10.default.existsSync(filePath)) {
2597
3477
  return { entries: [] };
2598
3478
  }
2599
3479
  try {
2600
- const raw = import_node_fs8.default.readFileSync(filePath, "utf-8");
3480
+ const raw = import_node_fs10.default.readFileSync(filePath, "utf-8");
2601
3481
  const parsed = JSON.parse(raw);
2602
3482
  if (parsed && typeof parsed === "object" && "entries" in parsed && Array.isArray(parsed.entries)) {
2603
3483
  const validatedEntries = parsed.entries.filter(isHistoryEntry);
@@ -2636,12 +3516,12 @@ function sleepSync(ms) {
2636
3516
  function withHistoryLock(configDir, fn) {
2637
3517
  const filePath = historyPath(configDir);
2638
3518
  const lockPath = `${filePath}${LOCK_FILE_SUFFIX}`;
2639
- import_node_fs8.default.mkdirSync(import_node_path8.default.dirname(filePath), { recursive: true });
3519
+ import_node_fs10.default.mkdirSync(import_node_path10.default.dirname(filePath), { recursive: true });
2640
3520
  const startedAt = Date.now();
2641
3521
  while (Date.now() - startedAt < LOCK_TIMEOUT_MS) {
2642
3522
  let fd;
2643
3523
  try {
2644
- fd = import_node_fs8.default.openSync(lockPath, "wx");
3524
+ fd = import_node_fs10.default.openSync(lockPath, "wx");
2645
3525
  } catch (error) {
2646
3526
  if (error.code === "EEXIST") {
2647
3527
  sleepSync(LOCK_RETRY_MS);
@@ -2653,10 +3533,10 @@ function withHistoryLock(configDir, fn) {
2653
3533
  return fn();
2654
3534
  } finally {
2655
3535
  try {
2656
- import_node_fs8.default.closeSync(fd);
3536
+ import_node_fs10.default.closeSync(fd);
2657
3537
  } catch {
2658
3538
  }
2659
- import_node_fs8.default.rmSync(lockPath, { force: true });
3539
+ import_node_fs10.default.rmSync(lockPath, { force: true });
2660
3540
  }
2661
3541
  }
2662
3542
  throw new Error(
@@ -2669,9 +3549,9 @@ function appendEntry(configDir, entry, maxRuns) {
2669
3549
  const current = readHistory(configDir);
2670
3550
  const next = pruneEntries([...current.entries, entry], maxRuns);
2671
3551
  const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
2672
- import_node_fs8.default.writeFileSync(tempPath, `${JSON.stringify({ entries: next }, null, 2)}
3552
+ import_node_fs10.default.writeFileSync(tempPath, `${JSON.stringify({ entries: next }, null, 2)}
2673
3553
  `);
2674
- import_node_fs8.default.renameSync(tempPath, filePath);
3554
+ import_node_fs10.default.renameSync(tempPath, filePath);
2675
3555
  });
2676
3556
  }
2677
3557
 
@@ -2684,11 +3564,11 @@ function parseViewportFlag(value) {
2684
3564
  return value;
2685
3565
  }
2686
3566
  function resolvePath(configDir, inputPath) {
2687
- if (import_node_path9.default.isAbsolute(inputPath)) {
3567
+ if (import_node_path11.default.isAbsolute(inputPath)) {
2688
3568
  return inputPath;
2689
3569
  }
2690
- const projectRoot = import_node_path9.default.dirname(configDir);
2691
- return import_node_path9.default.join(projectRoot, inputPath);
3570
+ const projectRoot = import_node_path11.default.dirname(configDir);
3571
+ return import_node_path11.default.join(projectRoot, inputPath);
2692
3572
  }
2693
3573
  function buildRunResult(options) {
2694
3574
  return {
@@ -2707,12 +3587,12 @@ function buildRunResult(options) {
2707
3587
  }
2708
3588
  function writeConsoleLog(runDir, entries) {
2709
3589
  const fileName = "console.log";
2710
- const filePath = import_node_path9.default.join(runDir, fileName);
3590
+ const filePath = import_node_path11.default.join(runDir, fileName);
2711
3591
  const lines = entries.map((entry) => {
2712
3592
  const location = entry.location ? ` (${entry.location})` : "";
2713
3593
  return `[${entry.type}] ${entry.text}${location}`;
2714
3594
  });
2715
- import_node_fs9.default.writeFileSync(filePath, `${lines.join("\n")}
3595
+ import_node_fs11.default.writeFileSync(filePath, `${lines.join("\n")}
2716
3596
  `);
2717
3597
  return fileName;
2718
3598
  }
@@ -2720,8 +3600,8 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
2720
3600
  const headless = options.headed ? false : config.browser.headless;
2721
3601
  const slowMo = options.slowMo ?? config.browser.slowMo;
2722
3602
  const maxSteps = config.guardrails.maxSteps;
2723
- const runDir = import_node_path9.default.join(configDir, "runs", timestamp());
2724
- import_node_fs9.default.mkdirSync(runDir, { recursive: true });
3603
+ const runDir = import_node_path11.default.join(configDir, "runs", timestamp());
3604
+ import_node_fs11.default.mkdirSync(runDir, { recursive: true });
2725
3605
  const storageStatePath = config.auth.storageStatePath ? resolvePath(configDir, config.auth.storageStatePath) : void 0;
2726
3606
  const engine = options.browser ?? config.browser.engine;
2727
3607
  const channel = options.channel ?? config.browser.channel;
@@ -2740,6 +3620,7 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
2740
3620
  });
2741
3621
  let result;
2742
3622
  try {
3623
+ const driver = createPlaywrightDriver(session.page);
2743
3624
  const consoleEntries = [];
2744
3625
  const networkEntries = [];
2745
3626
  const traceCorrelations = [];
@@ -2751,7 +3632,7 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
2751
3632
  location: message.location().url
2752
3633
  });
2753
3634
  });
2754
- session.page.on("response", (response) => {
3635
+ driver.onResponse((response) => {
2755
3636
  if (response.status() >= 400) {
2756
3637
  networkEntries.push({ url: response.url(), status: response.status() });
2757
3638
  captureTraceCorrelation(response, traceHeader, traceCorrelations, redactionValues);
@@ -2765,6 +3646,7 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
2765
3646
  try {
2766
3647
  const stepExecution = await executeSteps({
2767
3648
  page: session.page,
3649
+ driver,
2768
3650
  steps: interpolatedHunt.steps,
2769
3651
  targetUrl,
2770
3652
  runDir,
@@ -2797,7 +3679,7 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
2797
3679
  }
2798
3680
  let finalScreenshot;
2799
3681
  try {
2800
- finalScreenshot = await captureFinalScreenshot(session.page, runDir);
3682
+ finalScreenshot = await captureFinalScreenshot(driver, runDir);
2801
3683
  } catch {
2802
3684
  finalScreenshot = void 0;
2803
3685
  }
@@ -2841,6 +3723,9 @@ function delay(ms) {
2841
3723
  }
2842
3724
  async function runHunt(options) {
2843
3725
  const { config, configDir } = loadConfig(options.configPath);
3726
+ if (config.target.type === "macos") {
3727
+ return runMacHunt(options, config, configDir, config.target);
3728
+ }
2844
3729
  const hunt = loadHunt(options.huntName, configDir);
2845
3730
  const {
2846
3731
  hunt: interpolatedHunt,
@@ -2891,9 +3776,124 @@ async function runHunt(options) {
2891
3776
  }
2892
3777
  return lastResult;
2893
3778
  }
3779
+ async function executeMacHuntAttempt(options, config, configDir, target, interpolatedHunt, redactedFillSteps, randomVars, allowedApps) {
3780
+ const maxSteps = config.guardrails.maxSteps;
3781
+ const runDir = import_node_path11.default.join(configDir, "runs", timestamp());
3782
+ import_node_fs11.default.mkdirSync(runDir, { recursive: true });
3783
+ const session = await launchMacSession({
3784
+ app: target.app,
3785
+ timeoutMs: config.browser.timeout,
3786
+ clientFactory: options.macClientFactory
3787
+ });
3788
+ let result;
3789
+ try {
3790
+ const targetLabel = `macos:${session.bundleId}`;
3791
+ const effectiveAllowedApps = [.../* @__PURE__ */ new Set([...allowedApps, target.app, session.bundleId])];
3792
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
3793
+ const startTime = Date.now();
3794
+ let stepResults = [];
3795
+ let stepScreenshots = [];
3796
+ let stepFailed = false;
3797
+ try {
3798
+ const stepExecution = await executeSteps({
3799
+ driver: session.driver,
3800
+ steps: interpolatedHunt.steps,
3801
+ targetUrl: targetLabel,
3802
+ runDir,
3803
+ screenshotsMode: config.artifacts.screenshots,
3804
+ forbiddenSelectors: config.guardrails.forbiddenSelectors,
3805
+ allowedDomains: [],
3806
+ allowedApps: effectiveAllowedApps,
3807
+ maxSteps,
3808
+ maxTotalTimeMs: config.assertions.maxTotalTimeMs,
3809
+ selfHealing: config.guardrails.selfHealing,
3810
+ redactedFillSteps,
3811
+ randomVars,
3812
+ configDir,
3813
+ huntStack: [options.huntName],
3814
+ onStep: options.onStep
3815
+ });
3816
+ stepResults = stepExecution.results;
3817
+ stepScreenshots = stepExecution.screenshots;
3818
+ stepFailed = stepExecution.failed;
3819
+ } catch (error) {
3820
+ const message = error instanceof Error ? error.message : "Step execution failed";
3821
+ stepResults = [{ type: "steps", status: "fail", durationMs: 0, error: message }];
3822
+ stepFailed = true;
3823
+ }
3824
+ let finalScreenshot;
3825
+ try {
3826
+ finalScreenshot = await captureFinalScreenshot(session.driver, runDir);
3827
+ } catch {
3828
+ finalScreenshot = void 0;
3829
+ }
3830
+ const durationMs = Date.now() - startTime;
3831
+ const status = stepFailed ? "fail" : "pass";
3832
+ const artifacts = {
3833
+ screenshots: finalScreenshot ? [...stepScreenshots, finalScreenshot] : stepScreenshots
3834
+ };
3835
+ const runResult = buildRunResult({
3836
+ status,
3837
+ startedAt,
3838
+ durationMs,
3839
+ hunt: options.huntName,
3840
+ targetUrl: targetLabel,
3841
+ steps: stepResults,
3842
+ assertions: [],
3843
+ artifacts
3844
+ });
3845
+ result = writeReports(runDir, runResult, { junit: options.junit ?? config.artifacts.junit });
3846
+ } finally {
3847
+ await closeMacSession(session);
3848
+ }
3849
+ return { result, runDir, steps: interpolatedHunt.steps };
3850
+ }
3851
+ async function runMacHunt(options, config, configDir, target) {
3852
+ const hunt = loadHunt(options.huntName, configDir);
3853
+ const { hunt: interpolatedHunt, redactedFillSteps, randomVars } = interpolateHunt(hunt, process.env);
3854
+ assertStepsSupportedByTarget(interpolatedHunt.steps, "macos");
3855
+ assertHuntAssertionsSupportedByTarget(interpolatedHunt.assertions, "macos");
3856
+ assertTargetAppAllowed(config.guardrails.allowedApps, target.app);
3857
+ const maxSteps = config.guardrails.maxSteps;
3858
+ if (interpolatedHunt.steps.length > maxSteps) {
3859
+ throw new Error(`Hunt has ${interpolatedHunt.steps.length} steps. Max allowed is ${maxSteps}.`);
3860
+ }
3861
+ const maxRetries = hunt.retry?.maxRetries ?? 0;
3862
+ const retryDelay = hunt.retry?.delay ?? 0;
3863
+ let lastResult;
3864
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
3865
+ if (attempt > 0 && retryDelay > 0) {
3866
+ await delay(retryDelay);
3867
+ }
3868
+ lastResult = await executeMacHuntAttempt(
3869
+ options,
3870
+ config,
3871
+ configDir,
3872
+ target,
3873
+ interpolatedHunt,
3874
+ redactedFillSteps,
3875
+ randomVars,
3876
+ config.guardrails.allowedApps
3877
+ );
3878
+ if (lastResult.result.status === "pass") {
3879
+ if (attempt > 0) {
3880
+ lastResult.result.artifacts.summary = `Passed on attempt ${attempt + 1} of ${maxRetries + 1}`;
3881
+ }
3882
+ recordHistory(configDir, lastResult, config.history.maxRuns);
3883
+ return lastResult;
3884
+ }
3885
+ }
3886
+ if (maxRetries > 0 && lastResult) {
3887
+ lastResult.result.artifacts.summary = `Failed after ${maxRetries + 1} attempts`;
3888
+ }
3889
+ if (lastResult) {
3890
+ recordHistory(configDir, lastResult, config.history.maxRuns);
3891
+ }
3892
+ return lastResult;
3893
+ }
2894
3894
  function recordHistory(configDir, outcome, maxRuns) {
2895
3895
  try {
2896
- const relativeRunDir = import_node_path9.default.relative(configDir, outcome.runDir);
3896
+ const relativeRunDir = import_node_path11.default.relative(configDir, outcome.runDir);
2897
3897
  appendEntry(
2898
3898
  configDir,
2899
3899
  {
@@ -2910,11 +3910,11 @@ function recordHistory(configDir, outcome, maxRuns) {
2910
3910
  }
2911
3911
 
2912
3912
  // src/runner/suite.ts
2913
- var import_node_path12 = __toESM(require("path"), 1);
3913
+ var import_node_path14 = __toESM(require("path"), 1);
2914
3914
 
2915
3915
  // src/reporter/ci-summary.ts
2916
- var import_node_fs10 = __toESM(require("fs"), 1);
2917
- var import_node_path10 = __toESM(require("path"), 1);
3916
+ var import_node_fs12 = __toESM(require("fs"), 1);
3917
+ var import_node_path12 = __toESM(require("path"), 1);
2918
3918
  var import_chalk = __toESM(require("chalk"), 1);
2919
3919
  function countCiResults(results) {
2920
3920
  return {
@@ -2944,9 +3944,9 @@ function writeCiResult(ciRunDir, results, startedAt, totalDurationMs, flaky = []
2944
3944
  ...flaky.length > 0 ? { flaky } : {},
2945
3945
  ...clusters.length > 0 ? { clusters } : {}
2946
3946
  };
2947
- import_node_fs10.default.mkdirSync(ciRunDir, { recursive: true });
2948
- const filePath = import_node_path10.default.join(ciRunDir, "ci-result.json");
2949
- import_node_fs10.default.writeFileSync(filePath, JSON.stringify(ciResult, null, 2) + "\n");
3947
+ import_node_fs12.default.mkdirSync(ciRunDir, { recursive: true });
3948
+ const filePath = import_node_path12.default.join(ciRunDir, "ci-result.json");
3949
+ import_node_fs12.default.writeFileSync(filePath, JSON.stringify(ciResult, null, 2) + "\n");
2950
3950
  return filePath;
2951
3951
  }
2952
3952
 
@@ -3080,8 +4080,8 @@ function clusterFailures(failures) {
3080
4080
  }
3081
4081
 
3082
4082
  // src/backlog/index.ts
3083
- var import_node_fs11 = __toESM(require("fs"), 1);
3084
- var import_node_path11 = __toESM(require("path"), 1);
4083
+ var import_node_fs13 = __toESM(require("fs"), 1);
4084
+ var import_node_path13 = __toESM(require("path"), 1);
3085
4085
 
3086
4086
  // src/backlog/parse.ts
3087
4087
  var MARKER_FP = /<!--\s*prowl:fp=([0-9a-f]+)/;
@@ -3171,7 +4171,7 @@ ${after}`;
3171
4171
  // src/backlog/index.ts
3172
4172
  function readFileOrEmpty(filePath) {
3173
4173
  try {
3174
- return import_node_fs11.default.readFileSync(filePath, "utf-8");
4174
+ return import_node_fs13.default.readFileSync(filePath, "utf-8");
3175
4175
  } catch (error) {
3176
4176
  const err = error;
3177
4177
  if (err.code === "ENOENT") return "";
@@ -3187,7 +4187,7 @@ function buildFailure(hunt) {
3187
4187
  if (!hunt.runDir) return failure;
3188
4188
  let run;
3189
4189
  try {
3190
- const resultJson = readFileOrEmpty(import_node_path11.default.join(hunt.runDir, "result.json"));
4190
+ const resultJson = readFileOrEmpty(import_node_path13.default.join(hunt.runDir, "result.json"));
3191
4191
  if (!resultJson) return failure;
3192
4192
  run = JSON.parse(resultJson);
3193
4193
  } catch (error) {
@@ -3216,8 +4216,8 @@ function extractFailures(suiteResult) {
3216
4216
  }
3217
4217
  function updateBacklogFromSuite(suiteResult, options = {}) {
3218
4218
  const projectRoot = options.projectRoot ?? process.cwd();
3219
- const backlogPath = options.backlogPath ?? import_node_path11.default.join(projectRoot, "docs", "backlog.md");
3220
- const resolvedPath = options.resolvedPath ?? import_node_path11.default.join(projectRoot, "docs", "resolved.md");
4219
+ const backlogPath = options.backlogPath ?? import_node_path13.default.join(projectRoot, "docs", "backlog.md");
4220
+ const resolvedPath = options.resolvedPath ?? import_node_path13.default.join(projectRoot, "docs", "resolved.md");
3221
4221
  const date = options.date ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
3222
4222
  const summary = { created: [], regressions: [], skipped: [], backlogPath };
3223
4223
  const failures = extractFailures(suiteResult);
@@ -3249,8 +4249,8 @@ function updateBacklogFromSuite(suiteResult, options = {}) {
3249
4249
  }
3250
4250
  }
3251
4251
  if (ticketsToAdd.length > 0) {
3252
- import_node_fs11.default.mkdirSync(import_node_path11.default.dirname(backlogPath), { recursive: true });
3253
- import_node_fs11.default.writeFileSync(backlogPath, insertTickets(backlogContent, ticketsToAdd));
4252
+ import_node_fs13.default.mkdirSync(import_node_path13.default.dirname(backlogPath), { recursive: true });
4253
+ import_node_fs13.default.writeFileSync(backlogPath, insertTickets(backlogContent, ticketsToAdd));
3254
4254
  }
3255
4255
  return summary;
3256
4256
  }
@@ -3411,7 +4411,7 @@ async function runSuite(options = {}) {
3411
4411
  const clusters = clusterFailures(
3412
4412
  extractFailures({ result: { hunts: results }, resultPath: null })
3413
4413
  ).filter((cluster) => cluster.count > 1);
3414
- const ciRunDir = import_node_path12.default.join(configDir, "runs", timestamp("ci"));
4414
+ const ciRunDir = import_node_path14.default.join(configDir, "runs", timestamp("ci"));
3415
4415
  const resultPath = writeCiResult(ciRunDir, results, startedAt, totalDurationMs, flaky, clusters);
3416
4416
  const { passed, failed, skipped } = countCiResults(results);
3417
4417
  return {
@@ -3548,7 +4548,20 @@ async function analyzePage(page) {
3548
4548
 
3549
4549
  // src/generator/index.ts
3550
4550
  var import_yaml2 = __toESM(require("yaml"), 1);
3551
- var import_playwright2 = require("playwright");
4551
+
4552
+ // src/browser/engines.ts
4553
+ function formatSupportedBrowserEngines() {
4554
+ return SUPPORTED_BROWSER_ENGINES.join(", ");
4555
+ }
4556
+ function parseBrowserEngine(value, fallback = "chromium") {
4557
+ if (value === void 0 || value.length === 0) {
4558
+ return fallback;
4559
+ }
4560
+ if (SUPPORTED_BROWSER_ENGINES.includes(value)) {
4561
+ return value;
4562
+ }
4563
+ throw new Error(`Unsupported browser engine "${value}". Use ${formatSupportedBrowserEngines()}.`);
4564
+ }
3552
4565
 
3553
4566
  // src/generator/prompt.ts
3554
4567
  var STEP_REFERENCE = `
@@ -3710,18 +4723,34 @@ async function generateWithOpenAi(prompt, config) {
3710
4723
  }
3711
4724
 
3712
4725
  // src/generator/index.ts
4726
+ function parseViewportFlag2(value) {
4727
+ const match = /^(\d+)x(\d+)$/i.exec(value);
4728
+ if (match) {
4729
+ return { width: Number(match[1]), height: Number(match[2]) };
4730
+ }
4731
+ return value;
4732
+ }
3713
4733
  async function generateHunt(options) {
3714
4734
  let analysis = options.analysis;
3715
4735
  if (!analysis && options.url) {
3716
- const browser = await import_playwright2.chromium.launch({ headless: true });
3717
- const context = await browser.newContext();
3718
- const page = await context.newPage();
4736
+ const engine = parseBrowserEngine(options.browser);
4737
+ const viewport = options.viewport ? resolveViewport(parseViewportFlag2(options.viewport)) : resolveViewport(void 0);
4738
+ const session = await launchBrowser({
4739
+ headless: true,
4740
+ slowMo: 0,
4741
+ timeout: 3e4,
4742
+ trace: false,
4743
+ recordHar: false,
4744
+ runDir: process.cwd(),
4745
+ engine,
4746
+ viewport
4747
+ });
4748
+ const driver = createPlaywrightDriver(session.page);
3719
4749
  try {
3720
- await page.goto(options.url, { waitUntil: "networkidle" });
3721
- analysis = await analyzePage(page);
4750
+ await driver.goto(options.url, { waitUntil: "networkidle" });
4751
+ analysis = await analyzePage(driver);
3722
4752
  } finally {
3723
- await context.close();
3724
- await browser.close();
4753
+ await closeBrowser(session);
3725
4754
  }
3726
4755
  }
3727
4756
  if (!analysis) {
@@ -3738,28 +4767,40 @@ async function generateHunt(options) {
3738
4767
  // Annotate the CommonJS export names for ESM import in node:
3739
4768
  0 && (module.exports = {
3740
4769
  DEFAULT_FLAKY_THRESHOLD,
4770
+ DEFAULT_REQUEST_TIMEOUT_MS,
4771
+ SpawnMacHelperClient,
4772
+ WEB_ONLY_STEP_TYPES,
3741
4773
  analyzePage,
4774
+ assertStepsSupportedByTarget,
4775
+ assertTargetAppAllowed,
3742
4776
  buildHealCandidates,
4777
+ closeMacSession,
3743
4778
  clusterFailures,
3744
4779
  computeFlakeScore,
3745
4780
  configSchema,
4781
+ createMacDriver,
3746
4782
  extractFailures,
3747
4783
  extractSelectorIntent,
3748
4784
  generateHunt,
3749
4785
  healSelector,
3750
4786
  huntSchema,
3751
4787
  interpolateHunt,
4788
+ launchMacSession,
3752
4789
  listHunts,
3753
4790
  loadConfig,
3754
4791
  loadHunt,
3755
4792
  loadHuntMeta,
3756
4793
  loadHuntTags,
4794
+ macdriverBuildInstructions,
4795
+ parseMacSelector,
3757
4796
  rankFlaky,
3758
4797
  readHistory,
3759
4798
  readHuntHistory,
4799
+ resolveHelperBinary,
3760
4800
  runHunt,
3761
4801
  runSuite,
3762
4802
  stepSchema,
3763
- updateBacklogFromSuite
4803
+ updateBacklogFromSuite,
4804
+ webOnlyReason
3764
4805
  });
3765
4806
  //# sourceMappingURL=lib.cjs.map