prowl-tools 0.1.8 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -145,23 +145,33 @@ prowl init
145
145
 
146
146
  <!-- ILLUSTRATION: Terminal screenshot showing `prowl init` output with raccoon mascot and file listing -->
147
147
 
148
- This creates a `.prowl/` directory with a config file and two starter hunts:
148
+ This creates a `.prowl/` directory with a config file and four starter hunts:
149
149
 
150
150
  ```text
151
151
  .prowl/
152
- ├── config.yml # Target URL, browser settings, guardrails
153
- ├── .gitignore # Keeps runs/, auth-state.json, and .env out of git
152
+ ├── config.yml # Target URL, browser settings, guardrails
153
+ ├── .gitignore # Keeps runs/, auth-state.json, and .env out of git
154
154
  └── hunts/
155
- ├── hello.yml # Minimal smoke test — verifies the app loads
156
- └── login-flow.yml # Fuller example — auth, secrets, assertions
155
+ ├── hello.yml # Minimal smoke test — verifies the app loads
156
+ ├── login-flow.yml # Auth example — fill credentials, verify redirect
157
+ ├── form.yml # Forms example — fill, select, submit, assert
158
+ └── macos-hello.yml # Desktop starter — drive TextEdit (macOS, experimental)
157
159
  ```
158
160
 
