prowl-tools 0.1.3

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.
@@ -0,0 +1,3158 @@
1
+ import {
2
+ ensureAllowedDomain,
3
+ huntSchema,
4
+ listHunts,
5
+ loadConfig,
6
+ loadHunt,
7
+ loadHuntTags,
8
+ resolveViewport
9
+ } from "./chunk-NXXGJOBG.js";
10
+
11
+ // src/config/interpolate.ts
12
+ import crypto from "crypto";
13
+ var VAR_PATTERN = /\{\{([A-Z0-9_]+)\}\}/g;
14
+ function collectInterpolatedValues(input, vars, values) {
15
+ if (typeof input === "string") {
16
+ for (const match of input.matchAll(VAR_PATTERN)) {
17
+ const varValue = vars[match[1]];
18
+ if (varValue) values.add(varValue);
19
+ }
20
+ return;
21
+ }
22
+ if (Array.isArray(input)) {
23
+ for (const item of input) {
24
+ collectInterpolatedValues(item, vars, values);
25
+ }
26
+ return;
27
+ }
28
+ if (input && typeof input === "object") {
29
+ for (const [key, value] of Object.entries(input)) {
30
+ collectInterpolatedValues(key, vars, values);
31
+ collectInterpolatedValues(value, vars, values);
32
+ }
33
+ }
34
+ }
35
+ function interpolateString(input, vars) {
36
+ const usedVars = [];
37
+ const value = input.replace(VAR_PATTERN, (_, name) => {
38
+ const varValue = vars[name];
39
+ if (varValue === void 0) {
40
+ throw new Error(`Missing variable: ${name}`);
41
+ }
42
+ usedVars.push(name);
43
+ return varValue;
44
+ });
45
+ return { value, usedVars };
46
+ }
47
+ var RANDOM_FIRST_NAMES = ["Alex", "Jordan", "Morgan", "Taylor", "Casey", "Riley", "Quinn", "Avery"];
48
+ var RANDOM_LAST_NAMES = ["Smith", "Johnson", "Brown", "Davis", "Wilson", "Clark", "Hall", "Young"];
49
+ function generateRandomVars(randomSource) {
50
+ const random = randomSource?.random ?? Math.random;
51
+ const randomBytes = randomSource?.randomBytes ?? crypto.randomBytes;
52
+ const randomUUID = randomSource?.randomUUID ?? crypto.randomUUID;
53
+ const hex = randomBytes(4).toString("hex");
54
+ const firstIndex = Math.floor(random() * RANDOM_FIRST_NAMES.length);
55
+ const lastIndex = Math.floor(random() * RANDOM_LAST_NAMES.length);
56
+ const num = Math.floor(random() * 9e3) + 1e3;
57
+ const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
58
+ let text = "";
59
+ for (let i = 0; i < 8; i++) {
60
+ text += chars[Math.floor(random() * chars.length)];
61
+ }
62
+ return {
63
+ RANDOM_EMAIL: `prowl_${hex}@test.com`,
64
+ RANDOM_NAME: `${RANDOM_FIRST_NAMES[firstIndex]} ${RANDOM_LAST_NAMES[lastIndex]}`,
65
+ RANDOM_NUMBER: String(num),
66
+ RANDOM_UUID: randomUUID(),
67
+ RANDOM_TEXT: text
68
+ };
69
+ }
70
+ function interpolateStep(step, vars, stepPath2, redacted) {
71
+ const isExplicitFill = (value) => typeof value.selector === "string" && typeof value.value === "string";
72
+ const interpolateSinglePair = (record) => {
73
+ const entries = Object.entries(record);
74
+ if (entries.length !== 1) {
75
+ throw new Error("Shorthand step expects exactly one key-value pair");
76
+ }
77
+ const [key, value] = entries[0];
78
+ return {
79
+ [interpolateString(key, vars).value]: interpolateString(value, vars).value
80
+ };
81
+ };
82
+ if ("navigate" in step) {
83
+ const result = interpolateString(step.navigate, vars);
84
+ return { navigate: result.value };
85
+ }
86
+ if ("click" in step) {
87
+ if (typeof step.click === "string") {
88
+ return { click: interpolateString(step.click, vars).value };
89
+ }
90
+ const result = interpolateString(step.click.selector, vars);
91
+ return { click: { selector: result.value } };
92
+ }
93
+ if ("fill" in step) {
94
+ if (isExplicitFill(step.fill)) {
95
+ const selectorResult = interpolateString(step.fill.selector, vars);
96
+ const valueResult2 = interpolateString(step.fill.value, vars);
97
+ if (valueResult2.usedVars.length > 0) {
98
+ redacted.add(stepPath2);
99
+ }
100
+ return { fill: { selector: selectorResult.value, value: valueResult2.value } };
101
+ }
102
+ const [rawLabel, rawValue] = Object.entries(step.fill)[0] ?? [];
103
+ if (rawLabel === void 0 || rawValue === void 0) {
104
+ throw new Error("Shorthand fill expects exactly one key-value pair");
105
+ }
106
+ const labelResult = interpolateString(rawLabel, vars);
107
+ const valueResult = interpolateString(rawValue, vars);
108
+ if (valueResult.usedVars.length > 0) {
109
+ redacted.add(stepPath2);
110
+ }
111
+ return {
112
+ fill: {
113
+ [labelResult.value]: valueResult.value
114
+ }
115
+ };
116
+ }
117
+ if ("type" in step) {
118
+ const valueResult = interpolateString(step.type, vars);
119
+ if (valueResult.usedVars.length > 0) {
120
+ redacted.add(stepPath2);
121
+ }
122
+ return { type: valueResult.value };
123
+ }
124
+ if ("selectOption" in step) {
125
+ const selectorResult = interpolateString(step.selectOption.selector, vars);
126
+ const valueResult = interpolateString(step.selectOption.value, vars);
127
+ return { selectOption: { selector: selectorResult.value, value: valueResult.value } };
128
+ }
129
+ if ("select" in step) {
130
+ return { select: interpolateSinglePair(step.select) };
131
+ }
132
+ if ("press" in step) {
133
+ const selectorResult = interpolateString(step.press.selector, vars);
134
+ const keyResult = interpolateString(step.press.key, vars);
135
+ return { press: { selector: selectorResult.value, key: keyResult.value } };
136
+ }
137
+ if ("onDialog" in step) {
138
+ return { onDialog: { action: step.onDialog.action } };
139
+ }
140
+ if ("setInputFiles" in step) {
141
+ const selectorResult = interpolateString(step.setInputFiles.selector, vars);
142
+ const rawFiles = step.setInputFiles.files;
143
+ const files = Array.isArray(rawFiles) ? rawFiles.map((f) => interpolateString(f, vars).value) : interpolateString(rawFiles, vars).value;
144
+ return { setInputFiles: { selector: selectorResult.value, files } };
145
+ }
146
+ if ("runHunt" in step) {
147
+ if (typeof step.runHunt === "string") {
148
+ return { runHunt: interpolateString(step.runHunt, vars).value };
149
+ }
150
+ const nameResult = interpolateString(step.runHunt.name, vars);
151
+ const interpolatedVars = {};
152
+ for (const [key, value] of Object.entries(step.runHunt.vars ?? {})) {
153
+ interpolatedVars[key] = interpolateString(value, vars).value;
154
+ }
155
+ return {
156
+ runHunt: {
157
+ name: nameResult.value,
158
+ ...Object.keys(interpolatedVars).length > 0 ? { vars: interpolatedVars } : {}
159
+ }
160
+ };
161
+ }
162
+ if ("assert" in step) {
163
+ if (step.assert.visible !== void 0) {
164
+ return { assert: { visible: interpolateString(step.assert.visible, vars).value } };
165
+ }
166
+ if (step.assert.notVisible !== void 0) {
167
+ return { assert: { notVisible: interpolateString(step.assert.notVisible, vars).value } };
168
+ }
169
+ if (step.assert.urlIncludes !== void 0) {
170
+ return { assert: { urlIncludes: interpolateString(step.assert.urlIncludes, vars).value } };
171
+ }
172
+ if (step.assert.urlEquals !== void 0) {
173
+ return { assert: { urlEquals: interpolateString(step.assert.urlEquals, vars).value } };
174
+ }
175
+ return step;
176
+ }
177
+ if ("wait" in step) {
178
+ if (typeof step.wait === "string") {
179
+ return { wait: interpolateString(step.wait, vars).value };
180
+ }
181
+ return {
182
+ wait: {
183
+ for: interpolateString(step.wait.for, vars).value,
184
+ timeout: step.wait.timeout
185
+ }
186
+ };
187
+ }
188
+ if ("waitForSelector" in step) {
189
+ const selectorResult = interpolateString(step.waitForSelector.selector, vars);
190
+ return {
191
+ waitForSelector: {
192
+ selector: selectorResult.value,
193
+ timeout: step.waitForSelector.timeout
194
+ }
195
+ };
196
+ }
197
+ if ("waitForUrl" in step) {
198
+ const valueResult = interpolateString(step.waitForUrl.value, vars);
199
+ return {
200
+ waitForUrl: {
201
+ value: valueResult.value,
202
+ timeout: step.waitForUrl.timeout
203
+ }
204
+ };
205
+ }
206
+ if ("waitForNetworkIdle" in step) {
207
+ return { waitForNetworkIdle: { timeout: step.waitForNetworkIdle.timeout } };
208
+ }
209
+ if ("hover" in step) {
210
+ const selectorResult = interpolateString(step.hover.selector, vars);
211
+ return { hover: { selector: selectorResult.value } };
212
+ }
213
+ if ("scroll" in step) {
214
+ return { scroll: { direction: step.scroll.direction, amount: step.scroll.amount } };
215
+ }
216
+ if ("scrollTo" in step) {
217
+ const selectorResult = interpolateString(step.scrollTo.selector, vars);
218
+ return { scrollTo: { selector: selectorResult.value } };
219
+ }
220
+ if ("screenshot" in step) {
221
+ return { screenshot: { name: step.screenshot.name } };
222
+ }
223
+ if ("if" in step) {
224
+ const condition = step.if;
225
+ const thenSteps = condition.then.map(
226
+ (s, i) => interpolateStep(s, vars, `${stepPath2}.if.then.${i}`, redacted)
227
+ );
228
+ const elseSteps = condition.else?.map(
229
+ (s, i) => interpolateStep(s, vars, `${stepPath2}.if.else.${i}`, redacted)
230
+ );
231
+ return {
232
+ if: {
233
+ ...condition.visible !== void 0 ? { visible: interpolateString(condition.visible, vars).value } : {},
234
+ ...condition.notVisible !== void 0 ? { notVisible: interpolateString(condition.notVisible, vars).value } : {},
235
+ then: thenSteps,
236
+ ...elseSteps !== void 0 ? { else: elseSteps } : {}
237
+ }
238
+ };
239
+ }
240
+ if ("repeat" in step) {
241
+ const repeat = step.repeat;
242
+ const subSteps = repeat.steps.map(
243
+ (s, i) => interpolateStep(s, vars, `${stepPath2}.repeat.steps.${i}`, redacted)
244
+ );
245
+ return {
246
+ repeat: {
247
+ ...repeat.times !== void 0 ? { times: repeat.times } : {},
248
+ ...repeat.while !== void 0 ? {
249
+ while: {
250
+ ...repeat.while.visible !== void 0 ? { visible: interpolateString(repeat.while.visible, vars).value } : {},
251
+ ...repeat.while.notVisible !== void 0 ? { notVisible: interpolateString(repeat.while.notVisible, vars).value } : {}
252
+ }
253
+ } : {},
254
+ ...repeat.maxIterations !== void 0 ? { maxIterations: repeat.maxIterations } : {},
255
+ steps: subSteps
256
+ }
257
+ };
258
+ }
259
+ if ("mockRoute" in step) {
260
+ const mock = step.mockRoute;
261
+ return {
262
+ mockRoute: {
263
+ url: interpolateString(mock.url, vars).value,
264
+ response: {
265
+ status: mock.response.status,
266
+ ...mock.response.contentType !== void 0 ? { contentType: interpolateString(mock.response.contentType, vars).value } : {},
267
+ ...mock.response.body !== void 0 ? { body: interpolateString(mock.response.body, vars).value } : {},
268
+ ...mock.response.file !== void 0 ? { file: interpolateString(mock.response.file, vars).value } : {}
269
+ }
270
+ }
271
+ };
272
+ }
273
+ if ("unmockRoute" in step) {
274
+ if (typeof step.unmockRoute === "string") {
275
+ return { unmockRoute: interpolateString(step.unmockRoute, vars).value };
276
+ }
277
+ return {
278
+ unmockRoute: { url: interpolateString(step.unmockRoute.url, vars).value }
279
+ };
280
+ }
281
+ if ("evalScript" in step) {
282
+ if (typeof step.evalScript === "string") {
283
+ return { evalScript: interpolateString(step.evalScript, vars).value };
284
+ }
285
+ return {
286
+ evalScript: {
287
+ expression: interpolateString(step.evalScript.expression, vars).value,
288
+ ...step.evalScript.as !== void 0 ? { as: step.evalScript.as } : {}
289
+ }
290
+ };
291
+ }
292
+ if ("runScript" in step) {
293
+ return {
294
+ runScript: { file: interpolateString(step.runScript.file, vars).value }
295
+ };
296
+ }
297
+ if ("assertScreenshot" in step) {
298
+ return {
299
+ assertScreenshot: {
300
+ name: interpolateString(step.assertScreenshot.name, vars).value,
301
+ ...step.assertScreenshot.threshold !== void 0 ? { threshold: step.assertScreenshot.threshold } : {}
302
+ }
303
+ };
304
+ }
305
+ if ("copyText" in step) {
306
+ return {
307
+ copyText: {
308
+ selector: interpolateString(step.copyText.selector, vars).value,
309
+ as: step.copyText.as
310
+ }
311
+ };
312
+ }
313
+ if ("waitForDownload" in step) {
314
+ if (step.waitForDownload === null) {
315
+ return { waitForDownload: null };
316
+ }
317
+ return {
318
+ waitForDownload: {
319
+ ...step.waitForDownload.filename !== void 0 ? { filename: interpolateString(step.waitForDownload.filename, vars).value } : {},
320
+ ...step.waitForDownload.timeout !== void 0 ? { timeout: step.waitForDownload.timeout } : {}
321
+ }
322
+ };
323
+ }
324
+ return step;
325
+ }
326
+ function interpolateAssertion(assertion, vars) {
327
+ if ("selectorExists" in assertion) {
328
+ return { selectorExists: interpolateString(assertion.selectorExists, vars).value };
329
+ }
330
+ if ("selectorNotExists" in assertion) {
331
+ return { selectorNotExists: interpolateString(assertion.selectorNotExists, vars).value };
332
+ }
333
+ if ("urlIncludes" in assertion) {
334
+ return { urlIncludes: interpolateString(assertion.urlIncludes, vars).value };
335
+ }
336
+ if ("urlEquals" in assertion) {
337
+ return { urlEquals: interpolateString(assertion.urlEquals, vars).value };
338
+ }
339
+ if ("noConsoleErrors" in assertion) {
340
+ return { noConsoleErrors: assertion.noConsoleErrors };
341
+ }
342
+ if ("noNetworkErrors" in assertion) {
343
+ return { noNetworkErrors: assertion.noNetworkErrors };
344
+ }
345
+ return assertion;
346
+ }
347
+ function interpolateHunt(hunt, env, randomVars = generateRandomVars()) {
348
+ const redactedFillSteps = /* @__PURE__ */ new Set();
349
+ const envVars = Object.fromEntries(
350
+ Object.entries(env).filter(([, value]) => value !== void 0)
351
+ );
352
+ const baseVars = { ...randomVars, ...envVars };
353
+ const resolvedHuntVars = {};
354
+ for (const [key, value] of Object.entries(hunt.vars ?? {})) {
355
+ resolvedHuntVars[key] = interpolateString(value, baseVars).value;
356
+ }
357
+ const vars = { ...baseVars, ...resolvedHuntVars };
358
+ const redactionValues = /* @__PURE__ */ new Set();
359
+ collectInterpolatedValues(hunt.steps, vars, redactionValues);
360
+ collectInterpolatedValues(hunt.assertions, vars, redactionValues);
361
+ const steps = hunt.steps.map(
362
+ (step, index) => interpolateStep(step, vars, `${index}`, redactedFillSteps)
363
+ );
364
+ const assertions = hunt.assertions?.map((assertion) => interpolateAssertion(assertion, vars));
365
+ return {
366
+ hunt: {
367
+ ...hunt,
368
+ steps,
369
+ assertions
370
+ },
371
+ redactedFillSteps,
372
+ randomVars,
373
+ redactionValues: [...redactionValues]
374
+ };
375
+ }
376
+
377
+ // src/runner/healing.ts
378
+ var INTERACTIVE_TAGS = ["button", "a", "input", "select", "textarea"];
379
+ function extractSelectorIntent(selector) {
380
+ const raw = [];
381
+ for (const match of selector.matchAll(/[#.]([A-Za-z_][\w-]*)/g)) {
382
+ raw.push(match[1]);
383
+ }
384
+ for (const match of selector.matchAll(/\[[A-Za-z_:-]+\s*[~|^$*]?=\s*(?:"([^"]*)"|'([^']*)'|([^\]\s]+))\]/g)) {
385
+ const value = match[1] ?? match[2] ?? match[3];
386
+ if (value) raw.push(value);
387
+ }
388
+ const words = [];
389
+ for (const token of raw) {
390
+ for (const part of splitToken(token)) {
391
+ const lower = part.toLowerCase();
392
+ if (lower.length > 0 && !words.includes(lower)) {
393
+ words.push(lower);
394
+ }
395
+ }
396
+ }
397
+ return { words, label: words.join(" ") };
398
+ }
399
+ function splitToken(token) {
400
+ return token.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[\s\-_.:]+/).filter((part) => part.length > 0);
401
+ }
402
+ function buildHealCandidates(selector) {
403
+ const { words, label } = extractSelectorIntent(selector);
404
+ if (words.length === 0) return [];
405
+ const escaped = label.replace(/"/g, '\\"');
406
+ const candidates = [];
407
+ candidates.push({ selector: `text=${label}`, strategy: "text" });
408
+ candidates.push({ selector: `[aria-label*="${escaped}" i]`, strategy: "aria" });
409
+ for (const tag of INTERACTIVE_TAGS) {
410
+ candidates.push({ selector: `${tag}:has-text("${escaped}")`, strategy: "structural" });
411
+ }
412
+ return candidates;
413
+ }
414
+ async function healSelector(page, selector, options) {
415
+ if (!options.enabled) return null;
416
+ for (const candidate of buildHealCandidates(selector)) {
417
+ let count;
418
+ try {
419
+ const locator = page.locator(candidate.selector);
420
+ count = await locator.count();
421
+ } catch {
422
+ continue;
423
+ }
424
+ if (count === 1) {
425
+ return { selector: candidate.selector, healedFrom: selector, strategy: candidate.strategy };
426
+ }
427
+ }
428
+ return null;
429
+ }
430
+
431
+ // src/runner/history.ts
432
+ import fs from "fs";
433
+ import path from "path";
434
+ var HISTORY_FILE = "history.json";
435
+ var LOCK_FILE_SUFFIX = ".lock";
436
+ var LOCK_RETRY_MS = 10;
437
+ var LOCK_TIMEOUT_MS = 5e3;
438
+ var SLEEP_BUFFER = new Int32Array(new SharedArrayBuffer(4));
439
+ function historyPath(configDir) {
440
+ return path.join(configDir, HISTORY_FILE);
441
+ }
442
+ function isHistoryEntry(value) {
443
+ if (!value || typeof value !== "object") {
444
+ return false;
445
+ }
446
+ const entry = value;
447
+ return typeof entry.hunt === "string" && (entry.status === "pass" || entry.status === "fail") && typeof entry.durationMs === "number" && Number.isFinite(entry.durationMs) && typeof entry.startedAt === "string" && (entry.runDir === void 0 || typeof entry.runDir === "string");
448
+ }
449
+ function readHistory(configDir) {
450
+ const filePath = historyPath(configDir);
451
+ if (!fs.existsSync(filePath)) {
452
+ return { entries: [] };
453
+ }
454
+ try {
455
+ const raw = fs.readFileSync(filePath, "utf-8");
456
+ const parsed = JSON.parse(raw);
457
+ if (parsed && typeof parsed === "object" && "entries" in parsed && Array.isArray(parsed.entries)) {
458
+ const validatedEntries = parsed.entries.filter(isHistoryEntry);
459
+ return { entries: validatedEntries };
460
+ }
461
+ return { entries: [] };
462
+ } catch (error) {
463
+ const message = error instanceof Error ? error.message : String(error);
464
+ console.warn(`Failed to read history file at ${filePath}: ${message}`);
465
+ return { entries: [] };
466
+ }
467
+ }
468
+ function readHuntHistory(configDir, huntName) {
469
+ const { entries } = readHistory(configDir);
470
+ return entries.filter((entry) => entry.hunt === huntName);
471
+ }
472
+ function pruneEntries(entries, maxRuns) {
473
+ const perHunt = /* @__PURE__ */ new Map();
474
+ for (const entry of entries) {
475
+ const list = perHunt.get(entry.hunt) ?? [];
476
+ list.push(entry);
477
+ perHunt.set(entry.hunt, list);
478
+ }
479
+ const keptEntries = /* @__PURE__ */ new Set();
480
+ for (const list of perHunt.values()) {
481
+ const kept = list.length > maxRuns ? list.slice(list.length - maxRuns) : list;
482
+ for (const entry of kept) {
483
+ keptEntries.add(entry);
484
+ }
485
+ }
486
+ return entries.filter((entry) => keptEntries.has(entry));
487
+ }
488
+ function sleepSync(ms) {
489
+ Atomics.wait(SLEEP_BUFFER, 0, 0, ms);
490
+ }
491
+ function withHistoryLock(configDir, fn) {
492
+ const filePath = historyPath(configDir);
493
+ const lockPath = `${filePath}${LOCK_FILE_SUFFIX}`;
494
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
495
+ const startedAt = Date.now();
496
+ while (Date.now() - startedAt < LOCK_TIMEOUT_MS) {
497
+ let fd;
498
+ try {
499
+ fd = fs.openSync(lockPath, "wx");
500
+ } catch (error) {
501
+ if (error.code === "EEXIST") {
502
+ sleepSync(LOCK_RETRY_MS);
503
+ continue;
504
+ }
505
+ throw error;
506
+ }
507
+ try {
508
+ return fn();
509
+ } finally {
510
+ try {
511
+ fs.closeSync(fd);
512
+ } catch {
513
+ }
514
+ fs.rmSync(lockPath, { force: true });
515
+ }
516
+ }
517
+ throw new Error(
518
+ `Failed to acquire history lock before timeout (${LOCK_TIMEOUT_MS}ms): ${lockPath}; started waiting at ${new Date(startedAt).toISOString()}`
519
+ );
520
+ }
521
+ function appendEntry(configDir, entry, maxRuns) {
522
+ const filePath = historyPath(configDir);
523
+ withHistoryLock(configDir, () => {
524
+ const current = readHistory(configDir);
525
+ const next = pruneEntries([...current.entries, entry], maxRuns);
526
+ const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
527
+ fs.writeFileSync(tempPath, `${JSON.stringify({ entries: next }, null, 2)}
528
+ `);
529
+ fs.renameSync(tempPath, filePath);
530
+ });
531
+ }
532
+
533
+ // src/runner/index.ts
534
+ import fs7 from "fs";
535
+ import path7 from "path";
536
+
537
+ // src/browser/controller.ts
538
+ import fs2 from "fs";
539
+ import path2 from "path";
540
+ import { chromium, firefox, webkit } from "playwright";
541
+ var ENGINES = { chromium, firefox, webkit };
542
+ async function launchBrowser(options) {
543
+ const engine = ENGINES[options.engine ?? "chromium"];
544
+ const browser = await engine.launch({
545
+ headless: options.headless,
546
+ slowMo: options.slowMo,
547
+ channel: options.channel
548
+ });
549
+ const contextOptions = {};
550
+ if (options.viewport) {
551
+ contextOptions.viewport = options.viewport;
552
+ }
553
+ if (options.storageStatePath) {
554
+ if (fs2.existsSync(options.storageStatePath)) {
555
+ contextOptions.storageState = options.storageStatePath;
556
+ } else {
557
+ console.warn(`Auth state file not found: ${options.storageStatePath}. Run "prowl login" to create it.`);
558
+ }
559
+ }
560
+ if (options.recordHar) {
561
+ contextOptions.recordHar = { path: path2.join(options.runDir, "network.har") };
562
+ }
563
+ const context = await browser.newContext(contextOptions);
564
+ const page = await context.newPage();
565
+ page.setDefaultTimeout(options.timeout);
566
+ page.setDefaultNavigationTimeout(options.timeout);
567
+ let tracePath;
568
+ if (options.trace) {
569
+ tracePath = path2.join(options.runDir, "trace.zip");
570
+ await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
571
+ }
572
+ return { browser, context, page, tracePath };
573
+ }
574
+ async function closeBrowser(session) {
575
+ if (session.tracePath) {
576
+ await session.context.tracing.stop({ path: session.tracePath });
577
+ }
578
+ await session.context.close();
579
+ await session.browser.close();
580
+ }
581
+
582
+ // src/runner/steps.ts
583
+ import fs3 from "fs";
584
+ import path3 from "path";
585
+
586
+ // src/browser/actions.ts
587
+ async function clickElement(page, selector) {
588
+ await page.locator(selector).click();
589
+ }
590
+ async function fillElement(page, selector, value) {
591
+ await page.locator(selector).fill(value);
592
+ }
593
+ async function pressKey(page, selector, key) {
594
+ await page.locator(selector).press(key);
595
+ }
596
+ async function selectOption(page, selector, value) {
597
+ await page.locator(selector).selectOption(value);
598
+ }
599
+ function setupDialogHandler(page, action) {
600
+ page.once("dialog", async (dialog) => {
601
+ if (action === "accept") {
602
+ await dialog.accept();
603
+ } else {
604
+ await dialog.dismiss();
605
+ }
606
+ });
607
+ }
608
+ async function setInputFiles(page, selector, files) {
609
+ await page.locator(selector).setInputFiles(files);
610
+ }
611
+
612
+ // src/runner/steps.ts
613
+ var ALWAYS_ALLOWED_PROTOCOLS = ["about:", "data:"];
614
+ function unwrapTextSelector(value) {
615
+ const trimmed = value.trim();
616
+ if (trimmed.startsWith('text="') && trimmed.endsWith('"')) {
617
+ return trimmed.slice(6, -1);
618
+ }
619
+ if (trimmed.startsWith("text='") && trimmed.endsWith("'")) {
620
+ return trimmed.slice(6, -1);
621
+ }
622
+ if (trimmed.startsWith("text=")) {
623
+ return trimmed.slice(5);
624
+ }
625
+ return null;
626
+ }
627
+ function matchesForbiddenPattern(selector, forbidden) {
628
+ const selectorText = unwrapTextSelector(selector);
629
+ if (selectorText === null) {
630
+ return false;
631
+ }
632
+ const forbiddenText = unwrapTextSelector(forbidden);
633
+ if (forbiddenText !== null) {
634
+ return selectorText.includes(forbiddenText);
635
+ }
636
+ return selectorText.includes(forbidden);
637
+ }
638
+ function isForbiddenSelector(selector, forbiddenSelectors) {
639
+ return forbiddenSelectors.some(
640
+ (forbidden) => selector.includes(forbidden) || matchesForbiddenPattern(selector, forbidden)
641
+ );
642
+ }
643
+ function assertAllowedSelector(selector, forbiddenSelectors) {
644
+ if (isForbiddenSelector(selector, forbiddenSelectors)) {
645
+ throw new Error(`Forbidden selector: ${selector}`);
646
+ }
647
+ }
648
+ async function resolveActionSelector(context, selector) {
649
+ assertAllowedSelector(selector, context.forbiddenSelectors);
650
+ if (!context.selfHealing) {
651
+ return { selector };
652
+ }
653
+ let matched = false;
654
+ try {
655
+ matched = await context.page.locator(selector).count() > 0;
656
+ } catch {
657
+ return { selector };
658
+ }
659
+ if (matched) {
660
+ return { selector };
661
+ }
662
+ const healed = await healSelector(context.page, selector, { enabled: true });
663
+ if (!healed) {
664
+ return { selector };
665
+ }
666
+ assertAllowedSelector(healed.selector, context.forbiddenSelectors);
667
+ console.warn(
668
+ `Self-healed selector: "${selector}" \u2192 "${healed.selector}" (${healed.strategy}). Update your hunt to use a stable selector.`
669
+ );
670
+ return { selector: healed.selector, healedFrom: healed.healedFrom };
671
+ }
672
+ function assertWithinMaxSteps(stepCount, maxSteps, huntName) {
673
+ if (stepCount > maxSteps) {
674
+ if (huntName) {
675
+ throw new Error(`Hunt "${huntName}" has ${stepCount} steps. Max allowed is ${maxSteps}.`);
676
+ }
677
+ throw new Error(`Hunt has ${stepCount} steps. Max allowed is ${maxSteps}.`);
678
+ }
679
+ }
680
+ function getStepType(step) {
681
+ if ("navigate" in step) return "navigate";
682
+ if ("click" in step) return "click";
683
+ if ("fill" in step) return "fill";
684
+ if ("type" in step) return "type";
685
+ if ("selectOption" in step) return "selectOption";
686
+ if ("select" in step) return "select";
687
+ if ("onDialog" in step) return "onDialog";
688
+ if ("setInputFiles" in step) return "setInputFiles";
689
+ if ("runHunt" in step) return "runHunt";
690
+ if ("assert" in step) return "assert";
691
+ if ("press" in step) return "press";
692
+ if ("wait" in step) return "wait";
693
+ if ("waitForSelector" in step) return "waitForSelector";
694
+ if ("waitForUrl" in step) return "waitForUrl";
695
+ if ("waitForNetworkIdle" in step) return "waitForNetworkIdle";
696
+ if ("hover" in step) return "hover";
697
+ if ("scroll" in step) return "scroll";
698
+ if ("scrollTo" in step) return "scrollTo";
699
+ if ("screenshot" in step) return "screenshot";
700
+ if ("if" in step) return "if";
701
+ if ("repeat" in step) return "repeat";
702
+ if ("mockRoute" in step) return "mockRoute";
703
+ if ("unmockRoute" in step) return "unmockRoute";
704
+ if ("evalScript" in step) return "evalScript";
705
+ if ("runScript" in step) return "runScript";
706
+ if ("assertScreenshot" in step) return "assertScreenshot";
707
+ if ("copyText" in step) return "copyText";
708
+ if ("waitForDownload" in step) return "waitForDownload";
709
+ return "step";
710
+ }
711
+ var RUNTIME_VAR_PATTERN = /\{\{([A-Z0-9_]+)\}\}/g;
712
+ function substituteRuntimeVars(input, vars) {
713
+ return input.replace(RUNTIME_VAR_PATTERN, (match, name) => {
714
+ const value = vars.get(name);
715
+ return value !== void 0 ? value : match;
716
+ });
717
+ }
718
+ function applyRuntimeVars(step, vars) {
719
+ const sub = (s) => substituteRuntimeVars(s, vars);
720
+ if ("navigate" in step) return { navigate: sub(step.navigate) };
721
+ if ("click" in step) {
722
+ if (typeof step.click === "string") return { click: sub(step.click) };
723
+ return { click: { selector: sub(step.click.selector) } };
724
+ }
725
+ if ("fill" in step) {
726
+ if ("selector" in step.fill && "value" in step.fill) {
727
+ const f = step.fill;
728
+ return { fill: { selector: sub(f.selector), value: sub(f.value) } };
729
+ }
730
+ const [key, value] = Object.entries(step.fill)[0];
731
+ return { fill: { [sub(key)]: sub(value) } };
732
+ }
733
+ if ("type" in step) return { type: sub(step.type) };
734
+ if ("assert" in step) {
735
+ const a = step.assert;
736
+ if (a.visible !== void 0) return { assert: { visible: sub(a.visible) } };
737
+ if (a.notVisible !== void 0) return { assert: { notVisible: sub(a.notVisible) } };
738
+ if (a.urlIncludes !== void 0) return { assert: { urlIncludes: sub(a.urlIncludes) } };
739
+ if (a.urlEquals !== void 0) return { assert: { urlEquals: sub(a.urlEquals) } };
740
+ return step;
741
+ }
742
+ if ("wait" in step) {
743
+ if (typeof step.wait === "string") return { wait: sub(step.wait) };
744
+ return { wait: { for: sub(step.wait.for), timeout: step.wait.timeout } };
745
+ }
746
+ if ("waitForSelector" in step) {
747
+ return { waitForSelector: { selector: sub(step.waitForSelector.selector), timeout: step.waitForSelector.timeout } };
748
+ }
749
+ if ("evalScript" in step) {
750
+ if (typeof step.evalScript === "string") return { evalScript: sub(step.evalScript) };
751
+ return {
752
+ evalScript: {
753
+ expression: sub(step.evalScript.expression),
754
+ ...step.evalScript.as !== void 0 ? { as: step.evalScript.as } : {}
755
+ }
756
+ };
757
+ }
758
+ if ("assertScreenshot" in step) {
759
+ return {
760
+ assertScreenshot: {
761
+ name: sub(step.assertScreenshot.name),
762
+ ...step.assertScreenshot.threshold !== void 0 ? { threshold: step.assertScreenshot.threshold } : {}
763
+ }
764
+ };
765
+ }
766
+ if ("copyText" in step) {
767
+ return { copyText: { selector: sub(step.copyText.selector), as: step.copyText.as } };
768
+ }
769
+ if ("waitForDownload" in step) {
770
+ if (step.waitForDownload === null) return step;
771
+ return {
772
+ waitForDownload: {
773
+ ...step.waitForDownload.filename !== void 0 ? { filename: sub(step.waitForDownload.filename) } : {},
774
+ ...step.waitForDownload.timeout !== void 0 ? { timeout: step.waitForDownload.timeout } : {}
775
+ }
776
+ };
777
+ }
778
+ return step;
779
+ }
780
+ function isExplicitFillStep(value) {
781
+ return typeof value.selector === "string" && typeof value.value === "string";
782
+ }
783
+ function ensureAllowedUrl(urlValue, allowedDomains) {
784
+ for (const protocol of ALWAYS_ALLOWED_PROTOCOLS) {
785
+ if (urlValue.startsWith(protocol)) {
786
+ return;
787
+ }
788
+ }
789
+ const url = new URL(urlValue);
790
+ if (!allowedDomains.includes(url.hostname)) {
791
+ throw new Error(`Navigation to disallowed domain: ${url.hostname}`);
792
+ }
793
+ }
794
+ function resolveNavigationTarget(targetUrl, value) {
795
+ try {
796
+ return new URL(value, targetUrl).toString();
797
+ } catch {
798
+ return value;
799
+ }
800
+ }
801
+ function escapeForText(value) {
802
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
803
+ }
804
+ function escapeForAttribute(value) {
805
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
806
+ }
807
+ function exactTextSelector(text) {
808
+ return `text="${escapeForText(text)}"`;
809
+ }
810
+ function textContainsSelector(text) {
811
+ return `text=${escapeForText(text)}`;
812
+ }
813
+ function getSinglePair(value, stepType) {
814
+ const entries = Object.entries(value);
815
+ if (entries.length !== 1) {
816
+ throw new Error(`${stepType} shorthand expects exactly one key-value pair`);
817
+ }
818
+ return entries[0];
819
+ }
820
+ async function clickByTextWithFallback(page, text, forbiddenSelectors) {
821
+ const roleSelector = `role=button[name="${escapeForAttribute(text)}"]`;
822
+ assertAllowedSelector(roleSelector, forbiddenSelectors);
823
+ const button = page.getByRole("button", { name: text });
824
+ if (await button.count()) {
825
+ await button.first().click();
826
+ return roleSelector;
827
+ }
828
+ const selector = exactTextSelector(text);
829
+ assertAllowedSelector(selector, forbiddenSelectors);
830
+ await page.locator(selector).first().click();
831
+ return selector;
832
+ }
833
+ async function fillByLabelOrPlaceholder(page, label, value, forbiddenSelectors) {
834
+ const labelSelector = `label="${escapeForAttribute(label)}"`;
835
+ assertAllowedSelector(labelSelector, forbiddenSelectors);
836
+ const byLabel = page.getByLabel(label, { exact: true });
837
+ if (await byLabel.count()) {
838
+ await byLabel.first().fill(value);
839
+ return labelSelector;
840
+ }
841
+ const placeholder = `input[placeholder="${escapeForAttribute(label)}"], textarea[placeholder="${escapeForAttribute(label)}"]`;
842
+ assertAllowedSelector(placeholder, forbiddenSelectors);
843
+ const byPlaceholder = page.locator(placeholder);
844
+ if (await byPlaceholder.count()) {
845
+ await byPlaceholder.first().fill(value);
846
+ return placeholder;
847
+ }
848
+ throw new Error(`Could not resolve fill shorthand for "${label}"`);
849
+ }
850
+ async function selectByLabelOrFallback(page, label, value, forbiddenSelectors) {
851
+ const labelSelector = `label="${escapeForAttribute(label)}"`;
852
+ assertAllowedSelector(labelSelector, forbiddenSelectors);
853
+ const byLabel = page.getByLabel(label, { exact: true });
854
+ if (await byLabel.count()) {
855
+ await byLabel.first().selectOption(value);
856
+ return labelSelector;
857
+ }
858
+ const ariaSelector = `select[aria-label="${escapeForAttribute(label)}"]`;
859
+ assertAllowedSelector(ariaSelector, forbiddenSelectors);
860
+ const byAria = page.locator(ariaSelector);
861
+ if (await byAria.count()) {
862
+ await byAria.first().selectOption(value);
863
+ return ariaSelector;
864
+ }
865
+ const placeholderSelector = `select[placeholder="${escapeForAttribute(label)}"]`;
866
+ assertAllowedSelector(placeholderSelector, forbiddenSelectors);
867
+ const byPlaceholder = page.locator(placeholderSelector);
868
+ if (await byPlaceholder.count()) {
869
+ await byPlaceholder.first().selectOption(value);
870
+ return placeholderSelector;
871
+ }
872
+ throw new Error(`Could not resolve select shorthand for "${label}"`);
873
+ }
874
+ var SELECTOR_ENGINE_PREFIX = /^(?:css|xpath|text|id|role|data-testid)=/i;
875
+ var HTML_TYPE_SELECTORS = /* @__PURE__ */ new Set([
876
+ "a",
877
+ "article",
878
+ "aside",
879
+ "body",
880
+ "button",
881
+ "canvas",
882
+ "dialog",
883
+ "div",
884
+ "fieldset",
885
+ "footer",
886
+ "form",
887
+ "h1",
888
+ "h2",
889
+ "h3",
890
+ "h4",
891
+ "h5",
892
+ "h6",
893
+ "header",
894
+ "html",
895
+ "iframe",
896
+ "img",
897
+ "input",
898
+ "label",
899
+ "li",
900
+ "main",
901
+ "nav",
902
+ "ol",
903
+ "option",
904
+ "p",
905
+ "section",
906
+ "select",
907
+ "span",
908
+ "table",
909
+ "tbody",
910
+ "td",
911
+ "textarea",
912
+ "th",
913
+ "thead",
914
+ "tr",
915
+ "ul"
916
+ ]);
917
+ function isKnownCssTypeSelector(value) {
918
+ return value === "*" || value.includes("-") || HTML_TYPE_SELECTORS.has(value.toLowerCase());
919
+ }
920
+ function readCssTypeSelector(value, start) {
921
+ const match = /^(?:[A-Za-z][\w-]*|\*)/.exec(value.slice(start));
922
+ if (!match) return null;
923
+ return { end: start + match[0].length, isKnown: isKnownCssTypeSelector(match[0]) };
924
+ }
925
+ function readCssStructuralSelectorPart(value, start) {
926
+ const rest = value.slice(start);
927
+ const classOrId = /^[.#][A-Za-z_][\w-]*/.exec(rest);
928
+ if (classOrId) return start + classOrId[0].length;
929
+ const attribute = /^\[[A-Za-z_][\w:-]*(?:\s*(?:[~|^$*]?=)\s*(?:"[^"]*"|'[^']*'|[^\]\s]+))?\]/.exec(rest);
930
+ if (attribute) return start + attribute[0].length;
931
+ return null;
932
+ }
933
+ function readCssCompoundSelector(value, start) {
934
+ let cursor = start;
935
+ const type = readCssTypeSelector(value, cursor);
936
+ if (type) {
937
+ cursor = type.end;
938
+ }
939
+ let hasStructuralPart = false;
940
+ for (; ; ) {
941
+ const next = readCssStructuralSelectorPart(value, cursor);
942
+ if (next === null) break;
943
+ hasStructuralPart = true;
944
+ cursor = next;
945
+ }
946
+ if (cursor === start) return null;
947
+ if (type && !type.isKnown) return null;
948
+ return { end: cursor, hasStructuralPart };
949
+ }
950
+ function readCssSelectorSeparator(value, start) {
951
+ let cursor = start;
952
+ let sawWhitespace = false;
953
+ while (/\s/.test(value[cursor] ?? "")) {
954
+ sawWhitespace = true;
955
+ cursor += 1;
956
+ }
957
+ if (/[>+~]/.test(value[cursor] ?? "")) {
958
+ cursor += 1;
959
+ while (/\s/.test(value[cursor] ?? "")) {
960
+ cursor += 1;
961
+ }
962
+ return cursor;
963
+ }
964
+ return sawWhitespace ? cursor : null;
965
+ }
966
+ function isCssSelectorSequence(value) {
967
+ const first = readCssCompoundSelector(value, 0);
968
+ if (!first) return false;
969
+ let cursor = first.end;
970
+ let sawSeparator = false;
971
+ let hasStructuralPart = first.hasStructuralPart;
972
+ while (cursor < value.length) {
973
+ const afterSeparator = readCssSelectorSeparator(value, cursor);
974
+ if (afterSeparator === null) return false;
975
+ const next = readCssCompoundSelector(value, afterSeparator);
976
+ if (!next) return false;
977
+ sawSeparator = true;
978
+ hasStructuralPart = hasStructuralPart || next.hasStructuralPart;
979
+ cursor = next.end;
980
+ }
981
+ return sawSeparator && hasStructuralPart;
982
+ }
983
+ function looksLikeSelector(value) {
984
+ const trimmed = value.trim();
985
+ if (trimmed.length === 0) return false;
986
+ if (SELECTOR_ENGINE_PREFIX.test(trimmed) || trimmed.startsWith("//")) return true;
987
+ if (/^[.#]/.test(trimmed)) return true;
988
+ const compound = readCssCompoundSelector(trimmed, 0);
989
+ if (compound?.end === trimmed.length && compound.hasStructuralPart) return true;
990
+ if (isCssSelectorSequence(trimmed)) return true;
991
+ return false;
992
+ }
993
+ function toVisibilitySelector(value) {
994
+ if (looksLikeSelector(value)) return value;
995
+ return textContainsSelector(value);
996
+ }
997
+ async function runInlineAssert(page, assertion, forbiddenSelectors) {
998
+ if (assertion.visible !== void 0) {
999
+ const selector = toVisibilitySelector(assertion.visible);
1000
+ assertAllowedSelector(selector, forbiddenSelectors);
1001
+ const count = await page.locator(selector).count();
1002
+ if (count === 0) {
1003
+ throw new Error(`Expected visible: ${assertion.visible}`);
1004
+ }
1005
+ return `visible:${assertion.visible}`;
1006
+ }
1007
+ if (assertion.notVisible !== void 0) {
1008
+ const selector = toVisibilitySelector(assertion.notVisible);
1009
+ assertAllowedSelector(selector, forbiddenSelectors);
1010
+ const count = await page.locator(selector).count();
1011
+ if (count > 0) {
1012
+ throw new Error(`Expected not visible: ${assertion.notVisible}`);
1013
+ }
1014
+ return `notVisible:${assertion.notVisible}`;
1015
+ }
1016
+ if (assertion.urlIncludes !== void 0) {
1017
+ const current = page.url();
1018
+ if (!current.includes(assertion.urlIncludes)) {
1019
+ throw new Error(`URL did not include ${assertion.urlIncludes}`);
1020
+ }
1021
+ return `urlIncludes:${assertion.urlIncludes}`;
1022
+ }
1023
+ if (assertion.urlEquals !== void 0) {
1024
+ const current = page.url();
1025
+ if (current !== assertion.urlEquals) {
1026
+ throw new Error(`URL did not equal ${assertion.urlEquals}`);
1027
+ }
1028
+ return `urlEquals:${assertion.urlEquals}`;
1029
+ }
1030
+ throw new Error("assert step is missing an assertion type");
1031
+ }
1032
+ function screenshotPath(screenshotsDir, fileName) {
1033
+ return path3.join(screenshotsDir, fileName);
1034
+ }
1035
+ function stepPath(prefix, index) {
1036
+ return prefix ? `${prefix}.${index}` : `${index}`;
1037
+ }
1038
+ function isWaitForDownloadStep(step) {
1039
+ return step !== void 0 && "waitForDownload" in step;
1040
+ }
1041
+ function armDownloadListener(page, timeout) {
1042
+ const downloadPromise = page.waitForEvent("download", { timeout });
1043
+ void downloadPromise.catch(() => void 0);
1044
+ return downloadPromise;
1045
+ }
1046
+ function validateDownloadFilename(suggestedFilename) {
1047
+ const safeFilename = suggestedFilename.trim();
1048
+ const allowedFilenamePattern = /^[^<>:"/\\|?*]+$/;
1049
+ const hasControlCharacter = Array.from(safeFilename).some((char) => char.charCodeAt(0) < 32);
1050
+ if (safeFilename.length === 0 || safeFilename !== suggestedFilename || safeFilename !== path3.basename(safeFilename) || safeFilename.includes("..") || /[/\\]/.test(safeFilename) || hasControlCharacter || !allowedFilenamePattern.test(safeFilename)) {
1051
+ throw new Error(`Invalid download filename: "${suggestedFilename}"`);
1052
+ }
1053
+ return safeFilename;
1054
+ }
1055
+ async function captureScreenshot(page, filePath) {
1056
+ try {
1057
+ await page.screenshot({ path: filePath, fullPage: true });
1058
+ } catch (error) {
1059
+ const message = error instanceof Error ? error.message : "Screenshot failed";
1060
+ throw new Error(`Failed to capture screenshot at ${filePath}: ${message}`);
1061
+ }
1062
+ }
1063
+ async function executeNestedSteps(context, overrides) {
1064
+ const nestedContext = {
1065
+ ...context,
1066
+ ...overrides,
1067
+ pendingDownload: context.pendingDownload
1068
+ };
1069
+ const result = await executeSteps(nestedContext);
1070
+ context.pendingDownload = nestedContext.pendingDownload;
1071
+ if (nestedContext.randomVars !== void 0) {
1072
+ context.randomVars = nestedContext.randomVars;
1073
+ }
1074
+ return result;
1075
+ }
1076
+ async function executeSteps(context) {
1077
+ const screenshotsDir = path3.join(context.runDir, "screenshots");
1078
+ fs3.mkdirSync(screenshotsDir, { recursive: true });
1079
+ const currentHuntName = context.huntStack?.[context.huntStack.length - 1];
1080
+ assertWithinMaxSteps(context.steps.length, context.maxSteps, currentHuntName);
1081
+ const results = [];
1082
+ const screenshots = [];
1083
+ const runStartedAtMs = context.runStartedAtMs ?? Date.now();
1084
+ context.runStartedAtMs = runStartedAtMs;
1085
+ const addScreenshot = async (fileName) => {
1086
+ const fullPath = screenshotPath(screenshotsDir, fileName);
1087
+ await captureScreenshot(context.page, fullPath);
1088
+ const relative = path3.join("screenshots", fileName);
1089
+ screenshots.push(relative);
1090
+ return relative;
1091
+ };
1092
+ for (let index = 0; index < context.steps.length; index += 1) {
1093
+ const currentStepPath = stepPath(context.stepPathPrefix, index);
1094
+ if (Date.now() - runStartedAtMs > context.maxTotalTimeMs) {
1095
+ results.push({
1096
+ type: "timeout",
1097
+ status: "fail",
1098
+ durationMs: 0,
1099
+ error: `Max total time exceeded (${context.maxTotalTimeMs}ms)`
1100
+ });
1101
+ return { results, screenshots, failed: true, error: "Max total time exceeded" };
1102
+ }
1103
+ const runtimeVars = context.runtimeVars ?? /* @__PURE__ */ new Map();
1104
+ context.runtimeVars = runtimeVars;
1105
+ let step = context.steps[index];
1106
+ if (runtimeVars.size > 0) {
1107
+ step = applyRuntimeVars(step, runtimeVars);
1108
+ }
1109
+ const nextStep = context.steps[index + 1];
1110
+ if (!isWaitForDownloadStep(step) && context.pendingDownload === void 0 && isWaitForDownloadStep(nextStep)) {
1111
+ context.pendingDownload = armDownloadListener(
1112
+ context.page,
1113
+ nextStep.waitForDownload?.timeout ?? 3e4
1114
+ );
1115
+ }
1116
+ const stepStart = Date.now();
1117
+ const stepType = getStepType(step);
1118
+ let stepResult = null;
1119
+ try {
1120
+ if ("navigate" in step) {
1121
+ const destination = resolveNavigationTarget(context.targetUrl, step.navigate);
1122
+ ensureAllowedUrl(destination, context.allowedDomains);
1123
+ await context.page.goto(destination);
1124
+ ensureAllowedUrl(context.page.url(), context.allowedDomains);
1125
+ stepResult = { type: "navigate", status: "pass", durationMs: Date.now() - stepStart };
1126
+ } else if ("click" in step) {
1127
+ let selector;
1128
+ let healedFrom;
1129
+ if (typeof step.click === "string") {
1130
+ selector = await clickByTextWithFallback(
1131
+ context.page,
1132
+ step.click,
1133
+ context.forbiddenSelectors
1134
+ );
1135
+ } else {
1136
+ const resolved = await resolveActionSelector(context, step.click.selector);
1137
+ await clickElement(context.page, resolved.selector);
1138
+ selector = resolved.selector;
1139
+ healedFrom = resolved.healedFrom;
1140
+ }
1141
+ ensureAllowedUrl(context.page.url(), context.allowedDomains);
1142
+ stepResult = {
1143
+ type: "click",
1144
+ status: "pass",
1145
+ durationMs: Date.now() - stepStart,
1146
+ selector,
1147
+ ...healedFrom ? { healedFrom } : {}
1148
+ };
1149
+ } else if ("fill" in step) {
1150
+ let selector;
1151
+ let value;
1152
+ let healedFrom;
1153
+ if (isExplicitFillStep(step.fill)) {
1154
+ const resolved = await resolveActionSelector(context, step.fill.selector);
1155
+ await fillElement(context.page, resolved.selector, step.fill.value);
1156
+ selector = resolved.selector;
1157
+ healedFrom = resolved.healedFrom;
1158
+ value = step.fill.value;
1159
+ } else {
1160
+ const [label, shorthandValue] = getSinglePair(step.fill, "fill");
1161
+ selector = await fillByLabelOrPlaceholder(
1162
+ context.page,
1163
+ label,
1164
+ shorthandValue,
1165
+ context.forbiddenSelectors
1166
+ );
1167
+ value = shorthandValue;
1168
+ }
1169
+ ensureAllowedUrl(context.page.url(), context.allowedDomains);
1170
+ stepResult = {
1171
+ type: "fill",
1172
+ status: "pass",
1173
+ durationMs: Date.now() - stepStart,
1174
+ selector,
1175
+ value: context.redactedFillSteps.has(currentStepPath) ? "[REDACTED]" : value,
1176
+ ...healedFrom ? { healedFrom } : {}
1177
+ };
1178
+ } else if ("type" in step) {
1179
+ assertAllowedSelector(":focus", context.forbiddenSelectors);
1180
+ await fillElement(context.page, ":focus", step.type);
1181
+ ensureAllowedUrl(context.page.url(), context.allowedDomains);
1182
+ stepResult = {
1183
+ type: "type",
1184
+ status: "pass",
1185
+ durationMs: Date.now() - stepStart,
1186
+ selector: ":focus",
1187
+ value: context.redactedFillSteps.has(currentStepPath) ? "[REDACTED]" : step.type
1188
+ };
1189
+ } else if ("selectOption" in step) {
1190
+ const resolved = await resolveActionSelector(context, step.selectOption.selector);
1191
+ await selectOption(context.page, resolved.selector, step.selectOption.value);
1192
+ ensureAllowedUrl(context.page.url(), context.allowedDomains);
1193
+ stepResult = {
1194
+ type: "selectOption",
1195
+ status: "pass",
1196
+ durationMs: Date.now() - stepStart,
1197
+ selector: resolved.selector,
1198
+ value: step.selectOption.value,
1199
+ ...resolved.healedFrom ? { healedFrom: resolved.healedFrom } : {}
1200
+ };
1201
+ } else if ("select" in step) {
1202
+ const [label, value] = getSinglePair(step.select, "select");
1203
+ const selector = await selectByLabelOrFallback(
1204
+ context.page,
1205
+ label,
1206
+ value,
1207
+ context.forbiddenSelectors
1208
+ );
1209
+ ensureAllowedUrl(context.page.url(), context.allowedDomains);
1210
+ stepResult = {
1211
+ type: "select",
1212
+ status: "pass",
1213
+ durationMs: Date.now() - stepStart,
1214
+ selector,
1215
+ value
1216
+ };
1217
+ } else if ("onDialog" in step) {
1218
+ setupDialogHandler(context.page, step.onDialog.action);
1219
+ stepResult = {
1220
+ type: "onDialog",
1221
+ status: "pass",
1222
+ durationMs: Date.now() - stepStart,
1223
+ value: step.onDialog.action
1224
+ };
1225
+ } else if ("setInputFiles" in step) {
1226
+ const resolvedInput = await resolveActionSelector(context, step.setInputFiles.selector);
1227
+ const rawFiles = step.setInputFiles.files;
1228
+ const resolveFile = (f) => path3.isAbsolute(f) ? f : path3.join(context.configDir, f);
1229
+ const resolvedFiles = Array.isArray(rawFiles) ? rawFiles.map(resolveFile) : resolveFile(rawFiles);
1230
+ await setInputFiles(context.page, resolvedInput.selector, resolvedFiles);
1231
+ ensureAllowedUrl(context.page.url(), context.allowedDomains);
1232
+ const filesLabel = Array.isArray(rawFiles) ? rawFiles.join(", ") : rawFiles;
1233
+ stepResult = {
1234
+ type: "setInputFiles",
1235
+ status: "pass",
1236
+ durationMs: Date.now() - stepStart,
1237
+ selector: resolvedInput.selector,
1238
+ ...resolvedInput.healedFrom ? { healedFrom: resolvedInput.healedFrom } : {},
1239
+ value: filesLabel
1240
+ };
1241
+ } else if ("runHunt" in step) {
1242
+ const huntName = typeof step.runHunt === "string" ? step.runHunt : step.runHunt.name;
1243
+ const overrideVars = typeof step.runHunt === "string" ? void 0 : step.runHunt.vars;
1244
+ const stack = context.huntStack ?? [];
1245
+ if (stack.includes(huntName)) {
1246
+ throw new Error(`Circular hunt dependency: ${[...stack, huntName].join(" \u2192 ")}`);
1247
+ }
1248
+ const subHunt = loadHunt(huntName, context.configDir);
1249
+ if (overrideVars) {
1250
+ subHunt.vars = { ...subHunt.vars, ...overrideVars };
1251
+ }
1252
+ const {
1253
+ hunt: interpolatedSubHunt,
1254
+ redactedFillSteps: subRedacted,
1255
+ randomVars
1256
+ } = interpolateHunt(
1257
+ subHunt,
1258
+ process.env,
1259
+ context.randomVars
1260
+ );
1261
+ assertWithinMaxSteps(interpolatedSubHunt.steps.length, context.maxSteps, huntName);
1262
+ const subResult = await executeNestedSteps(context, {
1263
+ steps: interpolatedSubHunt.steps,
1264
+ redactedFillSteps: subRedacted,
1265
+ randomVars,
1266
+ stepPathPrefix: void 0,
1267
+ huntStack: [...stack, huntName],
1268
+ onStep: context.onStep
1269
+ });
1270
+ for (const sr of subResult.results) {
1271
+ results.push({ ...sr, type: `${huntName} > ${sr.type}` });
1272
+ }
1273
+ screenshots.push(...subResult.screenshots);
1274
+ if (subResult.failed) {
1275
+ return {
1276
+ results,
1277
+ screenshots,
1278
+ failed: true,
1279
+ error: `Sub-hunt "${huntName}" failed: ${subResult.error}`
1280
+ };
1281
+ }
1282
+ stepResult = {
1283
+ type: "runHunt",
1284
+ status: "pass",
1285
+ durationMs: Date.now() - stepStart,
1286
+ value: huntName
1287
+ };
1288
+ } else if ("press" in step) {
1289
+ const resolved = await resolveActionSelector(context, step.press.selector);
1290
+ await pressKey(context.page, resolved.selector, step.press.key);
1291
+ ensureAllowedUrl(context.page.url(), context.allowedDomains);
1292
+ stepResult = {
1293
+ type: "press",
1294
+ status: "pass",
1295
+ durationMs: Date.now() - stepStart,
1296
+ selector: resolved.selector,
1297
+ ...resolved.healedFrom ? { healedFrom: resolved.healedFrom } : {}
1298
+ };
1299
+ } else if ("assert" in step) {
1300
+ const value = await runInlineAssert(context.page, step.assert, context.forbiddenSelectors);
1301
+ stepResult = {
1302
+ type: "assert",
1303
+ status: "pass",
1304
+ durationMs: Date.now() - stepStart,
1305
+ value
1306
+ };
1307
+ } else if ("wait" in step) {
1308
+ const text = typeof step.wait === "string" ? step.wait : step.wait.for;
1309
+ const timeout = typeof step.wait === "string" ? void 0 : step.wait.timeout;
1310
+ const selector = `text=${escapeForText(text)}`;
1311
+ assertAllowedSelector(selector, context.forbiddenSelectors);
1312
+ await context.page.waitForSelector(selector, { timeout });
1313
+ stepResult = {
1314
+ type: "wait",
1315
+ status: "pass",
1316
+ durationMs: Date.now() - stepStart,
1317
+ selector
1318
+ };
1319
+ } else if ("waitForSelector" in step) {
1320
+ assertAllowedSelector(step.waitForSelector.selector, context.forbiddenSelectors);
1321
+ await context.page.waitForSelector(step.waitForSelector.selector, {
1322
+ timeout: step.waitForSelector.timeout
1323
+ });
1324
+ stepResult = {
1325
+ type: "waitForSelector",
1326
+ status: "pass",
1327
+ durationMs: Date.now() - stepStart,
1328
+ selector: step.waitForSelector.selector
1329
+ };
1330
+ } else if ("waitForUrl" in step) {
1331
+ await context.page.waitForURL(
1332
+ (url) => url.toString().includes(step.waitForUrl.value),
1333
+ { timeout: step.waitForUrl.timeout }
1334
+ );
1335
+ ensureAllowedUrl(context.page.url(), context.allowedDomains);
1336
+ stepResult = {
1337
+ type: "waitForUrl",
1338
+ status: "pass",
1339
+ durationMs: Date.now() - stepStart,
1340
+ value: step.waitForUrl.value
1341
+ };
1342
+ } else if ("waitForNetworkIdle" in step) {
1343
+ await context.page.waitForLoadState("networkidle", {
1344
+ timeout: step.waitForNetworkIdle.timeout
1345
+ });
1346
+ stepResult = {
1347
+ type: "waitForNetworkIdle",
1348
+ status: "pass",
1349
+ durationMs: Date.now() - stepStart
1350
+ };
1351
+ } else if ("hover" in step) {
1352
+ const resolved = await resolveActionSelector(context, step.hover.selector);
1353
+ await context.page.locator(resolved.selector).hover();
1354
+ ensureAllowedUrl(context.page.url(), context.allowedDomains);
1355
+ stepResult = {
1356
+ type: "hover",
1357
+ status: "pass",
1358
+ durationMs: Date.now() - stepStart,
1359
+ selector: resolved.selector,
1360
+ ...resolved.healedFrom ? { healedFrom: resolved.healedFrom } : {}
1361
+ };
1362
+ } else if ("scroll" in step) {
1363
+ const amount = step.scroll.amount ?? 500;
1364
+ const scrollMap = {
1365
+ up: [0, -amount],
1366
+ down: [0, amount],
1367
+ left: [-amount, 0],
1368
+ right: [amount, 0]
1369
+ };
1370
+ const [x, y] = scrollMap[step.scroll.direction];
1371
+ await context.page.evaluate(([sx, sy]) => window.scrollBy(sx, sy), [x, y]);
1372
+ stepResult = {
1373
+ type: "scroll",
1374
+ status: "pass",
1375
+ durationMs: Date.now() - stepStart,
1376
+ value: `${step.scroll.direction} ${amount}px`
1377
+ };
1378
+ } else if ("scrollTo" in step) {
1379
+ const resolved = await resolveActionSelector(context, step.scrollTo.selector);
1380
+ await context.page.locator(resolved.selector).scrollIntoViewIfNeeded();
1381
+ stepResult = {
1382
+ type: "scrollTo",
1383
+ status: "pass",
1384
+ durationMs: Date.now() - stepStart,
1385
+ selector: resolved.selector,
1386
+ ...resolved.healedFrom ? { healedFrom: resolved.healedFrom } : {}
1387
+ };
1388
+ } else if ("screenshot" in step) {
1389
+ const name = step.screenshot.name ?? `manual_step_${index + 1}.png`;
1390
+ if (/[/\\]|\.\./.test(name)) {
1391
+ throw new Error(`Invalid screenshot name: "${name}" must not contain path separators or ".."`);
1392
+ }
1393
+ const fileName = name.endsWith(".png") ? name : `${name}.png`;
1394
+ const relative = await addScreenshot(fileName);
1395
+ stepResult = {
1396
+ type: "screenshot",
1397
+ status: "pass",
1398
+ durationMs: Date.now() - stepStart,
1399
+ screenshot: relative
1400
+ };
1401
+ } else if ("if" in step) {
1402
+ const condition = step.if;
1403
+ const selector = condition.visible ?? condition.notVisible;
1404
+ assertAllowedSelector(selector, context.forbiddenSelectors);
1405
+ const count = await context.page.locator(selector).count();
1406
+ const conditionMet = condition.visible !== void 0 ? count > 0 : count === 0;
1407
+ if (conditionMet) {
1408
+ const subResult = await executeNestedSteps(context, {
1409
+ steps: condition.then,
1410
+ stepPathPrefix: `${currentStepPath}.if.then`
1411
+ });
1412
+ for (const sr of subResult.results) {
1413
+ results.push({ ...sr, type: `if > ${sr.type}` });
1414
+ }
1415
+ screenshots.push(...subResult.screenshots);
1416
+ if (subResult.failed) {
1417
+ return {
1418
+ results,
1419
+ screenshots,
1420
+ failed: true,
1421
+ error: subResult.error
1422
+ };
1423
+ }
1424
+ stepResult = {
1425
+ type: "if",
1426
+ status: "pass",
1427
+ durationMs: Date.now() - stepStart,
1428
+ value: `condition met, executed ${condition.then.length} steps`
1429
+ };
1430
+ } else {
1431
+ if (condition.else && condition.else.length > 0) {
1432
+ const subResult = await executeNestedSteps(context, {
1433
+ steps: condition.else,
1434
+ stepPathPrefix: `${currentStepPath}.if.else`
1435
+ });
1436
+ for (const sr of subResult.results) {
1437
+ results.push({ ...sr, type: `if > ${sr.type}` });
1438
+ }
1439
+ screenshots.push(...subResult.screenshots);
1440
+ if (subResult.failed) {
1441
+ return {
1442
+ results,
1443
+ screenshots,
1444
+ failed: true,
1445
+ error: subResult.error
1446
+ };
1447
+ }
1448
+ stepResult = {
1449
+ type: "if",
1450
+ status: "pass",
1451
+ durationMs: Date.now() - stepStart,
1452
+ value: `condition not met, executed ${condition.else.length} else steps`
1453
+ };
1454
+ } else {
1455
+ stepResult = {
1456
+ type: "if",
1457
+ status: "pass",
1458
+ durationMs: Date.now() - stepStart,
1459
+ value: "condition not met, skipped"
1460
+ };
1461
+ }
1462
+ }
1463
+ } else if ("repeat" in step) {
1464
+ const repeat = step.repeat;
1465
+ let totalSubSteps = 0;
1466
+ if (repeat.times !== void 0) {
1467
+ const totalPlanned = repeat.times * repeat.steps.length;
1468
+ if (totalPlanned + totalSubSteps > context.maxSteps) {
1469
+ throw new Error(`Repeat exceeded maxSteps guardrail (${context.maxSteps})`);
1470
+ }
1471
+ for (let i = 0; i < repeat.times; i++) {
1472
+ totalSubSteps += repeat.steps.length;
1473
+ const subResult = await executeNestedSteps(context, {
1474
+ steps: repeat.steps,
1475
+ stepPathPrefix: `${currentStepPath}.repeat.steps`
1476
+ });
1477
+ for (const sr of subResult.results) {
1478
+ results.push({ ...sr, type: `repeat[${i}] > ${sr.type}` });
1479
+ }
1480
+ screenshots.push(...subResult.screenshots);
1481
+ if (subResult.failed) {
1482
+ return {
1483
+ results,
1484
+ screenshots,
1485
+ failed: true,
1486
+ error: subResult.error
1487
+ };
1488
+ }
1489
+ }
1490
+ } else if (repeat.while !== void 0) {
1491
+ const maxIter = repeat.maxIterations;
1492
+ const whileSelector = repeat.while.visible ?? repeat.while.notVisible;
1493
+ assertAllowedSelector(whileSelector, context.forbiddenSelectors);
1494
+ for (let i = 0; i < maxIter; i++) {
1495
+ const whileCount = await context.page.locator(whileSelector).count();
1496
+ const shouldContinue = repeat.while.visible !== void 0 ? whileCount > 0 : whileCount === 0;
1497
+ if (!shouldContinue) break;
1498
+ totalSubSteps += repeat.steps.length;
1499
+ if (totalSubSteps > context.maxSteps) {
1500
+ throw new Error(`Repeat exceeded maxSteps guardrail (${context.maxSteps})`);
1501
+ }
1502
+ const subResult = await executeNestedSteps(context, {
1503
+ steps: repeat.steps,
1504
+ stepPathPrefix: `${currentStepPath}.repeat.steps`
1505
+ });
1506
+ for (const sr of subResult.results) {
1507
+ results.push({ ...sr, type: `repeat[${i}] > ${sr.type}` });
1508
+ }
1509
+ screenshots.push(...subResult.screenshots);
1510
+ if (subResult.failed) {
1511
+ return {
1512
+ results,
1513
+ screenshots,
1514
+ failed: true,
1515
+ error: subResult.error
1516
+ };
1517
+ }
1518
+ }
1519
+ }
1520
+ stepResult = {
1521
+ type: "repeat",
1522
+ status: "pass",
1523
+ durationMs: Date.now() - stepStart
1524
+ };
1525
+ } else if ("mockRoute" in step) {
1526
+ const mock = step.mockRoute;
1527
+ const mocks = context.activeMocks ?? /* @__PURE__ */ new Map();
1528
+ context.activeMocks = mocks;
1529
+ let responseBody;
1530
+ if (mock.response.body !== void 0) {
1531
+ responseBody = mock.response.body;
1532
+ } else {
1533
+ const responseFile = mock.response.file;
1534
+ if (!responseFile) {
1535
+ throw new Error("mock.response must include either body or file");
1536
+ }
1537
+ const candidateFilePath = path3.isAbsolute(responseFile) ? responseFile : path3.join(context.configDir, responseFile);
1538
+ const resolvedConfigDir = path3.resolve(context.configDir);
1539
+ const resolvedFilePath = path3.resolve(candidateFilePath);
1540
+ const relativePath = path3.relative(resolvedConfigDir, resolvedFilePath);
1541
+ const isWithinConfigDir = relativePath === "" || relativePath !== ".." && !relativePath.startsWith(`..${path3.sep}`) && !path3.isAbsolute(relativePath);
1542
+ if (!isWithinConfigDir) {
1543
+ throw new Error("mock.response.file must resolve within config directory");
1544
+ }
1545
+ responseBody = await fs3.promises.readFile(resolvedFilePath, "utf-8");
1546
+ }
1547
+ const contentType = mock.response.contentType ?? "application/json";
1548
+ const status = mock.response.status;
1549
+ await context.page.route(mock.url, (route) => {
1550
+ route.fulfill({
1551
+ status,
1552
+ contentType,
1553
+ body: responseBody
1554
+ });
1555
+ });
1556
+ mocks.set(mock.url, async () => {
1557
+ await context.page.unroute(mock.url);
1558
+ });
1559
+ stepResult = {
1560
+ type: "mockRoute",
1561
+ status: "pass",
1562
+ durationMs: Date.now() - stepStart,
1563
+ value: mock.url
1564
+ };
1565
+ } else if ("unmockRoute" in step) {
1566
+ const url = typeof step.unmockRoute === "string" ? step.unmockRoute : step.unmockRoute.url;
1567
+ const mocks = context.activeMocks;
1568
+ if (!mocks || !mocks.has(url)) {
1569
+ throw new Error(`No active mock for URL: ${url}`);
1570
+ }
1571
+ const cleanup = mocks.get(url);
1572
+ await cleanup();
1573
+ mocks.delete(url);
1574
+ stepResult = {
1575
+ type: "unmockRoute",
1576
+ status: "pass",
1577
+ durationMs: Date.now() - stepStart,
1578
+ value: url
1579
+ };
1580
+ } else if ("evalScript" in step) {
1581
+ const expression = typeof step.evalScript === "string" ? step.evalScript : step.evalScript.expression;
1582
+ const result = await context.page.evaluate(expression);
1583
+ const resultStr = String(result);
1584
+ if (typeof step.evalScript !== "string" && step.evalScript.as) {
1585
+ runtimeVars.set(step.evalScript.as, resultStr);
1586
+ }
1587
+ stepResult = {
1588
+ type: "evalScript",
1589
+ status: "pass",
1590
+ durationMs: Date.now() - stepStart,
1591
+ value: resultStr.length > 200 ? resultStr.slice(0, 200) + "\u2026" : resultStr
1592
+ };
1593
+ } else if ("runScript" in step) {
1594
+ const filePath = path3.isAbsolute(step.runScript.file) ? step.runScript.file : path3.join(context.configDir, step.runScript.file);
1595
+ const fileContents = fs3.readFileSync(filePath, "utf-8");
1596
+ await context.page.evaluate(fileContents);
1597
+ stepResult = {
1598
+ type: "runScript",
1599
+ status: "pass",
1600
+ durationMs: Date.now() - stepStart,
1601
+ value: step.runScript.file
1602
+ };
1603
+ } else if ("assertScreenshot" in step) {
1604
+ const { compareScreenshots, ensureBaselineDir } = await import("./visual-FSARM2JS.js");
1605
+ const name = step.assertScreenshot.name;
1606
+ const threshold = step.assertScreenshot.threshold ?? 0.1;
1607
+ const baselineDir = ensureBaselineDir(context.configDir);
1608
+ const baselinePath = path3.join(baselineDir, `${name}.png`);
1609
+ const currentScreenshotPath = path3.join(context.runDir, "screenshots", `${name}-current.png`);
1610
+ fs3.mkdirSync(path3.dirname(currentScreenshotPath), { recursive: true });
1611
+ await context.page.screenshot({ path: currentScreenshotPath, fullPage: true });
1612
+ screenshots.push(path3.join("screenshots", `${name}-current.png`));
1613
+ if (!fs3.existsSync(baselinePath)) {
1614
+ fs3.copyFileSync(currentScreenshotPath, baselinePath);
1615
+ stepResult = {
1616
+ type: "assertScreenshot",
1617
+ status: "pass",
1618
+ durationMs: Date.now() - stepStart,
1619
+ value: "baseline created"
1620
+ };
1621
+ } else {
1622
+ const diffPath = path3.join(context.runDir, "screenshots", `${name}-diff.png`);
1623
+ const comparison = await compareScreenshots(baselinePath, currentScreenshotPath, diffPath, threshold);
1624
+ if (comparison.match) {
1625
+ stepResult = {
1626
+ type: "assertScreenshot",
1627
+ status: "pass",
1628
+ durationMs: Date.now() - stepStart,
1629
+ value: `diff: ${(comparison.diffPercentage * 100).toFixed(2)}%`
1630
+ };
1631
+ } else {
1632
+ screenshots.push(path3.join("screenshots", `${name}-diff.png`));
1633
+ throw new Error(
1634
+ `Visual regression: ${(comparison.diffPercentage * 100).toFixed(2)}% diff exceeds threshold ${(threshold * 100).toFixed(0)}%`
1635
+ );
1636
+ }
1637
+ }
1638
+ } else if ("copyText" in step) {
1639
+ assertAllowedSelector(step.copyText.selector, context.forbiddenSelectors);
1640
+ const text = await context.page.locator(step.copyText.selector).textContent();
1641
+ if (text === null) {
1642
+ throw new Error(`No text content found for selector: ${step.copyText.selector}`);
1643
+ }
1644
+ runtimeVars.set(step.copyText.as, text);
1645
+ stepResult = {
1646
+ type: "copyText",
1647
+ status: "pass",
1648
+ durationMs: Date.now() - stepStart,
1649
+ selector: step.copyText.selector,
1650
+ value: "[REDACTED]"
1651
+ };
1652
+ } else if ("waitForDownload" in step) {
1653
+ const opts = step.waitForDownload;
1654
+ const downloadPromise = context.pendingDownload ?? armDownloadListener(
1655
+ context.page,
1656
+ opts?.timeout ?? 3e4
1657
+ );
1658
+ context.pendingDownload = void 0;
1659
+ const download = await downloadPromise;
1660
+ const suggestedFilename = validateDownloadFilename(download.suggestedFilename());
1661
+ if (opts?.filename !== void 0 && suggestedFilename !== opts.filename) {
1662
+ throw new Error(
1663
+ `Download filename mismatch: expected "${opts.filename}", got "${suggestedFilename}"`
1664
+ );
1665
+ }
1666
+ const savePath = path3.join(context.runDir, suggestedFilename);
1667
+ await download.saveAs(savePath);
1668
+ stepResult = {
1669
+ type: "waitForDownload",
1670
+ status: "pass",
1671
+ durationMs: Date.now() - stepStart,
1672
+ value: suggestedFilename
1673
+ };
1674
+ }
1675
+ if (!stepResult) {
1676
+ throw new Error("Unknown step type");
1677
+ }
1678
+ if (context.screenshotsMode === "all" && stepResult.type !== "screenshot") {
1679
+ const fileName = `step_${index + 1}.png`;
1680
+ await addScreenshot(fileName);
1681
+ }
1682
+ results.push(stepResult);
1683
+ context.onStep?.(stepResult, step, index);
1684
+ } catch (error) {
1685
+ const message = error instanceof Error ? error.message : "Step failed";
1686
+ stepResult = {
1687
+ type: stepResult?.type ?? stepType,
1688
+ status: "fail",
1689
+ durationMs: Date.now() - stepStart,
1690
+ error: message
1691
+ };
1692
+ if (context.screenshotsMode === "on-failure") {
1693
+ const fileName = `failure_step_${index + 1}.png`;
1694
+ await addScreenshot(fileName);
1695
+ }
1696
+ results.push(stepResult);
1697
+ context.onStep?.(stepResult, step, index);
1698
+ return { results, screenshots, failed: true, error: message };
1699
+ }
1700
+ }
1701
+ return { results, screenshots, failed: false };
1702
+ }
1703
+ async function captureFinalScreenshot(page, runDir) {
1704
+ const screenshotsDir = path3.join(runDir, "screenshots");
1705
+ fs3.mkdirSync(screenshotsDir, { recursive: true });
1706
+ const fileName = "final.png";
1707
+ const filePath = screenshotPath(screenshotsDir, fileName);
1708
+ await captureScreenshot(page, filePath);
1709
+ return path3.join("screenshots", fileName);
1710
+ }
1711
+
1712
+ // src/runner/assertions.ts
1713
+ function shouldIgnoreNetwork(url, patterns) {
1714
+ return patterns.some((pattern) => url.includes(pattern));
1715
+ }
1716
+ function filterNetworkEntries(entries, patterns) {
1717
+ if (patterns.length === 0) {
1718
+ return entries;
1719
+ }
1720
+ return entries.filter((entry) => !shouldIgnoreNetwork(entry.url, patterns));
1721
+ }
1722
+ function mergeAssertions(config, huntAssertions = []) {
1723
+ let noConsoleErrors = config.assertions.noConsoleErrors;
1724
+ let noNetworkErrors = config.assertions.noNetworkErrors;
1725
+ for (const assertion of huntAssertions) {
1726
+ if ("noConsoleErrors" in assertion) {
1727
+ noConsoleErrors = assertion.noConsoleErrors;
1728
+ }
1729
+ if ("noNetworkErrors" in assertion) {
1730
+ noNetworkErrors = assertion.noNetworkErrors;
1731
+ }
1732
+ }
1733
+ const merged = [];
1734
+ if (noConsoleErrors) {
1735
+ merged.push({ noConsoleErrors: true });
1736
+ }
1737
+ if (noNetworkErrors) {
1738
+ merged.push({ noNetworkErrors: true });
1739
+ }
1740
+ for (const assertion of huntAssertions) {
1741
+ if ("noConsoleErrors" in assertion || "noNetworkErrors" in assertion) {
1742
+ continue;
1743
+ }
1744
+ merged.push(assertion);
1745
+ }
1746
+ return merged;
1747
+ }
1748
+ async function evaluateAssertions(options) {
1749
+ const assertions = mergeAssertions(options.config, options.huntAssertions);
1750
+ const results = [];
1751
+ const networkEntries = filterNetworkEntries(
1752
+ options.networkEntries,
1753
+ options.config.assertions.networkIgnorePatterns
1754
+ );
1755
+ for (const assertion of assertions) {
1756
+ try {
1757
+ if ("selectorExists" in assertion) {
1758
+ const count = await options.page.locator(assertion.selectorExists).count();
1759
+ results.push({
1760
+ type: "selectorExists",
1761
+ value: assertion.selectorExists,
1762
+ status: count > 0 ? "pass" : "fail",
1763
+ error: count > 0 ? void 0 : "Selector not found"
1764
+ });
1765
+ continue;
1766
+ }
1767
+ if ("selectorNotExists" in assertion) {
1768
+ const count = await options.page.locator(assertion.selectorNotExists).count();
1769
+ results.push({
1770
+ type: "selectorNotExists",
1771
+ value: assertion.selectorNotExists,
1772
+ status: count === 0 ? "pass" : "fail",
1773
+ error: count === 0 ? void 0 : "Selector exists"
1774
+ });
1775
+ continue;
1776
+ }
1777
+ if ("urlIncludes" in assertion) {
1778
+ const current = options.page.url();
1779
+ const pass = current.includes(assertion.urlIncludes);
1780
+ results.push({
1781
+ type: "urlIncludes",
1782
+ value: assertion.urlIncludes,
1783
+ status: pass ? "pass" : "fail",
1784
+ error: pass ? void 0 : `URL did not include ${assertion.urlIncludes}`
1785
+ });
1786
+ continue;
1787
+ }
1788
+ if ("urlEquals" in assertion) {
1789
+ const current = options.page.url();
1790
+ const pass = current === assertion.urlEquals;
1791
+ results.push({
1792
+ type: "urlEquals",
1793
+ value: assertion.urlEquals,
1794
+ status: pass ? "pass" : "fail",
1795
+ error: pass ? void 0 : `URL did not equal ${assertion.urlEquals}`
1796
+ });
1797
+ continue;
1798
+ }
1799
+ if ("noConsoleErrors" in assertion) {
1800
+ const errors = options.consoleEntries.filter((entry) => entry.type === "error");
1801
+ const pass = errors.length === 0;
1802
+ results.push({
1803
+ type: "noConsoleErrors",
1804
+ value: true,
1805
+ status: pass ? "pass" : "fail",
1806
+ error: pass ? void 0 : `${errors.length} console error(s)`
1807
+ });
1808
+ continue;
1809
+ }
1810
+ if ("noNetworkErrors" in assertion) {
1811
+ const pass = networkEntries.length === 0;
1812
+ results.push({
1813
+ type: "noNetworkErrors",
1814
+ value: true,
1815
+ status: pass ? "pass" : "fail",
1816
+ error: pass ? void 0 : `${networkEntries.length} network error(s)`
1817
+ });
1818
+ }
1819
+ } catch (error) {
1820
+ const message = error instanceof Error ? error.message : "Assertion failed";
1821
+ const type = Object.keys(assertion)[0] ?? "assertion";
1822
+ results.push({
1823
+ type,
1824
+ status: "fail",
1825
+ error: message
1826
+ });
1827
+ }
1828
+ }
1829
+ return results;
1830
+ }
1831
+
1832
+ // src/runner/tracing.ts
1833
+ var DEFAULT_TRACE_HEADER = "traceparent";
1834
+ function parseTraceId(headerValue) {
1835
+ const raw = headerValue.trim();
1836
+ if (raw.length === 0) return void 0;
1837
+ const parts = raw.split("-");
1838
+ if (parts.length >= 3 && /^[0-9a-f]{32}$/i.test(parts[1])) {
1839
+ return parts[1];
1840
+ }
1841
+ return raw;
1842
+ }
1843
+ function readHeader(headers, headerName) {
1844
+ const normalizedName = headerName.toLowerCase();
1845
+ return headers[normalizedName];
1846
+ }
1847
+ function redactValues(text, values) {
1848
+ let redacted = text;
1849
+ for (const value of values) {
1850
+ if (value.length === 0) continue;
1851
+ redacted = redacted.split(value).join("[REDACTED]");
1852
+ }
1853
+ return redacted;
1854
+ }
1855
+ function captureTraceCorrelation(response, headerName, sink, redactionValues = []) {
1856
+ const value = readHeader(response.headers(), headerName);
1857
+ if (!value) return;
1858
+ const traceId = parseTraceId(value);
1859
+ if (!traceId) return;
1860
+ sink.push({
1861
+ url: redactValues(response.url(), redactionValues),
1862
+ status: response.status(),
1863
+ traceId,
1864
+ header: value
1865
+ });
1866
+ }
1867
+
1868
+ // src/reporter/result.ts
1869
+ import fs4 from "fs";
1870
+ import path4 from "path";
1871
+ function writeResult(runDir, result) {
1872
+ const fileName = "result.json";
1873
+ const fullPath = path4.join(runDir, fileName);
1874
+ fs4.writeFileSync(fullPath, JSON.stringify(result, null, 2));
1875
+ return fileName;
1876
+ }
1877
+
1878
+ // src/reporter/summary.ts
1879
+ import fs5 from "fs";
1880
+ import path5 from "path";
1881
+ function escapeMd(text) {
1882
+ return text.replace(/([|`*_{}[\]()#+\-!\\])/g, "\\$1");
1883
+ }
1884
+ function formatStep(step) {
1885
+ const base = `- [${step.status.toUpperCase()}] ${step.type} (${step.durationMs}ms)`;
1886
+ const selector = step.selector ? ` selector=${step.selector}` : "";
1887
+ const healed = step.healedFrom ? ` healed-from=${escapeMd(step.healedFrom)}` : "";
1888
+ const value = step.value ? ` value=${escapeMd(step.value)}` : "";
1889
+ const error = step.error ? ` error=${escapeMd(step.error)}` : "";
1890
+ return `${base}${selector}${healed}${value}${error}`;
1891
+ }
1892
+ function formatAssertion(assertion) {
1893
+ const value = assertion.value !== void 0 ? ` value=${typeof assertion.value === "string" ? escapeMd(assertion.value) : assertion.value}` : "";
1894
+ const error = assertion.error ? ` error=${escapeMd(assertion.error)}` : "";
1895
+ return `- [${assertion.status.toUpperCase()}] ${assertion.type}${value}${error}`;
1896
+ }
1897
+ function writeSummary(runDir, result) {
1898
+ const lines = [];
1899
+ lines.push("# Prowl Run Summary");
1900
+ lines.push("");
1901
+ lines.push(`Status: ${result.status.toUpperCase()}`);
1902
+ lines.push(`Hunt: ${result.hunt}`);
1903
+ lines.push(`Target: ${result.targetUrl}`);
1904
+ lines.push(`Started: ${result.startedAt}`);
1905
+ lines.push(`Duration: ${result.durationMs}ms`);
1906
+ lines.push("");
1907
+ lines.push("## Steps");
1908
+ for (const step of result.steps) {
1909
+ lines.push(formatStep(step));
1910
+ }
1911
+ const healed = result.steps.filter((step) => step.healedFrom);
1912
+ if (healed.length > 0) {
1913
+ lines.push("");
1914
+ lines.push("## Self-Healed Selectors");
1915
+ lines.push("These selectors no longer matched and were auto-healed. Update your hunt to use the healed selector (or a stable `data-testid`):");
1916
+ for (const step of healed) {
1917
+ lines.push(`- ${escapeMd(step.healedFrom ?? "")} \u2192 ${escapeMd(step.selector ?? "")}`);
1918
+ }
1919
+ }
1920
+ lines.push("");
1921
+ lines.push("## Assertions");
1922
+ for (const assertion of result.assertions) {
1923
+ lines.push(formatAssertion(assertion));
1924
+ }
1925
+ if (result.traceCorrelations && result.traceCorrelations.length > 0) {
1926
+ lines.push("");
1927
+ lines.push("## Trace Correlations");
1928
+ for (const correlation of result.traceCorrelations) {
1929
+ lines.push(
1930
+ `- [${correlation.status}] ${escapeMd(correlation.url)} traceId=${escapeMd(correlation.traceId)}`
1931
+ );
1932
+ }
1933
+ }
1934
+ lines.push("");
1935
+ lines.push("## Artifacts");
1936
+ const artifacts = result.artifacts;
1937
+ if (artifacts.summary) {
1938
+ lines.push(`- summary: ${artifacts.summary}`);
1939
+ }
1940
+ if (artifacts.console) {
1941
+ lines.push(`- console: ${artifacts.console}`);
1942
+ }
1943
+ if (artifacts.trace) {
1944
+ lines.push(`- trace: ${artifacts.trace}`);
1945
+ }
1946
+ if (artifacts.networkHar) {
1947
+ lines.push(`- network: ${artifacts.networkHar}`);
1948
+ }
1949
+ if (artifacts.junit) {
1950
+ lines.push(`- junit: ${artifacts.junit}`);
1951
+ }
1952
+ if (artifacts.screenshots && artifacts.screenshots.length > 0) {
1953
+ for (const screenshot of artifacts.screenshots) {
1954
+ lines.push(`- screenshot: ${screenshot}`);
1955
+ }
1956
+ }
1957
+ const fileName = "summary.md";
1958
+ const fullPath = path5.join(runDir, fileName);
1959
+ fs5.writeFileSync(fullPath, `${lines.join("\n")}
1960
+ `);
1961
+ return fileName;
1962
+ }
1963
+
1964
+ // src/reporter/junit.ts
1965
+ import fs6 from "fs";
1966
+ import path6 from "path";
1967
+ function escapeXml(text) {
1968
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
1969
+ }
1970
+ function writeJunit(runDir, result) {
1971
+ const totalTests = result.steps.length + result.assertions.length;
1972
+ const failures = result.steps.filter((s) => s.status === "fail").length + result.assertions.filter((a) => a.status === "fail").length;
1973
+ const timeSeconds = (result.durationMs / 1e3).toFixed(3);
1974
+ const huntName = escapeXml(result.hunt);
1975
+ const lines = [];
1976
+ lines.push('<?xml version="1.0" encoding="UTF-8"?>');
1977
+ lines.push("<testsuites>");
1978
+ lines.push(
1979
+ ` <testsuite name="${huntName}" tests="${totalTests}" failures="${failures}" errors="0" time="${timeSeconds}" timestamp="${escapeXml(result.startedAt)}">`
1980
+ );
1981
+ for (let i = 0; i < result.steps.length; i++) {
1982
+ const step = result.steps[i];
1983
+ const stepTime = (step.durationMs / 1e3).toFixed(3);
1984
+ const caseName = escapeXml(`step ${i + 1}: ${step.type}`);
1985
+ if (step.status === "fail") {
1986
+ const failureText = step.error ?? `Step ${step.type} failed with no error provided`;
1987
+ const escapedFailureText = escapeXml(failureText);
1988
+ lines.push(` <testcase name="${caseName}" classname="${huntName}" time="${stepTime}">`);
1989
+ lines.push(` <failure message="${escapedFailureText}" type="step">${escapedFailureText}</failure>`);
1990
+ lines.push(" </testcase>");
1991
+ } else {
1992
+ lines.push(` <testcase name="${caseName}" classname="${huntName}" time="${stepTime}"/>`);
1993
+ }
1994
+ }
1995
+ for (const assertion of result.assertions) {
1996
+ const caseName = escapeXml(`assertion: ${assertion.type}`);
1997
+ if (assertion.status === "fail") {
1998
+ const failureText = assertion.error ?? `Assertion ${assertion.type} failed with no error provided`;
1999
+ const escapedFailureText = escapeXml(failureText);
2000
+ lines.push(` <testcase name="${caseName}" classname="${huntName}" time="0">`);
2001
+ lines.push(` <failure message="${escapedFailureText}" type="assertion">${escapedFailureText}</failure>`);
2002
+ lines.push(" </testcase>");
2003
+ } else {
2004
+ lines.push(` <testcase name="${caseName}" classname="${huntName}" time="0"/>`);
2005
+ }
2006
+ }
2007
+ lines.push(" </testsuite>");
2008
+ lines.push("</testsuites>");
2009
+ const fileName = "junit.xml";
2010
+ const fullPath = path6.join(runDir, fileName);
2011
+ fs6.writeFileSync(fullPath, `${lines.join("\n")}
2012
+ `);
2013
+ return fileName;
2014
+ }
2015
+
2016
+ // src/reporter/index.ts
2017
+ function writeReports(runDir, result, options) {
2018
+ const summary = writeSummary(runDir, result);
2019
+ const updated = {
2020
+ ...result,
2021
+ artifacts: {
2022
+ ...result.artifacts,
2023
+ summary
2024
+ }
2025
+ };
2026
+ if (options?.junit) {
2027
+ updated.artifacts.junit = writeJunit(runDir, updated);
2028
+ }
2029
+ writeResult(runDir, updated);
2030
+ return updated;
2031
+ }
2032
+
2033
+ // src/utils/timestamp.ts
2034
+ function timestamp(prefix) {
2035
+ const now = /* @__PURE__ */ new Date();
2036
+ const pad = (value) => value.toString().padStart(2, "0");
2037
+ const pad3 = (value) => value.toString().padStart(3, "0");
2038
+ const ts = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}_${pad(
2039
+ now.getHours()
2040
+ )}-${pad(now.getMinutes())}-${pad(now.getSeconds())}-${pad3(now.getMilliseconds())}`;
2041
+ return prefix ? `${prefix}-${ts}` : ts;
2042
+ }
2043
+
2044
+ // src/runner/index.ts
2045
+ function parseViewportFlag(value) {
2046
+ const match = /^(\d+)x(\d+)$/i.exec(value);
2047
+ if (match) {
2048
+ return { width: Number(match[1]), height: Number(match[2]) };
2049
+ }
2050
+ return value;
2051
+ }
2052
+ function resolvePath(configDir, inputPath) {
2053
+ if (path7.isAbsolute(inputPath)) {
2054
+ return inputPath;
2055
+ }
2056
+ const projectRoot = path7.dirname(configDir);
2057
+ return path7.join(projectRoot, inputPath);
2058
+ }
2059
+ function buildRunResult(options) {
2060
+ return {
2061
+ status: options.status,
2062
+ exitCode: options.status === "pass" ? 0 : 1,
2063
+ startedAt: options.startedAt,
2064
+ durationMs: options.durationMs,
2065
+ hunt: options.hunt,
2066
+ targetUrl: options.targetUrl,
2067
+ steps: options.steps,
2068
+ assertions: options.assertions,
2069
+ artifacts: options.artifacts,
2070
+ // Omit entirely when there are no correlations, so passing/clean runs stay tidy.
2071
+ ...options.traceCorrelations && options.traceCorrelations.length > 0 ? { traceCorrelations: options.traceCorrelations } : {}
2072
+ };
2073
+ }
2074
+ function writeConsoleLog(runDir, entries) {
2075
+ const fileName = "console.log";
2076
+ const filePath = path7.join(runDir, fileName);
2077
+ const lines = entries.map((entry) => {
2078
+ const location = entry.location ? ` (${entry.location})` : "";
2079
+ return `[${entry.type}] ${entry.text}${location}`;
2080
+ });
2081
+ fs7.writeFileSync(filePath, `${lines.join("\n")}
2082
+ `);
2083
+ return fileName;
2084
+ }
2085
+ async function executeHuntAttempt(options, config, configDir, interpolatedHunt, redactedFillSteps, randomVars, redactionValues, targetUrl, allowedDomains) {
2086
+ const headless = options.headed ? false : config.browser.headless;
2087
+ const slowMo = options.slowMo ?? config.browser.slowMo;
2088
+ const maxSteps = config.guardrails.maxSteps;
2089
+ const runDir = path7.join(configDir, "runs", timestamp());
2090
+ fs7.mkdirSync(runDir, { recursive: true });
2091
+ const storageStatePath = config.auth.storageStatePath ? resolvePath(configDir, config.auth.storageStatePath) : void 0;
2092
+ const engine = options.browser ?? config.browser.engine;
2093
+ const channel = options.channel ?? config.browser.channel;
2094
+ const viewport = options.viewport ? resolveViewport(parseViewportFlag(options.viewport)) : config.browser.viewport;
2095
+ const session = await launchBrowser({
2096
+ headless,
2097
+ slowMo,
2098
+ timeout: config.browser.timeout,
2099
+ storageStatePath,
2100
+ trace: Boolean(options.trace),
2101
+ recordHar: config.artifacts.networkHar,
2102
+ runDir,
2103
+ engine,
2104
+ channel,
2105
+ viewport
2106
+ });
2107
+ let result;
2108
+ try {
2109
+ const consoleEntries = [];
2110
+ const networkEntries = [];
2111
+ const traceCorrelations = [];
2112
+ const traceHeader = config.tracing?.header ?? DEFAULT_TRACE_HEADER;
2113
+ session.page.on("console", (message) => {
2114
+ consoleEntries.push({
2115
+ type: message.type(),
2116
+ text: message.text(),
2117
+ location: message.location().url
2118
+ });
2119
+ });
2120
+ session.page.on("response", (response) => {
2121
+ if (response.status() >= 400) {
2122
+ networkEntries.push({ url: response.url(), status: response.status() });
2123
+ captureTraceCorrelation(response, traceHeader, traceCorrelations, redactionValues);
2124
+ }
2125
+ });
2126
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
2127
+ const startTime = Date.now();
2128
+ let stepResults = [];
2129
+ let stepScreenshots = [];
2130
+ let stepFailed = false;
2131
+ try {
2132
+ const stepExecution = await executeSteps({
2133
+ page: session.page,
2134
+ steps: interpolatedHunt.steps,
2135
+ targetUrl,
2136
+ runDir,
2137
+ screenshotsMode: config.artifacts.screenshots,
2138
+ forbiddenSelectors: config.guardrails.forbiddenSelectors,
2139
+ allowedDomains,
2140
+ maxSteps,
2141
+ maxTotalTimeMs: config.assertions.maxTotalTimeMs,
2142
+ selfHealing: config.guardrails.selfHealing,
2143
+ redactedFillSteps,
2144
+ randomVars,
2145
+ configDir,
2146
+ huntStack: [options.huntName],
2147
+ onStep: options.onStep
2148
+ });
2149
+ stepResults = stepExecution.results;
2150
+ stepScreenshots = stepExecution.screenshots;
2151
+ stepFailed = stepExecution.failed;
2152
+ } catch (error) {
2153
+ const message = error instanceof Error ? error.message : "Step execution failed";
2154
+ stepResults = [
2155
+ {
2156
+ type: "steps",
2157
+ status: "fail",
2158
+ durationMs: 0,
2159
+ error: message
2160
+ }
2161
+ ];
2162
+ stepFailed = true;
2163
+ }
2164
+ let finalScreenshot;
2165
+ try {
2166
+ finalScreenshot = await captureFinalScreenshot(session.page, runDir);
2167
+ } catch {
2168
+ finalScreenshot = void 0;
2169
+ }
2170
+ const assertionResults = await evaluateAssertions({
2171
+ page: session.page,
2172
+ config,
2173
+ huntAssertions: interpolatedHunt.assertions,
2174
+ consoleEntries,
2175
+ networkEntries
2176
+ });
2177
+ const durationMs = Date.now() - startTime;
2178
+ const assertionsFailed = assertionResults.some((assertion) => assertion.status === "fail");
2179
+ const status = stepFailed || assertionsFailed ? "fail" : "pass";
2180
+ const artifacts = {
2181
+ screenshots: finalScreenshot ? [...stepScreenshots, finalScreenshot] : stepScreenshots,
2182
+ trace: session.tracePath ? "trace.zip" : void 0,
2183
+ networkHar: config.artifacts.networkHar ? "network.har" : void 0
2184
+ };
2185
+ if (config.artifacts.console) {
2186
+ artifacts.console = writeConsoleLog(runDir, consoleEntries);
2187
+ }
2188
+ const runResult = buildRunResult({
2189
+ status,
2190
+ startedAt,
2191
+ durationMs,
2192
+ hunt: options.huntName,
2193
+ targetUrl,
2194
+ steps: stepResults,
2195
+ assertions: assertionResults,
2196
+ artifacts,
2197
+ traceCorrelations
2198
+ });
2199
+ result = writeReports(runDir, runResult, { junit: options.junit ?? config.artifacts.junit });
2200
+ } finally {
2201
+ await closeBrowser(session);
2202
+ }
2203
+ return { result, runDir, steps: interpolatedHunt.steps };
2204
+ }
2205
+ function delay(ms) {
2206
+ return new Promise((resolve) => setTimeout(resolve, ms));
2207
+ }
2208
+ async function runHunt(options) {
2209
+ const { config, configDir } = loadConfig(options.configPath);
2210
+ const hunt = loadHunt(options.huntName, configDir);
2211
+ const {
2212
+ hunt: interpolatedHunt,
2213
+ redactedFillSteps,
2214
+ randomVars,
2215
+ redactionValues = []
2216
+ } = interpolateHunt(
2217
+ hunt,
2218
+ process.env
2219
+ );
2220
+ const targetUrl = options.urlOverride ?? config.target.url;
2221
+ const allowedDomains = ensureAllowedDomain([...config.guardrails.allowedDomains], targetUrl);
2222
+ const maxSteps = config.guardrails.maxSteps;
2223
+ if (interpolatedHunt.steps.length > maxSteps) {
2224
+ throw new Error(`Hunt has ${interpolatedHunt.steps.length} steps. Max allowed is ${maxSteps}.`);
2225
+ }
2226
+ const maxRetries = hunt.retry?.maxRetries ?? 0;
2227
+ const retryDelay = hunt.retry?.delay ?? 0;
2228
+ let lastResult;
2229
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
2230
+ if (attempt > 0 && retryDelay > 0) {
2231
+ await delay(retryDelay);
2232
+ }
2233
+ lastResult = await executeHuntAttempt(
2234
+ options,
2235
+ config,
2236
+ configDir,
2237
+ interpolatedHunt,
2238
+ redactedFillSteps,
2239
+ randomVars,
2240
+ redactionValues,
2241
+ targetUrl,
2242
+ allowedDomains
2243
+ );
2244
+ if (lastResult.result.status === "pass") {
2245
+ if (attempt > 0) {
2246
+ lastResult.result.artifacts.summary = `Passed on attempt ${attempt + 1} of ${maxRetries + 1}`;
2247
+ }
2248
+ recordHistory(configDir, lastResult, config.history.maxRuns);
2249
+ return lastResult;
2250
+ }
2251
+ }
2252
+ if (maxRetries > 0 && lastResult) {
2253
+ lastResult.result.artifacts.summary = `Failed after ${maxRetries + 1} attempts`;
2254
+ }
2255
+ if (lastResult) {
2256
+ recordHistory(configDir, lastResult, config.history.maxRuns);
2257
+ }
2258
+ return lastResult;
2259
+ }
2260
+ function recordHistory(configDir, outcome, maxRuns) {
2261
+ try {
2262
+ const relativeRunDir = path7.relative(configDir, outcome.runDir);
2263
+ appendEntry(
2264
+ configDir,
2265
+ {
2266
+ hunt: outcome.result.hunt,
2267
+ status: outcome.result.status,
2268
+ durationMs: outcome.result.durationMs,
2269
+ startedAt: outcome.result.startedAt,
2270
+ runDir: relativeRunDir || void 0
2271
+ },
2272
+ maxRuns
2273
+ );
2274
+ } catch {
2275
+ }
2276
+ }
2277
+
2278
+ // src/runner/flaky.ts
2279
+ var DEFAULT_FLAKY_THRESHOLD = 0.3;
2280
+ function computeFlakeScore(entries, lastN) {
2281
+ const slice = lastN !== void 0 && lastN > 0 ? entries.slice(-lastN) : entries;
2282
+ if (slice.length < 2) return 0;
2283
+ let transitions = 0;
2284
+ for (let i = 1; i < slice.length; i++) {
2285
+ if (slice[i].status !== slice[i - 1].status) {
2286
+ transitions += 1;
2287
+ }
2288
+ }
2289
+ return transitions / (slice.length - 1);
2290
+ }
2291
+ function rankFlaky(configDir, options = {}) {
2292
+ const threshold = options.threshold ?? DEFAULT_FLAKY_THRESHOLD;
2293
+ const { entries } = readHistory(configDir);
2294
+ const byHunt = /* @__PURE__ */ new Map();
2295
+ for (const entry of entries) {
2296
+ const list = byHunt.get(entry.hunt) ?? [];
2297
+ list.push(entry);
2298
+ byHunt.set(entry.hunt, list);
2299
+ }
2300
+ const scores = [];
2301
+ for (const [hunt, huntEntries] of byHunt) {
2302
+ const considered = options.lastN !== void 0 && options.lastN > 0 ? huntEntries.slice(-options.lastN) : huntEntries;
2303
+ const score = computeFlakeScore(considered);
2304
+ scores.push({
2305
+ hunt,
2306
+ score,
2307
+ runs: considered.length,
2308
+ flaky: score >= threshold
2309
+ });
2310
+ }
2311
+ scores.sort((a, b) => b.score - a.score || b.runs - a.runs || a.hunt.localeCompare(b.hunt));
2312
+ return scores;
2313
+ }
2314
+
2315
+ // src/backlog/fingerprint.ts
2316
+ import { createHash } from "crypto";
2317
+ function normalizeError(error) {
2318
+ return error.toLowerCase().replace(/0x[0-9a-f]+/g, "").replace(/\b\d+(?:\.\d+)?\s*(?:ms|s|px)\b/g, "").replace(/\s+/g, " ").trim();
2319
+ }
2320
+ function stepLabel(failure) {
2321
+ if (failure.stepType === void 0 && failure.selector === void 0) {
2322
+ return "-";
2323
+ }
2324
+ const index = failure.stepIndex === void 0 ? "?" : String(failure.stepIndex);
2325
+ const type = failure.stepType ?? "?";
2326
+ const selector = failure.selector ? `@${failure.selector}` : "";
2327
+ return `${index}:${type}${selector}`;
2328
+ }
2329
+ function sanitizeMarkerValue(value) {
2330
+ return value.replace(/\r?\n/g, " ").replace(/-->/g, "--&gt;").trim();
2331
+ }
2332
+ function computeFingerprint(failure) {
2333
+ const parts = [
2334
+ failure.hunt,
2335
+ failure.stepType ?? "",
2336
+ failure.selector ?? "",
2337
+ normalizeError(failure.error)
2338
+ ];
2339
+ return createHash("sha1").update(parts.join("|")).digest("hex").slice(0, 8);
2340
+ }
2341
+ function buildMarker(failure, hash) {
2342
+ const hunt = sanitizeMarkerValue(failure.hunt);
2343
+ const step = sanitizeMarkerValue(stepLabel(failure));
2344
+ return `<!-- prowl:fp=${hash} hunt=${hunt} step=${step} -->`;
2345
+ }
2346
+
2347
+ // src/runner/clustering.ts
2348
+ function clusterKey(failure, normalizedError) {
2349
+ return [failure.stepType ?? "", failure.selector ?? "", normalizedError].join("|");
2350
+ }
2351
+ function describeCause(failure, error) {
2352
+ const where = failure.stepType ? `${failure.stepType}${failure.selector ? ` (${failure.selector})` : ""}` : "run";
2353
+ return `${where}: ${error}`;
2354
+ }
2355
+ function clusterFailures(failures) {
2356
+ const groups = /* @__PURE__ */ new Map();
2357
+ for (const failure of failures) {
2358
+ const error = normalizeError(failure.error);
2359
+ const key = clusterKey(failure, error);
2360
+ const existing = groups.get(key);
2361
+ if (existing) {
2362
+ existing.hunts.add(failure.hunt);
2363
+ } else {
2364
+ groups.set(key, { sample: failure, error, hunts: /* @__PURE__ */ new Set([failure.hunt]) });
2365
+ }
2366
+ }
2367
+ const clusters = [];
2368
+ for (const { sample, error, hunts } of groups.values()) {
2369
+ clusters.push({
2370
+ cause: describeCause(sample, error),
2371
+ stepType: sample.stepType,
2372
+ selector: sample.selector,
2373
+ error,
2374
+ count: hunts.size,
2375
+ hunts: [...hunts].sort()
2376
+ });
2377
+ }
2378
+ clusters.sort((a, b) => b.count - a.count || a.cause.localeCompare(b.cause));
2379
+ return clusters;
2380
+ }
2381
+
2382
+ // src/backlog/index.ts
2383
+ import fs8 from "fs";
2384
+ import path8 from "path";
2385
+
2386
+ // src/backlog/parse.ts
2387
+ var MARKER_FP = /<!--\s*prowl:fp=([0-9a-f]+)/;
2388
+ var TICKET_ID = /\bQA-(\d+)\b/;
2389
+ var TICKET_ID_GLOBAL = /\bQA-(\d+)\b/g;
2390
+ var HEADING = /^#{1,6}\s/;
2391
+ function extractFingerprints(content) {
2392
+ const map = /* @__PURE__ */ new Map();
2393
+ let currentId;
2394
+ for (const line of content.split("\n")) {
2395
+ if (HEADING.test(line)) {
2396
+ const idMatch = TICKET_ID.exec(line);
2397
+ currentId = idMatch ? `QA-${idMatch[1]}` : void 0;
2398
+ }
2399
+ const fpMatch = MARKER_FP.exec(line);
2400
+ if (fpMatch && currentId) {
2401
+ map.set(fpMatch[1], currentId);
2402
+ }
2403
+ }
2404
+ return map;
2405
+ }
2406
+ function nextTicketId(contents) {
2407
+ let max = 0;
2408
+ for (const content of contents) {
2409
+ for (const match of content.matchAll(TICKET_ID_GLOBAL)) {
2410
+ const n = Number(match[1]);
2411
+ if (n > max) max = n;
2412
+ }
2413
+ }
2414
+ return `QA-${String(max + 1).padStart(3, "0")}`;
2415
+ }
2416
+ function classifyFingerprint(fp, activeFps, resolvedFps) {
2417
+ const openId = activeFps.get(fp);
2418
+ if (openId) return { kind: "open", ticketId: openId };
2419
+ const resolvedId = resolvedFps.get(fp);
2420
+ if (resolvedId) return { kind: "regression", resolvedId };
2421
+ return { kind: "new" };
2422
+ }
2423
+
2424
+ // src/backlog/write.ts
2425
+ var SECTION_HEADING = "## QA Findings (automated)";
2426
+ function renderTicket(opts) {
2427
+ const { id, failure, marker, regressionOf, date } = opts;
2428
+ const spot = failure.stepType ? `${failure.stepType}${failure.selector ? ` (${failure.selector})` : ""}` : "run failed before steps executed";
2429
+ const lines = [];
2430
+ lines.push(`### ${id}: ${failure.hunt} \u2014 ${spot}`);
2431
+ lines.push(marker);
2432
+ lines.push(`**Logged**: ${date}`);
2433
+ if (regressionOf) {
2434
+ lines.push(`**Regression of**: ${regressionOf} (previously resolved \u2014 see resolved.md)`);
2435
+ }
2436
+ lines.push(`**Hunt**: ${failure.hunt}`);
2437
+ const stepDesc = failure.stepType ? `step ${failure.stepIndex ?? "?"} \u2014 ${failure.stepType}${failure.selector ? ` ${failure.selector}` : ""}` : "n/a (hunt did not produce step results)";
2438
+ lines.push(`**Failing step**: ${stepDesc}`);
2439
+ lines.push(`**Error**: ${failure.error}`);
2440
+ if (failure.runDir) {
2441
+ lines.push(`**Artifacts**: ${failure.runDir}`);
2442
+ }
2443
+ return lines.join("\n");
2444
+ }
2445
+ function insertTickets(content, tickets) {
2446
+ if (tickets.length === 0) return content;
2447
+ const block = tickets.join("\n\n");
2448
+ const headingIndex = content.indexOf(SECTION_HEADING);
2449
+ if (headingIndex === -1) {
2450
+ const base = content.replace(/\n*$/, "");
2451
+ const prefix = base ? `${base}
2452
+
2453
+ ` : "";
2454
+ return `${prefix}${SECTION_HEADING}
2455
+
2456
+ ${block}
2457
+ `;
2458
+ }
2459
+ const afterHeading = headingIndex + SECTION_HEADING.length;
2460
+ const rest = content.slice(afterHeading);
2461
+ const nextHeadingRel = rest.search(/\n## /);
2462
+ const insertAt = nextHeadingRel === -1 ? content.length : afterHeading + nextHeadingRel;
2463
+ const before = content.slice(0, insertAt).replace(/\n*$/, "");
2464
+ const after = content.slice(insertAt);
2465
+ return `${before}
2466
+
2467
+ ${block}
2468
+ ${after}`;
2469
+ }
2470
+
2471
+ // src/backlog/index.ts
2472
+ function readFileOrEmpty(filePath) {
2473
+ try {
2474
+ return fs8.readFileSync(filePath, "utf-8");
2475
+ } catch (error) {
2476
+ const err = error;
2477
+ if (err.code === "ENOENT") return "";
2478
+ throw new Error(`Failed to read "${filePath}": ${err.message}`);
2479
+ }
2480
+ }
2481
+ function buildFailure(hunt) {
2482
+ const failure = {
2483
+ hunt: hunt.hunt,
2484
+ error: hunt.error ?? "Run failed",
2485
+ runDir: hunt.runDir
2486
+ };
2487
+ if (!hunt.runDir) return failure;
2488
+ let run;
2489
+ try {
2490
+ const resultJson = readFileOrEmpty(path8.join(hunt.runDir, "result.json"));
2491
+ if (!resultJson) return failure;
2492
+ run = JSON.parse(resultJson);
2493
+ } catch (error) {
2494
+ if (!(error instanceof SyntaxError)) throw error;
2495
+ return failure;
2496
+ }
2497
+ if (!run || !Array.isArray(run.steps)) return failure;
2498
+ const stepIndex = run.steps.findIndex((step) => step.status === "fail");
2499
+ if (stepIndex !== -1) {
2500
+ const step = run.steps[stepIndex];
2501
+ failure.stepIndex = stepIndex;
2502
+ failure.stepType = step.type;
2503
+ failure.selector = step.selector;
2504
+ if (step.error) failure.error = step.error;
2505
+ return failure;
2506
+ }
2507
+ const failedAssertion = run.assertions?.find((assertion) => assertion.status === "fail");
2508
+ if (failedAssertion) {
2509
+ failure.stepType = `assert:${failedAssertion.type}`;
2510
+ if (failedAssertion.error) failure.error = failedAssertion.error;
2511
+ }
2512
+ return failure;
2513
+ }
2514
+ function extractFailures(suiteResult) {
2515
+ return suiteResult.result.hunts.filter((hunt) => hunt.status === "fail").map(buildFailure);
2516
+ }
2517
+ function updateBacklogFromSuite(suiteResult, options = {}) {
2518
+ const projectRoot = options.projectRoot ?? process.cwd();
2519
+ const backlogPath = options.backlogPath ?? path8.join(projectRoot, "docs", "backlog.md");
2520
+ const resolvedPath = options.resolvedPath ?? path8.join(projectRoot, "docs", "resolved.md");
2521
+ const date = options.date ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
2522
+ const summary = { created: [], regressions: [], skipped: [], backlogPath };
2523
+ const failures = extractFailures(suiteResult);
2524
+ if (failures.length === 0) return summary;
2525
+ const backlogContent = readFileOrEmpty(backlogPath);
2526
+ const resolvedContent = readFileOrEmpty(resolvedPath);
2527
+ const activeFps = extractFingerprints(backlogContent);
2528
+ const resolvedFps = extractFingerprints(resolvedContent);
2529
+ let counter = Number(nextTicketId([backlogContent, resolvedContent]).slice(3));
2530
+ const makeId = () => `QA-${String(counter++).padStart(3, "0")}`;
2531
+ const seenThisRun = /* @__PURE__ */ new Set();
2532
+ const ticketsToAdd = [];
2533
+ for (const failure of failures) {
2534
+ const fp = computeFingerprint(failure);
2535
+ if (seenThisRun.has(fp)) continue;
2536
+ seenThisRun.add(fp);
2537
+ const classification = classifyFingerprint(fp, activeFps, resolvedFps);
2538
+ if (classification.kind === "open") {
2539
+ summary.skipped.push(classification.ticketId);
2540
+ continue;
2541
+ }
2542
+ const id = makeId();
2543
+ const regressionOf = classification.kind === "regression" ? classification.resolvedId : void 0;
2544
+ ticketsToAdd.push(renderTicket({ id, failure, marker: buildMarker(failure, fp), regressionOf, date }));
2545
+ if (regressionOf) {
2546
+ summary.regressions.push(id);
2547
+ } else {
2548
+ summary.created.push(id);
2549
+ }
2550
+ }
2551
+ if (ticketsToAdd.length > 0) {
2552
+ fs8.mkdirSync(path8.dirname(backlogPath), { recursive: true });
2553
+ fs8.writeFileSync(backlogPath, insertTickets(backlogContent, ticketsToAdd));
2554
+ }
2555
+ return summary;
2556
+ }
2557
+
2558
+ // src/runner/suite.ts
2559
+ import path10 from "path";
2560
+
2561
+ // src/reporter/ci-summary.ts
2562
+ import fs9 from "fs";
2563
+ import path9 from "path";
2564
+ import chalk from "chalk";
2565
+ function countCiResults(results) {
2566
+ return {
2567
+ passed: results.filter((r) => r.status === "pass").length,
2568
+ failed: results.filter((r) => r.status === "fail").length,
2569
+ skipped: results.filter((r) => r.status === "skipped").length
2570
+ };
2571
+ }
2572
+ function resolveCiStatus(results) {
2573
+ if (results.length === 0) return "no-hunts";
2574
+ const { failed, passed } = countCiResults(results);
2575
+ if (failed > 0) return "fail";
2576
+ if (passed > 0) return "pass";
2577
+ return "all-skipped";
2578
+ }
2579
+ function printCiSummary(results, totalDurationMs, flaky = [], clusters = []) {
2580
+ const { passed, failed, skipped } = countCiResults(results);
2581
+ const lineWidth = 45;
2582
+ console.log(`
2583
+ \u2500\u2500 CI Summary ${"\u2500".repeat(lineWidth - 15)}`);
2584
+ for (const r of results) {
2585
+ const icon = r.status === "pass" ? chalk.green("\u2713") : r.status === "fail" ? chalk.red("\u2717") : chalk.yellow("\u25CB");
2586
+ const name = r.status === "fail" ? chalk.red(r.hunt) : r.status === "skipped" ? chalk.yellow(r.hunt) : r.hunt;
2587
+ const duration = r.status === "skipped" ? "" : chalk.gray(`(${r.durationMs}ms)`);
2588
+ const pad = " ".repeat(Math.max(1, 40 - r.hunt.length));
2589
+ console.log(` ${icon} ${name}${pad}${duration}`);
2590
+ }
2591
+ console.log(` ${"\u2500".repeat(lineWidth)}`);
2592
+ const parts = [];
2593
+ if (passed > 0) parts.push(chalk.green(`${passed} passed`));
2594
+ if (failed > 0) parts.push(chalk.red(`${failed} failed`));
2595
+ if (skipped > 0) parts.push(chalk.yellow(`${skipped} skipped`));
2596
+ parts.push(chalk.gray(`(${totalDurationMs}ms)`));
2597
+ console.log(` ${parts.join(" ")}`);
2598
+ if (flaky.length > 0) {
2599
+ console.log(`
2600
+ ${chalk.yellow("Flaky hunts")} (oscillating pass/fail):`);
2601
+ for (const entry of flaky) {
2602
+ console.log(` ${chalk.yellow("~")} ${entry.hunt} ${chalk.gray(`(score ${entry.score.toFixed(2)})`)}`);
2603
+ }
2604
+ }
2605
+ if (clusters.length > 0) {
2606
+ console.log(`
2607
+ ${chalk.red("Failure clusters")} (shared root causes):`);
2608
+ for (const cluster of clusters) {
2609
+ console.log(` ${chalk.red("\u2717")} ${cluster.cause} ${chalk.gray(`(${cluster.count} hunts: ${cluster.hunts.join(", ")})`)}`);
2610
+ }
2611
+ }
2612
+ }
2613
+ function writeCiResult(ciRunDir, results, startedAt, totalDurationMs, flaky = [], clusters = []) {
2614
+ const { passed, failed, skipped } = countCiResults(results);
2615
+ const ciResult = {
2616
+ status: resolveCiStatus(results),
2617
+ startedAt,
2618
+ durationMs: totalDurationMs,
2619
+ totalHunts: results.length,
2620
+ passed,
2621
+ failed,
2622
+ skipped,
2623
+ hunts: results,
2624
+ ...flaky.length > 0 ? { flaky } : {},
2625
+ ...clusters.length > 0 ? { clusters } : {}
2626
+ };
2627
+ fs9.mkdirSync(ciRunDir, { recursive: true });
2628
+ const filePath = path9.join(ciRunDir, "ci-result.json");
2629
+ fs9.writeFileSync(filePath, JSON.stringify(ciResult, null, 2) + "\n");
2630
+ return filePath;
2631
+ }
2632
+
2633
+ // src/utils/concurrency.ts
2634
+ async function runWithConcurrency(tasks, concurrency) {
2635
+ const normalizedConcurrency = Number.isFinite(concurrency) && concurrency > 0 ? Math.floor(concurrency) : 1;
2636
+ const results = new Array(tasks.length);
2637
+ let nextIndex = 0;
2638
+ async function worker() {
2639
+ while (nextIndex < tasks.length) {
2640
+ const index = nextIndex;
2641
+ nextIndex += 1;
2642
+ try {
2643
+ const value = await tasks[index]();
2644
+ results[index] = { status: "fulfilled", value };
2645
+ } catch (reason) {
2646
+ results[index] = { status: "rejected", reason };
2647
+ }
2648
+ }
2649
+ }
2650
+ const workers = Array.from(
2651
+ { length: Math.min(normalizedConcurrency, tasks.length) },
2652
+ () => worker()
2653
+ );
2654
+ await Promise.all(workers);
2655
+ return results;
2656
+ }
2657
+
2658
+ // src/runner/suite.ts
2659
+ function normalizeTagFilter(tags) {
2660
+ const normalized = tags?.map((tag) => tag.trim()).filter(Boolean);
2661
+ return normalized && normalized.length > 0 ? normalized : void 0;
2662
+ }
2663
+ async function callHook(callback) {
2664
+ if (!callback) return;
2665
+ try {
2666
+ await callback();
2667
+ } catch {
2668
+ }
2669
+ }
2670
+ function safeOnStep(hooks) {
2671
+ if (!hooks.onStep) return void 0;
2672
+ return (result, step, index) => {
2673
+ try {
2674
+ hooks.onStep?.(result, step, index);
2675
+ } catch {
2676
+ }
2677
+ };
2678
+ }
2679
+ function firstRunFailureMessage(result) {
2680
+ const failedStep = result.steps.find((step) => step.status === "fail" && step.error);
2681
+ if (failedStep?.error) return failedStep.error;
2682
+ const failedAssertion = result.assertions.find((assertion) => assertion.status === "fail" && assertion.error);
2683
+ return failedAssertion?.error;
2684
+ }
2685
+ async function runSuite(options = {}) {
2686
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
2687
+ const startTime = Date.now();
2688
+ const hooks = options.hooks ?? {};
2689
+ const { config, configDir } = loadConfig(options.configPath);
2690
+ const hunts = listHunts(configDir);
2691
+ if (hunts.length === 0) {
2692
+ return {
2693
+ result: {
2694
+ status: "no-hunts",
2695
+ startedAt,
2696
+ durationMs: 0,
2697
+ totalHunts: 0,
2698
+ passed: 0,
2699
+ failed: 0,
2700
+ skipped: 0,
2701
+ hunts: []
2702
+ },
2703
+ resultPath: null
2704
+ };
2705
+ }
2706
+ const includeTags = normalizeTagFilter(options.includeTags);
2707
+ const excludeTags = normalizeTagFilter(options.excludeTags);
2708
+ const resultsByIndex = new Array(hunts.length);
2709
+ const onStep = safeOnStep(hooks);
2710
+ const huntsToRun = [];
2711
+ for (let index = 0; index < hunts.length; index++) {
2712
+ const huntName = hunts[index];
2713
+ if (includeTags || excludeTags) {
2714
+ const tags = loadHuntTags(huntName, configDir);
2715
+ if (includeTags && !includeTags.some((t) => tags.includes(t))) {
2716
+ await callHook(() => hooks.onHuntSkipped?.(huntName, "include"));
2717
+ resultsByIndex[index] = { hunt: huntName, status: "skipped", durationMs: 0 };
2718
+ continue;
2719
+ }
2720
+ if (excludeTags && excludeTags.some((t) => tags.includes(t))) {
2721
+ await callHook(() => hooks.onHuntSkipped?.(huntName, "exclude"));
2722
+ resultsByIndex[index] = { hunt: huntName, status: "skipped", durationMs: 0 };
2723
+ continue;
2724
+ }
2725
+ }
2726
+ huntsToRun.push({ huntName, index });
2727
+ }
2728
+ const buildTask = (huntName) => async () => {
2729
+ const huntStart = Date.now();
2730
+ try {
2731
+ await callHook(() => hooks.onHuntStart?.(huntName));
2732
+ const { result, runDir } = await runHunt({
2733
+ huntName,
2734
+ urlOverride: options.urlOverride,
2735
+ headed: options.headed,
2736
+ slowMo: options.slowMo,
2737
+ trace: options.trace,
2738
+ browser: options.browser,
2739
+ channel: options.channel,
2740
+ viewport: options.viewport,
2741
+ junit: options.junit,
2742
+ configPath: options.configPath,
2743
+ onStep
2744
+ });
2745
+ const error = result.status === "fail" ? firstRunFailureMessage(result) ?? "Run failed" : void 0;
2746
+ if (error) {
2747
+ await callHook(() => hooks.onHuntFailure?.(huntName, error));
2748
+ } else {
2749
+ await callHook(() => hooks.onHuntSuccess?.(huntName, result, runDir));
2750
+ }
2751
+ return {
2752
+ hunt: huntName,
2753
+ status: result.status,
2754
+ durationMs: result.durationMs,
2755
+ runDir,
2756
+ error
2757
+ };
2758
+ } catch (error) {
2759
+ const durationMs = Date.now() - huntStart;
2760
+ const message = error instanceof Error ? error.message : "Run failed";
2761
+ await callHook(() => hooks.onHuntFailure?.(huntName, message));
2762
+ return {
2763
+ hunt: huntName,
2764
+ status: "fail",
2765
+ durationMs,
2766
+ error: message
2767
+ };
2768
+ }
2769
+ };
2770
+ const parallel = options.parallel;
2771
+ if (parallel !== void 0 && parallel > 1) {
2772
+ const tasks = huntsToRun.map((entry) => ({ ...entry, task: buildTask(entry.huntName) }));
2773
+ const parallelResults = await runWithConcurrency(
2774
+ tasks.map((entry) => entry.task),
2775
+ parallel
2776
+ );
2777
+ for (let i = 0; i < parallelResults.length; i++) {
2778
+ const pr = parallelResults[i];
2779
+ const task = tasks[i];
2780
+ if (pr.status === "fulfilled") {
2781
+ resultsByIndex[task.index] = pr.value;
2782
+ } else {
2783
+ const message = pr.reason instanceof Error ? pr.reason.message : "Run failed";
2784
+ resultsByIndex[task.index] = {
2785
+ hunt: task.huntName,
2786
+ status: "fail",
2787
+ durationMs: 0,
2788
+ error: message
2789
+ };
2790
+ }
2791
+ }
2792
+ } else {
2793
+ for (const { huntName, index } of huntsToRun) {
2794
+ resultsByIndex[index] = await buildTask(huntName)();
2795
+ }
2796
+ }
2797
+ const totalDurationMs = Date.now() - startTime;
2798
+ const results = resultsByIndex.map((result, index) => {
2799
+ return result ?? {
2800
+ hunt: hunts[index],
2801
+ status: "fail",
2802
+ durationMs: 0,
2803
+ error: "Run did not produce a result"
2804
+ };
2805
+ });
2806
+ const threshold = config.reliability?.flakyThreshold ?? DEFAULT_FLAKY_THRESHOLD;
2807
+ const ranThisSuite = new Set(
2808
+ results.filter((r) => r.status !== "skipped").map((r) => r.hunt)
2809
+ );
2810
+ const flaky = rankFlaky(configDir, { threshold }).filter((entry) => entry.flaky && ranThisSuite.has(entry.hunt)).map((entry) => ({ hunt: entry.hunt, score: entry.score }));
2811
+ const clusters = clusterFailures(
2812
+ extractFailures({ result: { hunts: results }, resultPath: null })
2813
+ ).filter((cluster) => cluster.count > 1);
2814
+ const ciRunDir = path10.join(configDir, "runs", timestamp("ci"));
2815
+ const resultPath = writeCiResult(ciRunDir, results, startedAt, totalDurationMs, flaky, clusters);
2816
+ const { passed, failed, skipped } = countCiResults(results);
2817
+ return {
2818
+ result: {
2819
+ status: resolveCiStatus(results),
2820
+ startedAt,
2821
+ durationMs: totalDurationMs,
2822
+ totalHunts: results.length,
2823
+ passed,
2824
+ failed,
2825
+ skipped,
2826
+ hunts: results,
2827
+ ...flaky.length > 0 ? { flaky } : {},
2828
+ ...clusters.length > 0 ? { clusters } : {}
2829
+ },
2830
+ resultPath
2831
+ };
2832
+ }
2833
+
2834
+ // src/analyzer/index.ts
2835
+ async function analyzePage(page) {
2836
+ const raw = await page.evaluate(() => {
2837
+ const forms2 = Array.from(document.querySelectorAll("form"));
2838
+ const formData = forms2.map((form, index) => ({
2839
+ index,
2840
+ action: form.getAttribute("action") || void 0,
2841
+ method: (form.getAttribute("method") || "GET").toUpperCase(),
2842
+ fieldCount: form.querySelectorAll("input, textarea, select").length
2843
+ }));
2844
+ function getFormIndex(el) {
2845
+ const form = el.closest("form");
2846
+ if (!form) return -1;
2847
+ return forms2.indexOf(form);
2848
+ }
2849
+ function getLabel(el) {
2850
+ const id = el.getAttribute("id");
2851
+ if (id) {
2852
+ const label = document.querySelector(`label[for="${id}"]`);
2853
+ if (label) return label.textContent?.trim() || void 0;
2854
+ }
2855
+ const parentLabel = el.closest("label");
2856
+ if (parentLabel) return parentLabel.textContent?.trim() || void 0;
2857
+ return void 0;
2858
+ }
2859
+ const selectors = "input, textarea, select, button, [role=button], a";
2860
+ const rawElements = Array.from(document.querySelectorAll(selectors));
2861
+ const elements2 = rawElements.filter((el) => {
2862
+ if (el.tagName.toLowerCase() === "input" && el.getAttribute("type") === "hidden") {
2863
+ return false;
2864
+ }
2865
+ return true;
2866
+ }).map((el) => {
2867
+ const tag = el.tagName.toLowerCase();
2868
+ const type = el.getAttribute("type") || void 0;
2869
+ const testId = el.getAttribute("data-testid") || void 0;
2870
+ const ariaLabel = el.getAttribute("aria-label") || void 0;
2871
+ const role = el.getAttribute("role") || void 0;
2872
+ const id = el.getAttribute("id") || void 0;
2873
+ const name = el.getAttribute("name") || void 0;
2874
+ const label = getLabel(el);
2875
+ const placeholder = el.getAttribute("placeholder") || void 0;
2876
+ const required = el.hasAttribute("required");
2877
+ const formIndex = getFormIndex(el);
2878
+ const text = el.textContent?.trim() || void 0;
2879
+ const href = el.getAttribute("href") || void 0;
2880
+ return {
2881
+ tag,
2882
+ type: type || void 0,
2883
+ testId,
2884
+ ariaLabel,
2885
+ role,
2886
+ id,
2887
+ name,
2888
+ label,
2889
+ placeholder,
2890
+ required,
2891
+ formIndex,
2892
+ text: tag === "a" || tag === "button" || role === "button" ? text : void 0,
2893
+ href: tag === "a" ? href : void 0
2894
+ };
2895
+ });
2896
+ return {
2897
+ title: document.title,
2898
+ url: window.location.href,
2899
+ elements: elements2,
2900
+ forms: formData
2901
+ };
2902
+ });
2903
+ const elements = raw.elements.filter((el) => el.tag !== "a").map((el) => {
2904
+ const selectors = {};
2905
+ if (el.testId) selectors.testId = `[data-testid="${el.testId}"]`;
2906
+ if (el.ariaLabel) selectors.ariaLabel = el.ariaLabel;
2907
+ if (el.label) selectors.label = el.label;
2908
+ if (el.id) selectors.css = `#${el.id}`;
2909
+ if (el.name) selectors.name = `[name="${el.name}"]`;
2910
+ if (el.placeholder) selectors.placeholder = el.placeholder;
2911
+ if (el.text) selectors.text = el.text;
2912
+ if (el.role) selectors.role = el.role;
2913
+ return {
2914
+ tag: el.tag,
2915
+ ...el.type ? { type: el.type } : {},
2916
+ selectors,
2917
+ ...el.role ? { role: el.role } : {},
2918
+ ...el.label ? { label: el.label } : {},
2919
+ ...el.placeholder ? { placeholder: el.placeholder } : {},
2920
+ required: el.required,
2921
+ ...el.formIndex >= 0 ? { formGroup: el.formIndex } : {}
2922
+ };
2923
+ });
2924
+ const links = raw.elements.filter((el) => el.tag === "a" && el.href).map((el) => {
2925
+ let selector;
2926
+ if (el.testId) {
2927
+ selector = `[data-testid="${el.testId}"]`;
2928
+ } else if (el.href) {
2929
+ selector = `a[href="${el.href}"]`;
2930
+ } else {
2931
+ selector = `a`;
2932
+ }
2933
+ return {
2934
+ text: el.text || "",
2935
+ href: el.href,
2936
+ selector
2937
+ };
2938
+ });
2939
+ const forms = raw.forms;
2940
+ return {
2941
+ url: raw.url,
2942
+ title: raw.title,
2943
+ elements,
2944
+ forms,
2945
+ links
2946
+ };
2947
+ }
2948
+
2949
+ // src/generator/index.ts
2950
+ import yaml from "yaml";
2951
+ import { chromium as chromium2 } from "playwright";
2952
+
2953
+ // src/generator/prompt.ts
2954
+ var STEP_REFERENCE = `
2955
+ ## Prowl Step Types
2956
+
2957
+ ### Navigation & Waiting
2958
+ - navigate: "/path" \u2014 navigate to URL (relative to target)
2959
+ - wait: "Text" \u2014 wait for text to appear
2960
+ - wait: { for: "Text", timeout: 5000 } \u2014 with timeout
2961
+ - waitForSelector: { selector: "#el", timeout: 5000 }
2962
+ - waitForUrl: { value: "/path", timeout: 5000 }
2963
+ - waitForNetworkIdle: { timeout: 5000 }
2964
+
2965
+ ### Interaction
2966
+ - click: "Button Text" \u2014 click by text (tries role=button first)
2967
+ - click: { selector: "[data-testid=btn]" } \u2014 click by selector
2968
+ - fill: { "Label": "value" } \u2014 fill by label/placeholder
2969
+ - fill: { selector: "#input", value: "text" } \u2014 fill by selector
2970
+ - type: "text" \u2014 type into focused element
2971
+ - press: { selector: "#input", key: "Enter" }
2972
+ - hover: { selector: "#menu" }
2973
+ - selectOption: { selector: "select", value: "option" }
2974
+ - select: { "Label": "value" } \u2014 select by label
2975
+ - setInputFiles: { selector: "#file", files: "path.png" }
2976
+ - onDialog: { action: "accept" } \u2014 handle browser dialogs
2977
+
2978
+ ### Assertions
2979
+ - assert: { visible: "Text" }
2980
+ - assert: { notVisible: "Error" }
2981
+ - assert: { urlIncludes: "/dashboard" }
2982
+ - assert: { urlEquals: "https://..." }
2983
+
2984
+ ### Scrolling & Screenshots
2985
+ - scroll: { direction: "down", amount: 500 }
2986
+ - scrollTo: { selector: "#section" }
2987
+ - screenshot: { name: "step-name" }
2988
+
2989
+ ### Script Execution
2990
+ - evalScript: "document.title" \u2014 evaluate JS expression
2991
+ - evalScript: { expression: "expr", as: "VAR" } \u2014 capture to variable
2992
+ - runScript: { file: "scripts/setup.js" }
2993
+
2994
+ ### Visual Regression
2995
+ - assertScreenshot: { name: "baseline-name", threshold: 0.1 }
2996
+
2997
+ ### Control Flow
2998
+ - if: { visible: ".banner", then: [steps...] }
2999
+ - repeat: { times: 3, steps: [steps...] }
3000
+ - repeat: { while: { visible: ".more" }, maxIterations: 10, steps: [steps...] }
3001
+ - runHunt: "other-hunt" \u2014 run another hunt file
3002
+ - mockRoute: { url: "**/api/data", response: { status: 200, body: "{}" } }
3003
+ - unmockRoute: { url: "**/api/data" }
3004
+ `.trim();
3005
+ function buildGenerationPrompt(analysis, intent) {
3006
+ return `You are a QA test generator for Prowl. Generate a YAML hunt file that tests the described intent using the page analysis data below.
3007
+
3008
+ ${STEP_REFERENCE}
3009
+
3010
+ ## Page Analysis
3011
+ \`\`\`json
3012
+ ${JSON.stringify(analysis, null, 2)}
3013
+ \`\`\`
3014
+
3015
+ ## Test Intent
3016
+ ${intent}
3017
+
3018
+ ## Instructions
3019
+ 1. Output ONLY a valid Prowl YAML hunt between \`\`\`yaml fences
3020
+ 2. Use shorthand syntax when possible (click: "Text", fill: { "Label": "value" })
3021
+ 3. Prefer stable selectors: data-testid > aria-label > text > CSS selectors
3022
+ 4. Include assertions to verify expected outcomes
3023
+ 5. Add a descriptive name and description
3024
+ 6. Keep steps focused and minimal \u2014 test exactly what the intent describes
3025
+
3026
+ \`\`\`yaml
3027
+ `;
3028
+ }
3029
+ function extractYamlFromResponse(response) {
3030
+ const fenceMatch = response.match(/```ya?ml\n?([\s\S]*?)```/);
3031
+ if (fenceMatch) {
3032
+ return fenceMatch[1].trim();
3033
+ }
3034
+ return response.trim();
3035
+ }
3036
+
3037
+ // src/generator/ai.ts
3038
+ function resolveAiConfig() {
3039
+ const provider = process.env.PROWL_AI_PROVIDER ?? "anthropic";
3040
+ if (provider !== "anthropic" && provider !== "openai") {
3041
+ throw new Error(`Unsupported AI provider: ${provider}. Use "anthropic" or "openai".`);
3042
+ }
3043
+ const apiKey = process.env.PROWL_AI_KEY;
3044
+ if (!apiKey) {
3045
+ throw new Error(
3046
+ "PROWL_AI_KEY environment variable is required. Set it to your Anthropic or OpenAI API key."
3047
+ );
3048
+ }
3049
+ const defaultModel = provider === "anthropic" ? "claude-sonnet-4-5-20250929" : "gpt-4o";
3050
+ const model = process.env.PROWL_AI_MODEL ?? defaultModel;
3051
+ return { provider, model, apiKey };
3052
+ }
3053
+ async function generateWithAi(prompt, config) {
3054
+ if (config.provider === "anthropic") {
3055
+ return generateWithAnthropic(prompt, config);
3056
+ }
3057
+ return generateWithOpenAi(prompt, config);
3058
+ }
3059
+ async function generateWithAnthropic(prompt, config) {
3060
+ const response = await fetch("https://api.anthropic.com/v1/messages", {
3061
+ method: "POST",
3062
+ headers: {
3063
+ "Content-Type": "application/json",
3064
+ "x-api-key": config.apiKey,
3065
+ "anthropic-version": "2023-06-01"
3066
+ },
3067
+ body: JSON.stringify({
3068
+ model: config.model,
3069
+ max_tokens: 4096,
3070
+ messages: [
3071
+ { role: "user", content: prompt }
3072
+ ]
3073
+ })
3074
+ });
3075
+ if (!response.ok) {
3076
+ const body = await response.text();
3077
+ throw new Error(`Anthropic API error (${response.status}): ${body}`);
3078
+ }
3079
+ const data = await response.json();
3080
+ const textBlock = data.content.find((c) => c.type === "text");
3081
+ if (!textBlock?.text) {
3082
+ throw new Error("Anthropic API returned no text content");
3083
+ }
3084
+ return textBlock.text;
3085
+ }
3086
+ async function generateWithOpenAi(prompt, config) {
3087
+ const response = await fetch("https://api.openai.com/v1/chat/completions", {
3088
+ method: "POST",
3089
+ headers: {
3090
+ "Content-Type": "application/json",
3091
+ "Authorization": `Bearer ${config.apiKey}`
3092
+ },
3093
+ body: JSON.stringify({
3094
+ model: config.model,
3095
+ messages: [
3096
+ { role: "user", content: prompt }
3097
+ ],
3098
+ max_tokens: 4096
3099
+ })
3100
+ });
3101
+ if (!response.ok) {
3102
+ const body = await response.text();
3103
+ throw new Error(`OpenAI API error (${response.status}): ${body}`);
3104
+ }
3105
+ const data = await response.json();
3106
+ if (!data.choices?.[0]?.message?.content) {
3107
+ throw new Error("OpenAI API returned no content");
3108
+ }
3109
+ return data.choices[0].message.content;
3110
+ }
3111
+
3112
+ // src/generator/index.ts
3113
+ async function generateHunt(options) {
3114
+ let analysis = options.analysis;
3115
+ if (!analysis && options.url) {
3116
+ const browser = await chromium2.launch({ headless: true });
3117
+ const context = await browser.newContext();
3118
+ const page = await context.newPage();
3119
+ try {
3120
+ await page.goto(options.url, { waitUntil: "networkidle" });
3121
+ analysis = await analyzePage(page);
3122
+ } finally {
3123
+ await context.close();
3124
+ await browser.close();
3125
+ }
3126
+ }
3127
+ if (!analysis) {
3128
+ throw new Error("Either --url or piped analysis JSON is required");
3129
+ }
3130
+ const config = options.aiConfig ?? resolveAiConfig();
3131
+ const prompt = buildGenerationPrompt(analysis, options.intent);
3132
+ const response = await generateWithAi(prompt, config);
3133
+ const yamlStr = extractYamlFromResponse(response);
3134
+ const parsed = yaml.parse(yamlStr);
3135
+ huntSchema.parse(parsed);
3136
+ return yamlStr;
3137
+ }
3138
+
3139
+ export {
3140
+ interpolateHunt,
3141
+ extractSelectorIntent,
3142
+ buildHealCandidates,
3143
+ healSelector,
3144
+ readHistory,
3145
+ readHuntHistory,
3146
+ runHunt,
3147
+ printCiSummary,
3148
+ DEFAULT_FLAKY_THRESHOLD,
3149
+ computeFlakeScore,
3150
+ rankFlaky,
3151
+ clusterFailures,
3152
+ extractFailures,
3153
+ updateBacklogFromSuite,
3154
+ runSuite,
3155
+ analyzePage,
3156
+ generateHunt
3157
+ };
3158
+ //# sourceMappingURL=chunk-T7YLXF6X.js.map