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 +103 -14
- package/dist/{chunk-CWLRDV5P.js → chunk-SRWAF2RI.js} +180 -19
- package/dist/chunk-SRWAF2RI.js.map +1 -0
- package/dist/{chunk-JFJQNJSJ.js → chunk-TTZ2IL3P.js} +27 -1
- package/dist/chunk-TTZ2IL3P.js.map +1 -0
- package/dist/index.cjs +843 -75
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +622 -43
- package/dist/index.js.map +1 -1
- package/dist/lib.cjs +204 -17
- package/dist/lib.cjs.map +1 -1
- package/dist/lib.d.cts +72 -1
- package/dist/lib.d.ts +72 -1
- package/dist/lib.js +2 -2
- package/dist/{loader-JTHA4BYG.js → loader-CAE4FPBK.js} +2 -2
- package/examples/hunts/form.yml +53 -0
- package/examples/hunts/macos-hello.yml +49 -0
- package/package.json +4 -2
- package/dist/chunk-CWLRDV5P.js.map +0 -1
- package/dist/chunk-JFJQNJSJ.js.map +0 -1
- /package/dist/{loader-JTHA4BYG.js.map → loader-CAE4FPBK.js.map} +0 -0
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
|
|
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
|
|
153
|
-
├── .gitignore
|
|
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
|
|
156
|
-
|
|
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
|
|
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,
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
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-
|
|
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 (
|
|
5561
|
-
lastResult
|
|
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
|
-
|
|
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 (
|
|
5792
|
-
lastResult
|
|
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
|
-
|
|
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-
|
|
7646
|
+
//# sourceMappingURL=chunk-SRWAF2RI.js.map
|