161
+ The web starters (`hello`, `login-flow`, `form`) run against the default web
162
+ target as soon as you point `config.yml` at your app. `macos-hello` is the
163
+ desktop-first first-run hunt: it drives TextEdit through the Accessibility API,
164
+ and its inline comments walk you through switching the target to `macos` and the
165
+ one-time setup (`prowl macdriver install`, Accessibility permission). The macOS
166
+ target is experimental — see [macOS Target](#macos-target-experimental).
167
+
159
168
  `prowl init` finishes by pointing you at the first hunt:
160
169
 
161
170
  ```text
162
171
  Initialized .prowl directory.
163
172
  Run prowl run hello to get started.
164
- See .prowl/hunts/login-flow.yml for a fuller example.
173
+ See .prowl/hunts/login-flow.yml (auth) and .prowl/hunts/form.yml (web forms) for fuller examples.
174
+ Desktop-first? .prowl/hunts/macos-hello.yml is a macOS starter (experimental — see its comments to enable).
165
175
  ```
166
176
 
167
177
  ### 3. Configure
@@ -379,7 +389,7 @@ macOS target (with a clear error), and `prowl login` / URL guardrails do not app
379
389
 
380
390
  | Portable (web + macOS) | Web-only (rejected on macOS) |
381
391
  |---|---|
382
- | `click`, `fill`, `type`, `press` | `navigate`, `waitForUrl`, `waitForNetworkIdle` |
392
+ | `click`, `fill`, `type`, `press` | `navigate`, `waitForUrl`, `waitForNetworkIdle`, `waitForResponse` |
383
393
  | `wait`, `waitForSelector` | `mockRoute` / `unmockRoute` |
384
394
  | `assert: visible` / `notVisible` | `evalScript`, `runScript` |
385
395
  | `screenshot`, `assertScreenshot` | `onDialog`, `select` / `selectOption` |
@@ -655,6 +665,21 @@ Wait for all network requests to complete.
655
665
  timeout: 5000
656
666
  ```
657
667
 
668
+ ### waitForResponse
669
+
670
+ Wait for a specific network response before continuing — more precise than `waitForNetworkIdle`, which waits for *all* traffic to settle.
671
+
672
+ ```yaml
673
+ - waitForResponse:
674
+ url: "**/api/orders"
675
+ status: 200
676
+ timeout: 10000
677
+ ```
678
+
679
+ - `url` (required) is matched against each response URL as a glob: `*`/`**` match any run of characters (including `/`) and `?` matches a single character. Matching is unanchored, so a pattern with no wildcards behaves as a substring match (e.g. `/api/orders` matches `https://shop.test/api/orders?page=2`).
680
+ - `status` (optional) resolves the step only on a response with that exact HTTP status.
681
+ - `timeout` (optional, milliseconds) fails the step with a clear message if no matching response arrives in time.
682
+
658
683
  ### onDialog
659
684
 
660
685
  Handle browser-native dialogs (alert, confirm, prompt). Register the handler **before** the action that triggers the dialog.
@@ -1004,6 +1029,39 @@ tracing:
1004
1029
  This is a correlation bridge only — Prowl does not generate or propagate its own
1005
1030
  spans. When the app emits no trace headers, nothing is recorded (no noise).
1006
1031
 
1032
+ ### Retry Diagnostics (why a retried hunt passed)
1033
+
1034
+ When a hunt sets a `retry` block and eventually passes — or exhausts its
1035
+ attempts — Prowl records what each attempt did instead of retrying silently:
1036
+
1037
+ ```yaml
1038
+ # In a hunt file
1039
+ retry:
1040
+ maxRetries: 2 # up to 2 extra attempts (3 total)
1041
+ delay: 500 # optional ms to wait between attempts
1042
+ ```
1043
+
1044
+ The final attempt's `result.json` (and the returned run result) then carries:
1045
+
1046
+ - `retryHistory` — one record per attempt: its `attempt` number, `status`,
1047
+ `durationMs`, the first `failedStep` (`index` + `type`), and the `error`.
1048
+ - `retrySummary` — a one-line headline, e.g.
1049
+ `Passed on attempt 2 of 3 — first failure: navigate (timeout)`, or
1050
+ `Failed after 3 attempts — first failure: …` when the retries were exhausted.
1051
+
1052
+ Both fields appear **only** when more than one attempt ran, so a hunt that passes
1053
+ on the first try (and any run artifact written before this feature) stays
1054
+ unchanged and keeps parsing. `summary.md` gains a matching **Retries** section
1055
+ with the headline and a per-attempt breakdown, and the `prowl run` summary prints
1056
+ the headline. JUnit continues to report the final attempt's outcome — the
1057
+ per-attempt history lives in `result.json`, not the XML — so a retried-and-passed
1058
+ hunt is a green testcase for CI while the diagnostics remain available alongside
1059
+ it.
1060
+
1061
+ This is what lets you distinguish a **flaky test** (passes on retry, intermittent
1062
+ first-attempt failures) from a **slow environment** (retries needed but the same
1063
+ step lags) from a **real regression** (all attempts fail the same way).
1064
+
1007
1065
  ---
1008
1066
 
1009
1067
  ## CLI Reference
@@ -1035,6 +1093,10 @@ prowl login
1035
1093
  prowl init
1036
1094
  prowl init --force # Overwrite existing
1037
1095
 
1096
+ # Doctor — check the environment is ready to run hunts
1097
+ prowl doctor
1098
+ prowl doctor --fix # Install Chromium / scaffold .prowl if missing
1099
+
1038
1100
  # List available hunts
1039
1101
  prowl list
1040
1102
 
@@ -1058,13 +1120,36 @@ prowl mcp --projects ~/.prowl/projects.yml # Drive multiple repos via a regist
1058
1120
  - Must be a positive integer (`>= 1`).
1059
1121
  - Invalid values (for example `0` or `1.5`) fail fast with an argument error.
1060
1122
 
1123
+ ### Environment Check (`prowl doctor`)
1124
+
1125
+ `prowl doctor` verifies your machine is ready to run hunts and prints a
1126
+ color-coded report (green ✓ pass, yellow ⚠ warning, red ✗ failure, gray ○
1127
+ skipped) with a one-line summary. It checks:
1128
+
1129
+ - **Node.js** is version 20 or newer.
1130
+ - **Playwright** is installed, and its **Chromium** browser is available.
1131
+ - A **`.prowl/` directory** exists with a **valid `config.yml`**.
1132
+ - **Target tooling for your configured target only** (desktop-first): the macOS
1133
+ target reuses `prowl macdriver status` to report which helper binary resolved;
1134
+ the iOS target checks that `xcrun simctl` is reachable (macOS only); the
1135
+ Android target checks that `adb` is reachable. Missing native tooling prints
1136
+ the install command — `doctor` never installs system tools for you.
1137
+
1138
+ Warnings do not fail the command; only real failures do (exit code `1`, so it
1139
+ slots into CI). `prowl doctor --fix` performs just the two safe repairs —
1140
+ installing Chromium through Prowl's resolved Playwright dependency and
1141
+ scaffolding a missing `.prowl/` (the same templates `prowl init` writes) — then
1142
+ re-runs the checks and reports the new status. Everything else prints a manual
1143
+ remedy.
1144
+
1061
1145
  ### Run History
1062
1146
 
1063
1147
  Every `prowl run` and `prowl ci` appends an entry to `.prowl/history.json`
1064
- with the hunt name, status, start time, duration, and run directory. Retention
1065
- is capped per hunt by `history.maxRuns` (default 100) — once a hunt exceeds the
1066
- cap, its oldest entries are dropped on the next write. Other hunts are not
1067
- affected.
1148
+ with the hunt name, status, start time, duration, run directory, and — when the
1149
+ run used `retry` — a `retries` count (the number of extra attempts beyond the
1150
+ first; omitted when the run passed on its first attempt). Retention is capped
1151
+ per hunt by `history.maxRuns` (default 100) — once a hunt exceeds the cap, its
1152
+ oldest entries are dropped on the next write. Other hunts are not affected.
1068
1153
 
1069
1154
  ```yaml
1070
1155
  # In .prowl/config.yml
@@ -1073,7 +1158,11 @@ history:
1073
1158
  ```
1074
1159
 
1075
1160
  Use `prowl history <hunt-name>` for a quick status/duration table, or
1076
- `--json` to feed the entries into dashboards, flake detectors, or agents.
1161
+ `--json` to feed the entries into dashboards, flake detectors, or agents. The
1162
+ table includes a **Retries** column and a retry-frequency line
1163
+ (`Retried in N of M runs`) so you can tell a flaky hunt apart from a slow
1164
+ environment or a real regression at a glance. History from before retry
1165
+ tracking (entries without a `retries` field) still loads and reads as `0`.
1077
1166
 
1078
1167
  ### Failure Clustering
1079
1168
 
@@ -1359,7 +1448,7 @@ Portable steps run on the Android target; web-only steps are rejected up front
1359
1448
 
1360
1449
  | Portable (Android) | Not supported on Android |
1361
1450
  |---|---|
1362
- | `click`, `fill`, `type`, `press` | `navigate`, `waitForUrl`, `waitForNetworkIdle` |
1451
+ | `click`, `fill`, `type`, `press` | `navigate`, `waitForUrl`, `waitForNetworkIdle`, `waitForResponse` |
1363
1452
  | `wait`, `waitForSelector` | `mockRoute` / `unmockRoute`, `evalScript`, `runScript` |
1364
1453
  | `assert: visible` / `notVisible` | `onDialog`, `select` / `selectOption`, `setInputFiles` |
1365
1454
  | `screenshot`, `assertScreenshot` | `waitForDownload`, `scroll`, `assert: urlIncludes` / `urlEquals` |
@@ -1509,7 +1598,7 @@ clear error), and URL guardrails do not apply.
1509
1598
 
1510
1599
  | Portable (iOS) | Not supported on iOS |
1511
1600
  |---|---|
1512
- | `click`, `fill`, `type`, `press` | `navigate`, `waitForUrl`, `waitForNetworkIdle` |
1601
+ | `click`, `fill`, `type`, `press` | `navigate`, `waitForUrl`, `waitForNetworkIdle`, `waitForResponse` |
1513
1602
  | `wait`, `waitForSelector` | `mockRoute` / `unmockRoute`, `evalScript`, `runScript` |
1514
1603
  | `assert: visible` / `notVisible` | `onDialog`, `select` / `selectOption`, `setInputFiles` |
1515
1604
  | `screenshot`, `assertScreenshot` | `waitForDownload`, `scroll`, `assert: urlIncludes` / `urlEquals` |
@@ -8,7 +8,7 @@ import {
8
8
  loadHunt,
9
9
  loadHuntTags,
10
10
  resolveViewport
11
- } from "./chunk-JFJQNJSJ.js";
11
+ } from "./chunk-TTZ2IL3P.js";
12
12
 
13
13
  // src/config/target.ts
14
14
  import { execFileSync } from "child_process";
@@ -18,6 +18,7 @@ var WEB_ONLY_STEP_TYPES = /* @__PURE__ */ new Set([
18
18
  "navigate",
19
19
  "waitForUrl",
20
20
  "waitForNetworkIdle",
21
+ "waitForResponse",
21
22
  "mockRoute",
22
23
  "unmockRoute",
23
24
  "evalScript",
@@ -372,6 +373,9 @@ function createMacDriver(client, options = {}) {
372
373
  waitForNetworkIdle() {
373
374
  return rejectUnsupported("waitForNetworkIdle");
374
375
  },
376
+ waitForResponse() {
377
+ return rejectUnsupported("waitForResponse");
378
+ },
375
379
  // scripting & artifacts ------------------------------------------------
376
380
  evaluate() {
377
381
  return rejectUnsupported("evalScript");
@@ -499,10 +503,15 @@ ${macdriverBuildInstructions()}`
499
503
  ${macdriverBuildInstructions()}`);
500
504
  }
501
505
  var DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
506
+ function errorMessage(error) {
507
+ return error instanceof Error ? error.message : String(error);
508
+ }
502
509
  var SpawnMacHelperClient = class {
503
510
  child;
504
511
  pending = /* @__PURE__ */ new Map();
505
512
  requestTimeoutMs;
513
+ onEvent;
514
+ onEventError;
506
515
  stdoutBuffer = "";
507
516
  stderrBuffer = "";
508
517
  nextId = 1;
@@ -510,6 +519,9 @@ var SpawnMacHelperClient = class {
510
519
  terminalError;
511
520
  constructor(binaryPath, options = {}) {
512
521
  this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
522
+ this.onEvent = options.onEvent;
523
+ this.onEventError = options.onEventError ?? ((message) => process.stderr.write(`${message}
524
+ `));
513
525
  this.child = spawn(binaryPath, ["serve"], { stdio: ["pipe", "pipe", "pipe"] });
514
526
  this.child.stdout?.setEncoding("utf-8");
515
527
  this.child.stderr?.setEncoding("utf-8");
@@ -546,6 +558,10 @@ var SpawnMacHelperClient = class {
546
558
  } catch {
547
559
  return;
548
560
  }
561
+ if (typeof message.event === "string" && typeof message.id !== "number") {
562
+ this.handleEvent(message);
563
+ return;
564
+ }
549
565
  const id = typeof message.id === "number" ? message.id : void 0;
550
566
  if (id === void 0) {
551
567
  return;
@@ -562,6 +578,18 @@ var SpawnMacHelperClient = class {
562
578
  pending.reject(new Error(typeof message.error === "string" ? message.error : "prowl-macdriver error"));
563
579
  }
564
580
  }
581
+ handleEvent(event) {
582
+ try {
583
+ this.onEvent?.(event);
584
+ } catch (error) {
585
+ try {
586
+ this.onEventError(
587
+ `prowl-macdriver event sink failed for event "${event.event}": ${errorMessage(error)}`
588
+ );
589
+ } catch {
590
+ }
591
+ }
592
+ }
565
593
  failAll(error) {
566
594
  for (const pending of this.pending.values()) {
567
595
  clearTimeout(pending.timer);
@@ -1270,6 +1298,9 @@ function createAndroidDriver(client, options = {}) {
1270
1298
  waitForNetworkIdle() {
1271
1299
  return rejectUnsupported("waitForNetworkIdle");
1272
1300
  },
1301
+ waitForResponse() {
1302
+ return rejectUnsupported("waitForResponse");
1303
+ },
1273
1304
  // scripting & artifacts ------------------------------------------------
1274
1305
  evaluate() {
1275
1306
  return rejectUnsupported("evalScript");
@@ -2132,6 +2163,9 @@ function createIosDriver(client, options) {
2132
2163
  waitForNetworkIdle() {
2133
2164
  return rejectUnsupported("waitForNetworkIdle");
2134
2165
  },
2166
+ waitForResponse() {
2167
+ return rejectUnsupported("waitForResponse");
2168
+ },
2135
2169
  // scripting & artifacts ------------------------------------------------
2136
2170
  evaluate() {
2137
2171
  return rejectUnsupported("evalScript");
@@ -3056,7 +3090,8 @@ function isHistoryEntry(value) {
3056
3090
  return false;
3057
3091
  }
3058
3092
  const entry = value;
3059
- 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");
3093
+ 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") && // Optional (PROWL-033): older history.json files predate `retries`.
3094
+ (entry.retries === void 0 || typeof entry.retries === "number" && Number.isInteger(entry.retries) && entry.retries >= 0);
3060
3095
  }
3061
3096
  function readHistory(configDir) {
3062
3097
  const filePath = historyPath(configDir);
@@ -3327,6 +3362,9 @@ function createPlaywrightDriver(page) {
3327
3362
  async waitForNetworkIdle(options) {
3328
3363
  await page.waitForLoadState("networkidle", { timeout: options?.timeout });
3329
3364
  },
3365
+ async waitForResponse(predicate, options) {
3366
+ await page.waitForResponse((response) => predicate(response), { timeout: options?.timeout });
3367
+ },
3330
3368
  evaluate(pageFunction, arg) {
3331
3369
  const raw = page.evaluate;
3332
3370
  const result = arg === void 0 ? raw.call(page, pageFunction) : raw.call(page, pageFunction, arg);
@@ -3734,6 +3772,7 @@ function getStepType(step) {
3734
3772
  if ("waitForSelector" in step) return "waitForSelector";
3735
3773
  if ("waitForUrl" in step) return "waitForUrl";
3736
3774
  if ("waitForNetworkIdle" in step) return "waitForNetworkIdle";
3775
+ if ("waitForResponse" in step) return "waitForResponse";
3737
3776
  if ("hover" in step) return "hover";
3738
3777
  if ("scroll" in step) return "scroll";
3739
3778
  if ("scrollTo" in step) return "scrollTo";
@@ -3750,6 +3789,11 @@ function getStepType(step) {
3750
3789
  if ("waitForDownload" in step) return "waitForDownload";
3751
3790
  return "step";
3752
3791
  }
3792
+ function compileResponseUrlMatcher(pattern) {
3793
+ const regexBody = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\\\*/g, ".*").replace(/\\\?/g, ".");
3794
+ const regex = new RegExp(regexBody);
3795
+ return (url) => regex.test(url);
3796
+ }
3753
3797
  var RUNTIME_VAR_PATTERN = /\{\{([A-Z0-9_]+)\}\}/g;
3754
3798
  function substituteRuntimeVars(input, vars) {
3755
3799
  return input.replace(RUNTIME_VAR_PATTERN, (match, name) => {
@@ -3788,6 +3832,15 @@ function applyRuntimeVars(step, vars) {
3788
3832
  if ("waitForSelector" in step) {
3789
3833
  return { waitForSelector: { selector: sub(step.waitForSelector.selector), timeout: step.waitForSelector.timeout } };
3790
3834
  }
3835
+ if ("waitForResponse" in step) {
3836
+ return {
3837
+ waitForResponse: {
3838
+ url: sub(step.waitForResponse.url),
3839
+ ...step.waitForResponse.status !== void 0 ? { status: step.waitForResponse.status } : {},
3840
+ ...step.waitForResponse.timeout !== void 0 ? { timeout: step.waitForResponse.timeout } : {}
3841
+ }
3842
+ };
3843
+ }
3791
3844
  if ("evalScript" in step) {
3792
3845
  if (typeof step.evalScript === "string") return { evalScript: sub(step.evalScript) };
3793
3846
  return {
@@ -4397,6 +4450,32 @@ var STEP_HANDLERS = {
4397
4450
  };
4398
4451
  }
4399
4452
  },
4453
+ waitForResponse: {
4454
+ capabilities: ["response", "wait"],
4455
+ run: async (h) => {
4456
+ if (!("waitForResponse" in h.step)) unknownStep();
4457
+ const { url: pattern, status, timeout } = h.step.waitForResponse;
4458
+ const matchesResponseUrl = compileResponseUrlMatcher(pattern);
4459
+ try {
4460
+ await h.driver.waitForResponse(
4461
+ (response) => matchesResponseUrl(response.url()) && (status === void 0 || response.status() === status),
4462
+ { timeout }
4463
+ );
4464
+ } catch (err) {
4465
+ const isTimeout = err instanceof Error && (err.name === "TimeoutError" || /timeout/i.test(err.message));
4466
+ if (!isTimeout) throw err;
4467
+ const statusPart = status !== void 0 ? ` with status ${status}` : "";
4468
+ const timeoutPart = timeout !== void 0 ? ` within ${timeout}ms` : "";
4469
+ throw new Error(
4470
+ `waitForResponse: no response matching "${pattern}"${statusPart} was received${timeoutPart}.`
4471
+ );
4472
+ }
4473
+ return {
4474
+ kind: "result",
4475
+ result: { type: "waitForResponse", status: "pass", durationMs: Date.now() - h.stepStart, value: pattern }
4476
+ };
4477
+ }
4478
+ },
4400
4479
  waitForNetworkIdle: {
4401
4480
  capabilities: ["wait"],
4402
4481
  run: async (h) => {
@@ -5187,6 +5266,15 @@ function formatStep(step) {
5187
5266
  const error = step.error ? ` error=${escapeMd(step.error)}` : "";
5188
5267
  return `${base}${selector}${healed}${value}${error}`;
5189
5268
  }
5269
+ function formatRetryAttempt(attempt) {
5270
+ const base = `- attempt ${attempt.attempt}: ${attempt.status.toUpperCase()} (${attempt.durationMs}ms)`;
5271
+ if (attempt.status === "pass") {
5272
+ return base;
5273
+ }
5274
+ const step = attempt.failedStep ? ` failed-step=[${attempt.failedStep.index}] ${escapeMd(attempt.failedStep.type)}` : "";
5275
+ const error = attempt.error ? ` error=${escapeMd(attempt.error)}` : "";
5276
+ return `${base}${step}${error}`;
5277
+ }
5190
5278
  function formatAssertion(assertion) {
5191
5279
  const value = assertion.value !== void 0 ? ` value=${typeof assertion.value === "string" ? escapeMd(assertion.value) : assertion.value}` : "";
5192
5280
  const error = assertion.error ? ` error=${escapeMd(assertion.error)}` : "";
@@ -5201,6 +5289,16 @@ function writeSummary(runDir, result) {
5201
5289
  lines.push(`Target: ${result.targetUrl}`);
5202
5290
  lines.push(`Started: ${result.startedAt}`);
5203
5291
  lines.push(`Duration: ${result.durationMs}ms`);
5292
+ if (result.retryHistory && result.retryHistory.length > 1) {
5293
+ lines.push("");
5294
+ lines.push("## Retries");
5295
+ if (result.retrySummary) {
5296
+ lines.push(escapeMd(result.retrySummary));
5297
+ }
5298
+ for (const attempt of result.retryHistory) {
5299
+ lines.push(formatRetryAttempt(attempt));
5300
+ }
5301
+ }
5204
5302
  lines.push("");
5205
5303
  lines.push("## Steps");
5206
5304
  for (const step of result.steps) {
@@ -5511,6 +5609,60 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
5511
5609
  function delay3(ms) {
5512
5610
  return new Promise((resolve) => setTimeout(resolve, ms));
5513
5611
  }
5612
+ function toRetryAttempt(attempt, result) {
5613
+ const record = {
5614
+ attempt,
5615
+ status: result.status,
5616
+ durationMs: result.durationMs
5617
+ };
5618
+ if (result.status === "fail") {
5619
+ const failedIndex = result.steps.findIndex((step) => step.status === "fail");
5620
+ if (failedIndex >= 0) {
5621
+ const failed = result.steps[failedIndex];
5622
+ record.failedStep = { index: failedIndex, type: failed.type };
5623
+ if (failed.error) {
5624
+ record.error = failed.error;
5625
+ }
5626
+ }
5627
+ if (!record.error) {
5628
+ const failedAssertion = result.assertions.find((assertion) => assertion.status === "fail");
5629
+ if (failedAssertion?.error) {
5630
+ record.error = failedAssertion.error;
5631
+ }
5632
+ }
5633
+ }
5634
+ return record;
5635
+ }
5636
+ function describeAttemptFailure(attempt) {
5637
+ if (attempt.status !== "fail") {
5638
+ return void 0;
5639
+ }
5640
+ const step = attempt.failedStep?.type;
5641
+ if (step && attempt.error) {
5642
+ return `${step} (${attempt.error})`;
5643
+ }
5644
+ return step ?? attempt.error;
5645
+ }
5646
+ function buildRetrySummary(attempts, maxRetries) {
5647
+ const final = attempts[attempts.length - 1];
5648
+ const firstFailure = describeAttemptFailure(attempts[0]);
5649
+ const suffix = firstFailure ? ` \u2014 first failure: ${firstFailure}` : "";
5650
+ if (final.status === "pass") {
5651
+ return `Passed on attempt ${final.attempt} of ${maxRetries + 1}${suffix}`;
5652
+ }
5653
+ return `Failed after ${attempts.length} attempts${suffix}`;
5654
+ }
5655
+ function finalizeRetryOutcome(outcome, attempts, maxRetries, junit) {
5656
+ outcome.result = writeReports(
5657
+ outcome.runDir,
5658
+ {
5659
+ ...outcome.result,
5660
+ retryHistory: attempts,
5661
+ retrySummary: buildRetrySummary(attempts, maxRetries)
5662
+ },
5663
+ { junit }
5664
+ );
5665
+ }
5514
5666
  async function runHunt(options) {
5515
5667
  const { config, configDir } = loadConfig(options.configPath);
5516
5668
  if (config.target.type === "macos") {
@@ -5540,7 +5692,9 @@ async function runHunt(options) {
5540
5692
  }
5541
5693
  const maxRetries = hunt.retry?.maxRetries ?? 0;
5542
5694
  const retryDelay = hunt.retry?.delay ?? 0;
5695
+ const junit = options.junit ?? config.artifacts.junit;
5543
5696
  let lastResult;
5697
+ const attempts = [];
5544
5698
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
5545
5699
  if (attempt > 0 && retryDelay > 0) {
5546
5700
  await delay3(retryDelay);
@@ -5556,19 +5710,20 @@ async function runHunt(options) {
5556
5710
  targetUrl,
5557
5711
  allowedDomains
5558
5712
  );
5713
+ attempts.push(toRetryAttempt(attempt + 1, lastResult.result));
5559
5714
  if (lastResult.result.status === "pass") {
5560
- if (attempt > 0) {
5561
- lastResult.result.artifacts.summary = `Passed on attempt ${attempt + 1} of ${maxRetries + 1}`;
5715
+ if (attempts.length > 1) {
5716
+ finalizeRetryOutcome(lastResult, attempts, maxRetries, junit);
5562
5717
  }
5563
- recordHistory(configDir, lastResult, config.history.maxRuns);
5718
+ recordHistory(configDir, lastResult, config.history.maxRuns, attempts.length - 1);
5564
5719
  return lastResult;
5565
5720
  }
5566
5721
  }
5567
- if (maxRetries > 0 && lastResult) {
5568
- lastResult.result.artifacts.summary = `Failed after ${maxRetries + 1} attempts`;
5569
- }
5570
5722
  if (lastResult) {
5571
- recordHistory(configDir, lastResult, config.history.maxRuns);
5723
+ if (attempts.length > 1) {
5724
+ finalizeRetryOutcome(lastResult, attempts, maxRetries, junit);
5725
+ }
5726
+ recordHistory(configDir, lastResult, config.history.maxRuns, attempts.length - 1);
5572
5727
  }
5573
5728
  return lastResult;
5574
5729
  }
@@ -5772,7 +5927,9 @@ async function runNativeHunt(options, config, configDir, target, native) {
5772
5927
  }
5773
5928
  const maxRetries = hunt.retry?.maxRetries ?? 0;
5774
5929
  const retryDelay = hunt.retry?.delay ?? 0;
5930
+ const junit = options.junit ?? config.artifacts.junit;
5775
5931
  let lastResult;
5932
+ const attempts = [];
5776
5933
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
5777
5934
  if (attempt > 0 && retryDelay > 0) {
5778
5935
  await delay3(retryDelay);
@@ -5787,23 +5944,24 @@ async function runNativeHunt(options, config, configDir, target, native) {
5787
5944
  randomVars,
5788
5945
  config.guardrails.allowedApps
5789
5946
  );
5947
+ attempts.push(toRetryAttempt(attempt + 1, lastResult.result));
5790
5948
  if (lastResult.result.status === "pass") {
5791
- if (attempt > 0) {
5792
- lastResult.result.artifacts.summary = `Passed on attempt ${attempt + 1} of ${maxRetries + 1}`;
5949
+ if (attempts.length > 1) {
5950
+ finalizeRetryOutcome(lastResult, attempts, maxRetries, junit);
5793
5951
  }
5794
- recordHistory(configDir, lastResult, config.history.maxRuns);
5952
+ recordHistory(configDir, lastResult, config.history.maxRuns, attempts.length - 1);
5795
5953
  return lastResult;
5796
5954
  }
5797
5955
  }
5798
- if (maxRetries > 0 && lastResult) {
5799
- lastResult.result.artifacts.summary = `Failed after ${maxRetries + 1} attempts`;
5800
- }
5801
5956
  if (lastResult) {
5802
- recordHistory(configDir, lastResult, config.history.maxRuns);
5957
+ if (attempts.length > 1) {
5958
+ finalizeRetryOutcome(lastResult, attempts, maxRetries, junit);
5959
+ }
5960
+ recordHistory(configDir, lastResult, config.history.maxRuns, attempts.length - 1);
5803
5961
  }
5804
5962
  return lastResult;
5805
5963
  }
5806
- function recordHistory(configDir, outcome, maxRuns) {
5964
+ function recordHistory(configDir, outcome, maxRuns, retries = 0) {
5807
5965
  try {
5808
5966
  const relativeRunDir = path13.relative(configDir, outcome.runDir);
5809
5967
  appendEntry(
@@ -5813,7 +5971,9 @@ function recordHistory(configDir, outcome, maxRuns) {
5813
5971
  status: outcome.result.status,
5814
5972
  durationMs: outcome.result.durationMs,
5815
5973
  startedAt: outcome.result.startedAt,
5816
- runDir: relativeRunDir || void 0
5974
+ runDir: relativeRunDir || void 0,
5975
+ // Omit when no retry ran so entries stay additive/backward-compatible.
5976
+ retries: retries > 0 ? retries : void 0
5817
5977
  },
5818
5978
  maxRuns
5819
5979
  );
@@ -6915,6 +7075,7 @@ var STEP_REFERENCE = `
6915
7075
  - waitForSelector: { selector: "#el", timeout: 5000 }
6916
7076
  - waitForUrl: { value: "/path", timeout: 5000 }
6917
7077
  - waitForNetworkIdle: { timeout: 5000 }
7078
+ - waitForResponse: { url: "**/api/orders", status: 200, timeout: 10000 } \u2014 wait for a matching network response (url is a glob/substring; status is optional)
6918
7079
 
6919
7080
  ### Interaction
6920
7081
  - click: "Button Text" \u2014 click by text (tries role=button first)
@@ -7482,4 +7643,4 @@ export {
7482
7643
  collectMacdriverStatus,
7483
7644
  tccGuidance
7484
7645
  };
7485
- //# sourceMappingURL=chunk-CWLRDV5P.js.map
7646
+ //# sourceMappingURL=chunk-SRWAF2RI.js.map