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/dist/index.cjs CHANGED
@@ -74,7 +74,7 @@ var init_hunt_name = __esm({
74
74
  });
75
75
 
76
76
  // src/config/schema.ts
77
- var import_zod, webTargetSchema, macosTargetSchema, androidTargetSchema, iosTargetSchema, targetSchema, configSchema, navigateStepSchema, clickStepSchema, singleKeyValueSchema, fillStepSchema, typeStepSchema, pressStepSchema, waitForSelectorStepSchema, waitStepSchema, waitForUrlStepSchema, waitForNetworkIdleStepSchema, selectOptionStepSchema, selectStepSchema, onDialogStepSchema, setInputFilesStepSchema, inlineAssertStepSchema, runHuntStepSchema, hoverStepSchema, scrollStepSchema, scrollToStepSchema, screenshotStepSchema, ifStepSchema, repeatStepSchema, mockRouteStepSchema, unmockRouteStepSchema, evalScriptStepSchema, runScriptStepSchema, assertScreenshotStepSchema, assertWithAiStepSchema, copyTextStepSchema, waitForDownloadStepSchema, stepSchema, assertionSchema, huntSchema;
77
+ var import_zod, webTargetSchema, macosTargetSchema, androidTargetSchema, iosTargetSchema, targetSchema, configSchema, navigateStepSchema, clickStepSchema, singleKeyValueSchema, fillStepSchema, typeStepSchema, pressStepSchema, waitForSelectorStepSchema, waitStepSchema, waitForUrlStepSchema, waitForNetworkIdleStepSchema, waitForResponseStatusSchema, waitForResponseTimeoutSchema, waitForResponseStepSchema, selectOptionStepSchema, selectStepSchema, onDialogStepSchema, setInputFilesStepSchema, inlineAssertStepSchema, runHuntStepSchema, hoverStepSchema, scrollStepSchema, scrollToStepSchema, screenshotStepSchema, ifStepSchema, repeatStepSchema, mockRouteStepSchema, unmockRouteStepSchema, evalScriptStepSchema, runScriptStepSchema, assertScreenshotStepSchema, assertWithAiStepSchema, copyTextStepSchema, waitForDownloadStepSchema, stepSchema, assertionSchema, huntSchema;
78
78
  var init_schema = __esm({
79
79
  "src/config/schema.ts"() {
80
80
  "use strict";
@@ -199,6 +199,21 @@ var init_schema = __esm({
199
199
  waitForNetworkIdleStepSchema = import_zod.z.object({
200
200
  waitForNetworkIdle: import_zod.z.object({ timeout: import_zod.z.number().optional() }).strict()
201
201
  }).strict();
202
+ waitForResponseStatusSchema = import_zod.z.number().int().min(100).max(599);
203
+ waitForResponseTimeoutSchema = import_zod.z.number().int().min(0).finite();
204
+ waitForResponseStepSchema = import_zod.z.object({
205
+ waitForResponse: import_zod.z.object({
206
+ // Matched against each response URL as a glob (`*`/`**` match any run of
207
+ // characters including `/`, `?` matches one character); matching is
208
+ // unanchored, so a pattern with no wildcards behaves as a substring match.
209
+ url: import_zod.z.string().min(1).describe(
210
+ "URL glob to wait for; unanchored, so a wildcard-free pattern is a substring match"
211
+ ),
212
+ // Optional status filter: only resolve on a response with this exact HTTP status.
213
+ status: waitForResponseStatusSchema.optional(),
214
+ timeout: waitForResponseTimeoutSchema.optional()
215
+ }).strict()
216
+ }).strict();
202
217
  selectOptionStepSchema = import_zod.z.object({
203
218
  selectOption: import_zod.z.object({ selector: import_zod.z.string().min(1), value: import_zod.z.string() }).strict()
204
219
  }).strict();
@@ -360,6 +375,7 @@ var init_schema = __esm({
360
375
  waitForSelectorStepSchema,
361
376
  waitForUrlStepSchema,
362
377
  waitForNetworkIdleStepSchema,
378
+ waitForResponseStepSchema,
363
379
  hoverStepSchema,
364
380
  scrollStepSchema,
365
381
  scrollToStepSchema,
@@ -592,6 +608,16 @@ function interpolateStep(step, vars, stepPath2, redacted) {
592
608
  if ("waitForNetworkIdle" in step) {
593
609
  return { waitForNetworkIdle: { timeout: step.waitForNetworkIdle.timeout } };
594
610
  }
611
+ if ("waitForResponse" in step) {
612
+ const urlResult = interpolateString(step.waitForResponse.url, vars);
613
+ return {
614
+ waitForResponse: {
615
+ url: urlResult.value,
616
+ ...step.waitForResponse.status !== void 0 ? { status: step.waitForResponse.status } : {},
617
+ timeout: step.waitForResponse.timeout
618
+ }
619
+ };
620
+ }
595
621
  if ("hover" in step) {
596
622
  const selectorResult = interpolateString(step.hover.selector, vars);
597
623
  return { hover: { selector: selectorResult.value } };
@@ -1128,12 +1154,12 @@ var init_visual = __esm({
1128
1154
  });
1129
1155
 
1130
1156
  // src/cli/program.ts
1131
- var import_commander14 = require("commander");
1157
+ var import_commander15 = require("commander");
1132
1158
 
1133
1159
  // package.json
1134
1160
  var package_default = {
1135
1161
  name: "prowl-tools",
1136
- version: "0.1.8",
1162
+ version: "0.1.10",
1137
1163
  description: "E2E testing for native macOS apps and web apps from declarative YAML hunts.",
1138
1164
  type: "module",
1139
1165
  license: "Apache-2.0",
@@ -1192,7 +1218,8 @@ var package_default = {
1192
1218
  build: "tsup",
1193
1219
  lint: "eslint .",
1194
1220
  test: "vitest run",
1195
- "test:watch": "vitest"
1221
+ "test:watch": "vitest",
1222
+ "audit:licenses": "node scripts/audit-licenses.mjs"
1196
1223
  },
1197
1224
  dependencies: {
1198
1225
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -1216,6 +1243,7 @@ var package_default = {
1216
1243
  "@typescript-eslint/eslint-plugin": "^7.18.0",
1217
1244
  "@typescript-eslint/parser": "^7.18.0",
1218
1245
  eslint: "^8.57.1",
1246
+ "license-checker-rseidelsohn": "^4.4.2",
1219
1247
  tsup: "^8.3.5",
1220
1248
  typescript: "^5.7.3",
1221
1249
  vitest: "^2.1.8"
@@ -1240,6 +1268,7 @@ var WEB_ONLY_STEP_TYPES = /* @__PURE__ */ new Set([
1240
1268
  "navigate",
1241
1269
  "waitForUrl",
1242
1270
  "waitForNetworkIdle",
1271
+ "waitForResponse",
1243
1272
  "mockRoute",
1244
1273
  "unmockRoute",
1245
1274
  "evalScript",
@@ -1625,6 +1654,9 @@ function createPlaywrightDriver(page) {
1625
1654
  async waitForNetworkIdle(options) {
1626
1655
  await page.waitForLoadState("networkidle", { timeout: options?.timeout });
1627
1656
  },
1657
+ async waitForResponse(predicate, options) {
1658
+ await page.waitForResponse((response) => predicate(response), { timeout: options?.timeout });
1659
+ },
1628
1660
  evaluate(pageFunction, arg) {
1629
1661
  const raw = page.evaluate;
1630
1662
  const result = arg === void 0 ? raw.call(page, pageFunction) : raw.call(page, pageFunction, arg);
@@ -1827,6 +1859,9 @@ function createMacDriver(client, options = {}) {
1827
1859
  waitForNetworkIdle() {
1828
1860
  return rejectUnsupported("waitForNetworkIdle");
1829
1861
  },
1862
+ waitForResponse() {
1863
+ return rejectUnsupported("waitForResponse");
1864
+ },
1830
1865
  // scripting & artifacts ------------------------------------------------
1831
1866
  evaluate() {
1832
1867
  return rejectUnsupported("evalScript");
@@ -1950,10 +1985,15 @@ ${macdriverBuildInstructions()}`
1950
1985
  ${macdriverBuildInstructions()}`);
1951
1986
  }
1952
1987
  var DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
1988
+ function errorMessage(error) {
1989
+ return error instanceof Error ? error.message : String(error);
1990
+ }
1953
1991
  var SpawnMacHelperClient = class {
1954
1992
  child;
1955
1993
  pending = /* @__PURE__ */ new Map();
1956
1994
  requestTimeoutMs;
1995
+ onEvent;
1996
+ onEventError;
1957
1997
  stdoutBuffer = "";
1958
1998
  stderrBuffer = "";
1959
1999
  nextId = 1;
@@ -1961,6 +2001,9 @@ var SpawnMacHelperClient = class {
1961
2001
  terminalError;
1962
2002
  constructor(binaryPath, options = {}) {
1963
2003
  this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
2004
+ this.onEvent = options.onEvent;
2005
+ this.onEventError = options.onEventError ?? ((message) => process.stderr.write(`${message}
2006
+ `));
1964
2007
  this.child = (0, import_node_child_process2.spawn)(binaryPath, ["serve"], { stdio: ["pipe", "pipe", "pipe"] });
1965
2008
  this.child.stdout?.setEncoding("utf-8");
1966
2009
  this.child.stderr?.setEncoding("utf-8");
@@ -1997,6 +2040,10 @@ var SpawnMacHelperClient = class {
1997
2040
  } catch {
1998
2041
  return;
1999
2042
  }
2043
+ if (typeof message.event === "string" && typeof message.id !== "number") {
2044
+ this.handleEvent(message);
2045
+ return;
2046
+ }
2000
2047
  const id = typeof message.id === "number" ? message.id : void 0;
2001
2048
  if (id === void 0) {
2002
2049
  return;
@@ -2013,6 +2060,18 @@ var SpawnMacHelperClient = class {
2013
2060
  pending.reject(new Error(typeof message.error === "string" ? message.error : "prowl-macdriver error"));
2014
2061
  }
2015
2062
  }
2063
+ handleEvent(event) {
2064
+ try {
2065
+ this.onEvent?.(event);
2066
+ } catch (error) {
2067
+ try {
2068
+ this.onEventError(
2069
+ `prowl-macdriver event sink failed for event "${event.event}": ${errorMessage(error)}`
2070
+ );
2071
+ } catch {
2072
+ }
2073
+ }
2074
+ }
2016
2075
  failAll(error) {
2017
2076
  for (const pending of this.pending.values()) {
2018
2077
  clearTimeout(pending.timer);
@@ -2651,6 +2710,9 @@ function createAndroidDriver(client, options = {}) {
2651
2710
  waitForNetworkIdle() {
2652
2711
  return rejectUnsupported("waitForNetworkIdle");
2653
2712
  },
2713
+ waitForResponse() {
2714
+ return rejectUnsupported("waitForResponse");
2715
+ },
2654
2716
  // scripting & artifacts ------------------------------------------------
2655
2717
  evaluate() {
2656
2718
  return rejectUnsupported("evalScript");
@@ -2924,12 +2986,12 @@ var Uia2Transport = class {
2924
2986
  * {@link Uia2HttpError} on a non-2xx response, or a timeout error when the
2925
2987
  * per-request deadline elapses.
2926
2988
  */
2927
- async request(method, path27, body, timeoutMs) {
2989
+ async request(method, path28, body, timeoutMs) {
2928
2990
  const requestTimeoutMs = timeoutMs ?? this.requestTimeoutMs;
2929
2991
  const controller = new AbortController();
2930
2992
  const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
2931
2993
  timer.unref?.();
2932
- const url = `${this.baseUrl}${path27}`;
2994
+ const url = `${this.baseUrl}${path28}`;
2933
2995
  let response;
2934
2996
  try {
2935
2997
  response = await this.fetchImpl(url, {
@@ -2941,7 +3003,7 @@ var Uia2Transport = class {
2941
3003
  } catch (error) {
2942
3004
  if (controller.signal.aborted) {
2943
3005
  const shown = requestTimeoutMs >= 1e3 ? `${Math.round(requestTimeoutMs / 1e3)}s` : `${requestTimeoutMs}ms`;
2944
- throw new Error(`uiautomator2 request ${method} ${path27} timed out after ${shown}`);
3006
+ throw new Error(`uiautomator2 request ${method} ${path28} timed out after ${shown}`);
2945
3007
  }
2946
3008
  throw error instanceof Error ? error : new Error(String(error));
2947
3009
  } finally {
@@ -2952,7 +3014,7 @@ var Uia2Transport = class {
2952
3014
  if (!response.ok) {
2953
3015
  const wdError = extractWebdriverError(parsed);
2954
3016
  throw new Uia2HttpError(
2955
- `uiautomator2 ${method} ${path27} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
3017
+ `uiautomator2 ${method} ${path28} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
2956
3018
  response.status,
2957
3019
  wdError
2958
3020
  );
@@ -3035,10 +3097,10 @@ function sleep(ms) {
3035
3097
  }
3036
3098
  function createUia2AgentClient(transport, sessionId, options = {}) {
3037
3099
  const base = `/session/${sessionId}`;
3038
- async function locate(query, path27) {
3100
+ async function locate(query, path28) {
3039
3101
  return transport.request(
3040
3102
  "POST",
3041
- `${base}${path27}`,
3103
+ `${base}${path28}`,
3042
3104
  androidQueryToLocator(query, { appPackage: options.appPackage })
3043
3105
  );
3044
3106
  }
@@ -3517,6 +3579,9 @@ function createIosDriver(client, options) {
3517
3579
  waitForNetworkIdle() {
3518
3580
  return rejectUnsupported("waitForNetworkIdle");
3519
3581
  },
3582
+ waitForResponse() {
3583
+ return rejectUnsupported("waitForResponse");
3584
+ },
3520
3585
  // scripting & artifacts ------------------------------------------------
3521
3586
  evaluate() {
3522
3587
  return rejectUnsupported("evalScript");
@@ -3839,15 +3904,15 @@ var WdaTransport = class {
3839
3904
  * {@link WdaHttpError} on a non-2xx response, or a timeout error when the
3840
3905
  * per-request deadline elapses.
3841
3906
  */
3842
- async requestFull(method, path27, body, timeoutMs) {
3907
+ async requestFull(method, path28, body, timeoutMs) {
3843
3908
  const requestTimeoutMs = timeoutMs ?? this.requestTimeoutMs;
3844
3909
  const controller = new AbortController();
3845
3910
  const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
3846
3911
  timer.unref?.();
3847
- const url = `${this.baseUrl}${path27}`;
3912
+ const url = `${this.baseUrl}${path28}`;
3848
3913
  const timeoutError = () => {
3849
3914
  const shown = requestTimeoutMs >= 1e3 ? `${Math.round(requestTimeoutMs / 1e3)}s` : `${requestTimeoutMs}ms`;
3850
- return new Error(`WebDriverAgent request ${method} ${path27} timed out after ${shown}`);
3915
+ return new Error(`WebDriverAgent request ${method} ${path28} timed out after ${shown}`);
3851
3916
  };
3852
3917
  let response;
3853
3918
  try {
@@ -3879,7 +3944,7 @@ var WdaTransport = class {
3879
3944
  if (!response.ok) {
3880
3945
  const wdError = extractWebdriverError2(parsed);
3881
3946
  throw new WdaHttpError(
3882
- `WebDriverAgent ${method} ${path27} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
3947
+ `WebDriverAgent ${method} ${path28} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
3883
3948
  response.status,
3884
3949
  wdError
3885
3950
  );
@@ -3887,8 +3952,8 @@ var WdaTransport = class {
3887
3952
  return parsed;
3888
3953
  }
3889
3954
  /** Like {@link requestFull} but returns just the `value` field. */
3890
- async request(method, path27, body, timeoutMs) {
3891
- const parsed = await this.requestFull(method, path27, body, timeoutMs);
3955
+ async request(method, path28, body, timeoutMs) {
3956
+ const parsed = await this.requestFull(method, path28, body, timeoutMs);
3892
3957
  return parsed?.value;
3893
3958
  }
3894
3959
  };
@@ -3964,8 +4029,8 @@ function sleep2(ms) {
3964
4029
  }
3965
4030
  function createWdaAgentClient(transport, sessionId) {
3966
4031
  const base = `/session/${sessionId}`;
3967
- async function locate(query, path27) {
3968
- return transport.request("POST", `${base}${path27}`, iosQueryToLocator(query));
4032
+ async function locate(query, path28) {
4033
+ return transport.request("POST", `${base}${path28}`, iosQueryToLocator(query));
3969
4034
  }
3970
4035
  return {
3971
4036
  async findElement(query) {
@@ -4781,6 +4846,7 @@ function getStepType(step) {
4781
4846
  if ("waitForSelector" in step) return "waitForSelector";
4782
4847
  if ("waitForUrl" in step) return "waitForUrl";
4783
4848
  if ("waitForNetworkIdle" in step) return "waitForNetworkIdle";
4849
+ if ("waitForResponse" in step) return "waitForResponse";
4784
4850
  if ("hover" in step) return "hover";
4785
4851
  if ("scroll" in step) return "scroll";
4786
4852
  if ("scrollTo" in step) return "scrollTo";
@@ -4797,6 +4863,11 @@ function getStepType(step) {
4797
4863
  if ("waitForDownload" in step) return "waitForDownload";
4798
4864
  return "step";
4799
4865
  }
4866
+ function compileResponseUrlMatcher(pattern) {
4867
+ const regexBody = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\\\*/g, ".*").replace(/\\\?/g, ".");
4868
+ const regex = new RegExp(regexBody);
4869
+ return (url) => regex.test(url);
4870
+ }
4800
4871
  var RUNTIME_VAR_PATTERN = /\{\{([A-Z0-9_]+)\}\}/g;
4801
4872
  function substituteRuntimeVars(input, vars) {
4802
4873
  return input.replace(RUNTIME_VAR_PATTERN, (match, name) => {
@@ -4835,6 +4906,15 @@ function applyRuntimeVars(step, vars) {
4835
4906
  if ("waitForSelector" in step) {
4836
4907
  return { waitForSelector: { selector: sub(step.waitForSelector.selector), timeout: step.waitForSelector.timeout } };
4837
4908
  }
4909
+ if ("waitForResponse" in step) {
4910
+ return {
4911
+ waitForResponse: {
4912
+ url: sub(step.waitForResponse.url),
4913
+ ...step.waitForResponse.status !== void 0 ? { status: step.waitForResponse.status } : {},
4914
+ ...step.waitForResponse.timeout !== void 0 ? { timeout: step.waitForResponse.timeout } : {}
4915
+ }
4916
+ };
4917
+ }
4838
4918
  if ("evalScript" in step) {
4839
4919
  if (typeof step.evalScript === "string") return { evalScript: sub(step.evalScript) };
4840
4920
  return {
@@ -5444,6 +5524,32 @@ var STEP_HANDLERS = {
5444
5524
  };
5445
5525
  }
5446
5526
  },
5527
+ waitForResponse: {
5528
+ capabilities: ["response", "wait"],
5529
+ run: async (h) => {
5530
+ if (!("waitForResponse" in h.step)) unknownStep();
5531
+ const { url: pattern, status, timeout } = h.step.waitForResponse;
5532
+ const matchesResponseUrl = compileResponseUrlMatcher(pattern);
5533
+ try {
5534
+ await h.driver.waitForResponse(
5535
+ (response) => matchesResponseUrl(response.url()) && (status === void 0 || response.status() === status),
5536
+ { timeout }
5537
+ );
5538
+ } catch (err) {
5539
+ const isTimeout = err instanceof Error && (err.name === "TimeoutError" || /timeout/i.test(err.message));
5540
+ if (!isTimeout) throw err;
5541
+ const statusPart = status !== void 0 ? ` with status ${status}` : "";
5542
+ const timeoutPart = timeout !== void 0 ? ` within ${timeout}ms` : "";
5543
+ throw new Error(
5544
+ `waitForResponse: no response matching "${pattern}"${statusPart} was received${timeoutPart}.`
5545
+ );
5546
+ }
5547
+ return {
5548
+ kind: "result",
5549
+ result: { type: "waitForResponse", status: "pass", durationMs: Date.now() - h.stepStart, value: pattern }
5550
+ };
5551
+ }
5552
+ },
5447
5553
  waitForNetworkIdle: {
5448
5554
  capabilities: ["wait"],
5449
5555
  run: async (h) => {
@@ -6234,6 +6340,15 @@ function formatStep(step) {
6234
6340
  const error = step.error ? ` error=${escapeMd(step.error)}` : "";
6235
6341
  return `${base}${selector}${healed}${value}${error}`;
6236
6342
  }
6343
+ function formatRetryAttempt(attempt) {
6344
+ const base = `- attempt ${attempt.attempt}: ${attempt.status.toUpperCase()} (${attempt.durationMs}ms)`;
6345
+ if (attempt.status === "pass") {
6346
+ return base;
6347
+ }
6348
+ const step = attempt.failedStep ? ` failed-step=[${attempt.failedStep.index}] ${escapeMd(attempt.failedStep.type)}` : "";
6349
+ const error = attempt.error ? ` error=${escapeMd(attempt.error)}` : "";
6350
+ return `${base}${step}${error}`;
6351
+ }
6237
6352
  function formatAssertion(assertion) {
6238
6353
  const value = assertion.value !== void 0 ? ` value=${typeof assertion.value === "string" ? escapeMd(assertion.value) : assertion.value}` : "";
6239
6354
  const error = assertion.error ? ` error=${escapeMd(assertion.error)}` : "";
@@ -6248,6 +6363,16 @@ function writeSummary(runDir, result) {
6248
6363
  lines.push(`Target: ${result.targetUrl}`);
6249
6364
  lines.push(`Started: ${result.startedAt}`);
6250
6365
  lines.push(`Duration: ${result.durationMs}ms`);
6366
+ if (result.retryHistory && result.retryHistory.length > 1) {
6367
+ lines.push("");
6368
+ lines.push("## Retries");
6369
+ if (result.retrySummary) {
6370
+ lines.push(escapeMd(result.retrySummary));
6371
+ }
6372
+ for (const attempt of result.retryHistory) {
6373
+ lines.push(formatRetryAttempt(attempt));
6374
+ }
6375
+ }
6251
6376
  lines.push("");
6252
6377
  lines.push("## Steps");
6253
6378
  for (const step of result.steps) {
@@ -6408,7 +6533,8 @@ function isHistoryEntry(value) {
6408
6533
  return false;
6409
6534
  }
6410
6535
  const entry = value;
6411
- 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");
6536
+ 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`.
6537
+ (entry.retries === void 0 || typeof entry.retries === "number" && Number.isInteger(entry.retries) && entry.retries >= 0);
6412
6538
  }
6413
6539
  function readHistory(configDir) {
6414
6540
  const filePath = historyPath(configDir);
@@ -6660,6 +6786,60 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
6660
6786
  function delay3(ms) {
6661
6787
  return new Promise((resolve) => setTimeout(resolve, ms));
6662
6788
  }
6789
+ function toRetryAttempt(attempt, result) {
6790
+ const record = {
6791
+ attempt,
6792
+ status: result.status,
6793
+ durationMs: result.durationMs
6794
+ };
6795
+ if (result.status === "fail") {
6796
+ const failedIndex = result.steps.findIndex((step) => step.status === "fail");
6797
+ if (failedIndex >= 0) {
6798
+ const failed = result.steps[failedIndex];
6799
+ record.failedStep = { index: failedIndex, type: failed.type };
6800
+ if (failed.error) {
6801
+ record.error = failed.error;
6802
+ }
6803
+ }
6804
+ if (!record.error) {
6805
+ const failedAssertion = result.assertions.find((assertion) => assertion.status === "fail");
6806
+ if (failedAssertion?.error) {
6807
+ record.error = failedAssertion.error;
6808
+ }
6809
+ }
6810
+ }
6811
+ return record;
6812
+ }
6813
+ function describeAttemptFailure(attempt) {
6814
+ if (attempt.status !== "fail") {
6815
+ return void 0;
6816
+ }
6817
+ const step = attempt.failedStep?.type;
6818
+ if (step && attempt.error) {
6819
+ return `${step} (${attempt.error})`;
6820
+ }
6821
+ return step ?? attempt.error;
6822
+ }
6823
+ function buildRetrySummary(attempts, maxRetries) {
6824
+ const final = attempts[attempts.length - 1];
6825
+ const firstFailure = describeAttemptFailure(attempts[0]);
6826
+ const suffix = firstFailure ? ` \u2014 first failure: ${firstFailure}` : "";
6827
+ if (final.status === "pass") {
6828
+ return `Passed on attempt ${final.attempt} of ${maxRetries + 1}${suffix}`;
6829
+ }
6830
+ return `Failed after ${attempts.length} attempts${suffix}`;
6831
+ }
6832
+ function finalizeRetryOutcome(outcome, attempts, maxRetries, junit) {
6833
+ outcome.result = writeReports(
6834
+ outcome.runDir,
6835
+ {
6836
+ ...outcome.result,
6837
+ retryHistory: attempts,
6838
+ retrySummary: buildRetrySummary(attempts, maxRetries)
6839
+ },
6840
+ { junit }
6841
+ );
6842
+ }
6663
6843
  async function runHunt(options) {
6664
6844
  const { config, configDir } = loadConfig(options.configPath);
6665
6845
  if (config.target.type === "macos") {
@@ -6689,7 +6869,9 @@ async function runHunt(options) {
6689
6869
  }
6690
6870
  const maxRetries = hunt.retry?.maxRetries ?? 0;
6691
6871
  const retryDelay = hunt.retry?.delay ?? 0;
6872
+ const junit = options.junit ?? config.artifacts.junit;
6692
6873
  let lastResult;
6874
+ const attempts = [];
6693
6875
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
6694
6876
  if (attempt > 0 && retryDelay > 0) {
6695
6877
  await delay3(retryDelay);
@@ -6705,19 +6887,20 @@ async function runHunt(options) {
6705
6887
  targetUrl,
6706
6888
  allowedDomains
6707
6889
  );
6890
+ attempts.push(toRetryAttempt(attempt + 1, lastResult.result));
6708
6891
  if (lastResult.result.status === "pass") {
6709
- if (attempt > 0) {
6710
- lastResult.result.artifacts.summary = `Passed on attempt ${attempt + 1} of ${maxRetries + 1}`;
6892
+ if (attempts.length > 1) {
6893
+ finalizeRetryOutcome(lastResult, attempts, maxRetries, junit);
6711
6894
  }
6712
- recordHistory(configDir, lastResult, config.history.maxRuns);
6895
+ recordHistory(configDir, lastResult, config.history.maxRuns, attempts.length - 1);
6713
6896
  return lastResult;
6714
6897
  }
6715
6898
  }
6716
- if (maxRetries > 0 && lastResult) {
6717
- lastResult.result.artifacts.summary = `Failed after ${maxRetries + 1} attempts`;
6718
- }
6719
6899
  if (lastResult) {
6720
- recordHistory(configDir, lastResult, config.history.maxRuns);
6900
+ if (attempts.length > 1) {
6901
+ finalizeRetryOutcome(lastResult, attempts, maxRetries, junit);
6902
+ }
6903
+ recordHistory(configDir, lastResult, config.history.maxRuns, attempts.length - 1);
6721
6904
  }
6722
6905
  return lastResult;
6723
6906
  }
@@ -6921,7 +7104,9 @@ async function runNativeHunt(options, config, configDir, target, native) {
6921
7104
  }
6922
7105
  const maxRetries = hunt.retry?.maxRetries ?? 0;
6923
7106
  const retryDelay = hunt.retry?.delay ?? 0;
7107
+ const junit = options.junit ?? config.artifacts.junit;
6924
7108
  let lastResult;
7109
+ const attempts = [];
6925
7110
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
6926
7111
  if (attempt > 0 && retryDelay > 0) {
6927
7112
  await delay3(retryDelay);
@@ -6936,23 +7121,24 @@ async function runNativeHunt(options, config, configDir, target, native) {
6936
7121
  randomVars,
6937
7122
  config.guardrails.allowedApps
6938
7123
  );
7124
+ attempts.push(toRetryAttempt(attempt + 1, lastResult.result));
6939
7125
  if (lastResult.result.status === "pass") {
6940
- if (attempt > 0) {
6941
- lastResult.result.artifacts.summary = `Passed on attempt ${attempt + 1} of ${maxRetries + 1}`;
7126
+ if (attempts.length > 1) {
7127
+ finalizeRetryOutcome(lastResult, attempts, maxRetries, junit);
6942
7128
  }
6943
- recordHistory(configDir, lastResult, config.history.maxRuns);
7129
+ recordHistory(configDir, lastResult, config.history.maxRuns, attempts.length - 1);
6944
7130
  return lastResult;
6945
7131
  }
6946
7132
  }
6947
- if (maxRetries > 0 && lastResult) {
6948
- lastResult.result.artifacts.summary = `Failed after ${maxRetries + 1} attempts`;
6949
- }
6950
7133
  if (lastResult) {
6951
- recordHistory(configDir, lastResult, config.history.maxRuns);
7134
+ if (attempts.length > 1) {
7135
+ finalizeRetryOutcome(lastResult, attempts, maxRetries, junit);
7136
+ }
7137
+ recordHistory(configDir, lastResult, config.history.maxRuns, attempts.length - 1);
6952
7138
  }
6953
7139
  return lastResult;
6954
7140
  }
6955
- function recordHistory(configDir, outcome, maxRuns) {
7141
+ function recordHistory(configDir, outcome, maxRuns, retries = 0) {
6956
7142
  try {
6957
7143
  const relativeRunDir = import_node_path15.default.relative(configDir, outcome.runDir);
6958
7144
  appendEntry(
@@ -6962,7 +7148,9 @@ function recordHistory(configDir, outcome, maxRuns) {
6962
7148
  status: outcome.result.status,
6963
7149
  durationMs: outcome.result.durationMs,
6964
7150
  startedAt: outcome.result.startedAt,
6965
- runDir: relativeRunDir || void 0
7151
+ runDir: relativeRunDir || void 0,
7152
+ // Omit when no retry ran so entries stay additive/backward-compatible.
7153
+ retries: retries > 0 ? retries : void 0
6966
7154
  },
6967
7155
  maxRuns
6968
7156
  );
@@ -7017,6 +7205,10 @@ function describeStep(step) {
7017
7205
  if ("waitForSelector" in step) return `waitForSelector "${step.waitForSelector.selector}"`;
7018
7206
  if ("waitForUrl" in step) return `waitForUrl "${step.waitForUrl.value}"`;
7019
7207
  if ("waitForNetworkIdle" in step) return "waitForNetworkIdle";
7208
+ if ("waitForResponse" in step) {
7209
+ const statusSuffix = step.waitForResponse.status !== void 0 ? ` (status ${step.waitForResponse.status})` : "";
7210
+ return `waitForResponse "${step.waitForResponse.url}"${statusSuffix}`;
7211
+ }
7020
7212
  if ("hover" in step) return `hover "${step.hover.selector}"`;
7021
7213
  if ("scroll" in step) return `scroll ${step.scroll.direction} ${step.scroll.amount ?? 500}px`;
7022
7214
  if ("scrollTo" in step) return `scrollTo "${step.scrollTo.selector}"`;
@@ -7080,6 +7272,10 @@ function printHuntSummary(result, runDir) {
7080
7272
  const duration = import_chalk.default.gray(`(${result.durationMs}ms)`);
7081
7273
  console.log(`
7082
7274
  ${status} ${import_chalk.default.bold(result.hunt)} ${duration} ${stepCount}`);
7275
+ if (result.retrySummary) {
7276
+ const color = result.status === "pass" ? import_chalk.default.yellow : import_chalk.default.red;
7277
+ console.log(` ${color(result.retrySummary)}`);
7278
+ }
7083
7279
  console.log(` ${import_chalk.default.gray("Artifacts:")} ${runDir}
7084
7280
  `);
7085
7281
  }
@@ -7189,6 +7385,7 @@ function buildRunCommand() {
7189
7385
 
7190
7386
  // src/cli/commands/init.ts
7191
7387
  var import_node_fs15 = __toESM(require("fs"), 1);
7388
+ var import_node_os5 = __toESM(require("os"), 1);
7192
7389
  var import_node_path16 = __toESM(require("path"), 1);
7193
7390
  var import_node_url2 = require("url");
7194
7391
  var import_commander2 = require("commander");
@@ -7214,6 +7411,192 @@ function copyFile(source, destination) {
7214
7411
  import_node_fs15.default.mkdirSync(import_node_path16.default.dirname(destination), { recursive: true });
7215
7412
  import_node_fs15.default.copyFileSync(source, destination);
7216
7413
  }
7414
+ function isInsideDir(parent, child) {
7415
+ const relative = import_node_path16.default.relative(parent, child);
7416
+ return relative.length > 0 && !relative.startsWith("..") && !import_node_path16.default.isAbsolute(relative);
7417
+ }
7418
+ function lstatIfExists(target) {
7419
+ try {
7420
+ return import_node_fs15.default.lstatSync(target);
7421
+ } catch (error) {
7422
+ const code = error.code;
7423
+ if (code === "ENOENT" || code === "ENOTDIR") {
7424
+ return null;
7425
+ }
7426
+ throw error;
7427
+ }
7428
+ }
7429
+ function errorMessage2(error) {
7430
+ return error instanceof Error ? error.message : String(error);
7431
+ }
7432
+ function assertNoSymlinkDestination(prowlDir, destination) {
7433
+ const relative = import_node_path16.default.relative(prowlDir, destination);
7434
+ if (relative.startsWith("..") || import_node_path16.default.isAbsolute(relative)) {
7435
+ throw new Error(`Refusing to write outside ${CONFIG_DIR}: ${destination}`);
7436
+ }
7437
+ const parts = relative.split(import_node_path16.default.sep).filter((part) => part.length > 0);
7438
+ let current = prowlDir;
7439
+ const rootStat = lstatIfExists(current);
7440
+ if (rootStat?.isSymbolicLink()) {
7441
+ throw new Error(
7442
+ `${CONFIG_DIR} scaffold destination path contains a symlink: ${current}. Replace it with a real file or directory before running prowl init --force.`
7443
+ );
7444
+ }
7445
+ if (rootStat && !rootStat.isDirectory()) {
7446
+ throw new Error(`${CONFIG_DIR} scaffold path is not a directory: ${current}`);
7447
+ }
7448
+ for (let index = 0; index < parts.length; index += 1) {
7449
+ current = import_node_path16.default.join(current, parts[index] ?? "");
7450
+ const stat = lstatIfExists(current);
7451
+ if (stat?.isSymbolicLink()) {
7452
+ throw new Error(
7453
+ `${CONFIG_DIR} scaffold destination path contains a symlink: ${current}. Replace it with a real file or directory before running prowl init --force.`
7454
+ );
7455
+ }
7456
+ if (stat && index < parts.length - 1 && !stat.isDirectory()) {
7457
+ throw new Error(`${CONFIG_DIR} scaffold parent path is not a directory: ${current}`);
7458
+ }
7459
+ }
7460
+ }
7461
+ function gitignoreTemplate() {
7462
+ return [
7463
+ "# Run artifacts (screenshots, logs, reports)",
7464
+ "runs/",
7465
+ "",
7466
+ "# Auth state (tokens, cookies)",
7467
+ "auth-state.json",
7468
+ "",
7469
+ "# Environment variables (credentials)",
7470
+ ".env",
7471
+ ""
7472
+ ].join("\n");
7473
+ }
7474
+ function stageScaffoldTemplates(examplesDir) {
7475
+ const stageDir = import_node_fs15.default.mkdtempSync(import_node_path16.default.join(import_node_os5.default.tmpdir(), "prowl-init-"));
7476
+ const files = [];
7477
+ try {
7478
+ copyFile(import_node_path16.default.join(examplesDir, "config.yml"), import_node_path16.default.join(stageDir, "config.yml"));
7479
+ files.push({ source: import_node_path16.default.join(stageDir, "config.yml"), relativePath: "config.yml" });
7480
+ const exampleHuntsDir = import_node_path16.default.join(examplesDir, "hunts");
7481
+ const huntFiles = import_node_fs15.default.readdirSync(exampleHuntsDir).filter((f) => f.endsWith(".yml"));
7482
+ for (const huntFile of huntFiles) {
7483
+ const relativePath = import_node_path16.default.join("hunts", huntFile);
7484
+ copyFile(import_node_path16.default.join(exampleHuntsDir, huntFile), import_node_path16.default.join(stageDir, relativePath));
7485
+ files.push({ source: import_node_path16.default.join(stageDir, relativePath), relativePath });
7486
+ }
7487
+ const gitignorePath = import_node_path16.default.join(stageDir, ".gitignore");
7488
+ import_node_fs15.default.writeFileSync(gitignorePath, gitignoreTemplate());
7489
+ files.push({ source: gitignorePath, relativePath: ".gitignore" });
7490
+ return { stageDir, files };
7491
+ } catch (error) {
7492
+ import_node_fs15.default.rmSync(stageDir, { recursive: true, force: true });
7493
+ throw error;
7494
+ }
7495
+ }
7496
+ function trackCreatedParentDirs(destination, prowlDir, createdDirs) {
7497
+ let dir = import_node_path16.default.dirname(destination);
7498
+ while (isInsideDir(prowlDir, dir) && !import_node_fs15.default.existsSync(dir)) {
7499
+ createdDirs.add(dir);
7500
+ dir = import_node_path16.default.dirname(dir);
7501
+ }
7502
+ }
7503
+ function prepareDestinationRollback(prowlDir, files) {
7504
+ const backupDir = import_node_fs15.default.mkdtempSync(import_node_path16.default.join(import_node_os5.default.tmpdir(), "prowl-init-rollback-"));
7505
+ const backups = [];
7506
+ const createdFiles = [];
7507
+ const createdDirs = /* @__PURE__ */ new Set();
7508
+ const prowlDirExisted = import_node_fs15.default.existsSync(prowlDir);
7509
+ try {
7510
+ for (const file of files) {
7511
+ const destination = import_node_path16.default.join(prowlDir, file.relativePath);
7512
+ assertNoSymlinkDestination(prowlDir, destination);
7513
+ const stat = lstatIfExists(destination);
7514
+ if (stat) {
7515
+ if (!stat.isFile()) {
7516
+ throw new Error(`${CONFIG_DIR} scaffold destination is not a regular file: ${destination}`);
7517
+ }
7518
+ const backup = import_node_path16.default.join(backupDir, file.relativePath);
7519
+ copyFile(destination, backup);
7520
+ backups.push({ destination, backup });
7521
+ } else {
7522
+ createdFiles.push(destination);
7523
+ }
7524
+ if (prowlDirExisted) {
7525
+ trackCreatedParentDirs(destination, prowlDir, createdDirs);
7526
+ }
7527
+ }
7528
+ return {
7529
+ prowlDirExisted,
7530
+ backupDir,
7531
+ backups,
7532
+ createdFiles,
7533
+ createdDirs: [...createdDirs].sort((a, b) => b.length - a.length)
7534
+ };
7535
+ } catch (error) {
7536
+ import_node_fs15.default.rmSync(backupDir, { recursive: true, force: true });
7537
+ throw error;
7538
+ }
7539
+ }
7540
+ function rollbackDestination(prowlDir, rollback) {
7541
+ if (!rollback.prowlDirExisted) {
7542
+ import_node_fs15.default.rmSync(prowlDir, { recursive: true, force: true });
7543
+ return;
7544
+ }
7545
+ for (const file of rollback.createdFiles) {
7546
+ import_node_fs15.default.rmSync(file, { force: true });
7547
+ }
7548
+ for (const backup of rollback.backups) {
7549
+ try {
7550
+ copyFile(backup.backup, backup.destination);
7551
+ } catch (error) {
7552
+ throw new Error(
7553
+ `Failed to restore ${backup.destination} from backup ${backup.backup}: ${errorMessage2(error)}`
7554
+ );
7555
+ }
7556
+ }
7557
+ for (const dir of rollback.createdDirs) {
7558
+ try {
7559
+ import_node_fs15.default.rmdirSync(dir);
7560
+ } catch {
7561
+ }
7562
+ }
7563
+ }
7564
+ function scaffoldProwlDir(root) {
7565
+ const prowlDir = import_node_path16.default.join(root, CONFIG_DIR);
7566
+ const packageRoot = getPackageRoot2();
7567
+ const examplesDir = import_node_path16.default.join(packageRoot, "examples");
7568
+ const exampleConfig = import_node_path16.default.join(examplesDir, "config.yml");
7569
+ const exampleHuntsDir = import_node_path16.default.join(examplesDir, "hunts");
7570
+ if (!import_node_fs15.default.existsSync(exampleConfig) || !import_node_fs15.default.existsSync(exampleHuntsDir)) {
7571
+ throw new Error("Examples not found in package. Reinstall prowl-tools.");
7572
+ }
7573
+ const staged = stageScaffoldTemplates(examplesDir);
7574
+ let rollback = null;
7575
+ let keepBackup = false;
7576
+ try {
7577
+ rollback = prepareDestinationRollback(prowlDir, staged.files);
7578
+ for (const file of staged.files) {
7579
+ copyFile(file.source, import_node_path16.default.join(prowlDir, file.relativePath));
7580
+ }
7581
+ } catch (error) {
7582
+ if (rollback) {
7583
+ try {
7584
+ rollbackDestination(prowlDir, rollback);
7585
+ } catch (rollbackError) {
7586
+ keepBackup = true;
7587
+ throw new Error(
7588
+ `Scaffold failed: ${errorMessage2(error)}. Rollback failed: ${errorMessage2(rollbackError)}. Backup preserved at ${rollback.backupDir}.`
7589
+ );
7590
+ }
7591
+ }
7592
+ throw error;
7593
+ } finally {
7594
+ if (rollback && !keepBackup) {
7595
+ import_node_fs15.default.rmSync(rollback.backupDir, { recursive: true, force: true });
7596
+ }
7597
+ import_node_fs15.default.rmSync(staged.stageDir, { recursive: true, force: true });
7598
+ }
7599
+ }
7217
7600
  function buildInitCommand() {
7218
7601
  const command = new import_commander2.Command("init").option("--force", `Overwrite existing ${CONFIG_DIR} directory`).action((options) => {
7219
7602
  const root = process.cwd();
@@ -7227,39 +7610,18 @@ function buildInitCommand() {
7227
7610
  process.exitCode = 1;
7228
7611
  return;
7229
7612
  }
7230
- const packageRoot = getPackageRoot2();
7231
- const examplesDir = import_node_path16.default.join(packageRoot, "examples");
7232
- const exampleConfig = import_node_path16.default.join(examplesDir, "config.yml");
7233
- const exampleHuntsDir = import_node_path16.default.join(examplesDir, "hunts");
7234
- if (!import_node_fs15.default.existsSync(exampleConfig) || !import_node_fs15.default.existsSync(exampleHuntsDir)) {
7235
- console.error(import_chalk4.default.red("Examples not found in package. Reinstall prowl-tools."));
7613
+ try {
7614
+ scaffoldProwlDir(root);
7615
+ } catch (error) {
7616
+ console.error(import_chalk4.default.red(error instanceof Error ? error.message : "init failed"));
7236
7617
  process.exitCode = 1;
7237
7618
  return;
7238
7619
  }
7239
- copyFile(exampleConfig, import_node_path16.default.join(prowlDir, "config.yml"));
7240
- const huntFiles = import_node_fs15.default.readdirSync(exampleHuntsDir).filter((f) => f.endsWith(".yml"));
7241
- for (const huntFile of huntFiles) {
7242
- copyFile(
7243
- import_node_path16.default.join(exampleHuntsDir, huntFile),
7244
- import_node_path16.default.join(prowlDir, "hunts", huntFile)
7245
- );
7246
- }
7247
- const gitignore = [
7248
- "# Run artifacts (screenshots, logs, reports)",
7249
- "runs/",
7250
- "",
7251
- "# Auth state (tokens, cookies)",
7252
- "auth-state.json",
7253
- "",
7254
- "# Environment variables (credentials)",
7255
- ".env",
7256
- ""
7257
- ].join("\n");
7258
- import_node_fs15.default.writeFileSync(import_node_path16.default.join(prowlDir, ".gitignore"), gitignore);
7259
7620
  console.log(welcomeBanner());
7260
7621
  console.log(import_chalk4.default.green(` Initialized ${CONFIG_DIR} directory.`));
7261
7622
  console.log(import_chalk4.default.gray(" Run ") + import_chalk4.default.bold("prowl run hello") + import_chalk4.default.gray(" to get started."));
7262
- console.log(import_chalk4.default.gray(" See ") + import_chalk4.default.cyan(`${CONFIG_DIR}/hunts/login-flow.yml`) + import_chalk4.default.gray(" for a fuller example.") + "\n");
7623
+ console.log(import_chalk4.default.gray(" See ") + import_chalk4.default.cyan(`${CONFIG_DIR}/hunts/login-flow.yml`) + import_chalk4.default.gray(" (auth) and ") + import_chalk4.default.cyan(`${CONFIG_DIR}/hunts/form.yml`) + import_chalk4.default.gray(" (web forms) for fuller examples."));
7624
+ console.log(import_chalk4.default.gray(" Desktop-first? ") + import_chalk4.default.cyan(`${CONFIG_DIR}/hunts/macos-hello.yml`) + import_chalk4.default.gray(" is a macOS starter (experimental \u2014 see its comments to enable).") + "\n");
7263
7625
  });
7264
7626
  return command;
7265
7627
  }
@@ -9033,6 +9395,7 @@ var STEP_REFERENCE = `
9033
9395
  - waitForSelector: { selector: "#el", timeout: 5000 }
9034
9396
  - waitForUrl: { value: "/path", timeout: 5000 }
9035
9397
  - waitForNetworkIdle: { timeout: 5000 }
9398
+ - waitForResponse: { url: "**/api/orders", status: 200, timeout: 10000 } \u2014 wait for a matching network response (url is a glob/substring; status is optional)
9036
9399
 
9037
9400
  ### Interaction
9038
9401
  - click: "Button Text" \u2014 click by text (tries role=button first)
@@ -9273,6 +9636,7 @@ function buildHistoryCommand() {
9273
9636
  }
9274
9637
  console.log();
9275
9638
  console.log(` ${import_chalk13.default.bold(huntName)} \u2014 last ${recent.length} of ${entries.length} runs`);
9639
+ console.log(` ${import_chalk13.default.gray(formatRetryFrequency(recent))}`);
9276
9640
  console.log();
9277
9641
  console.log(formatTable(recent));
9278
9642
  console.log();
@@ -9288,12 +9652,23 @@ function buildHistoryCommand() {
9288
9652
  });
9289
9653
  return command;
9290
9654
  }
9655
+ function formatRetryFrequency(entries) {
9656
+ const retried = entries.filter((entry) => (entry.retries ?? 0) > 0).length;
9657
+ if (retried === 0) {
9658
+ return "No retries in these runs";
9659
+ }
9660
+ return `Retried in ${retried} of ${entries.length} runs`;
9661
+ }
9662
+ function formatRetries(retries) {
9663
+ return retries && retries > 0 ? String(retries) : "-";
9664
+ }
9291
9665
  function formatTable(entries) {
9292
- const headers = ["Status", "Started", "Duration"];
9666
+ const headers = ["Status", "Started", "Duration", "Retries"];
9293
9667
  const rows = entries.map((entry) => [
9294
9668
  formatStatus(entry.status),
9295
9669
  entry.startedAt,
9296
- formatDuration(entry.durationMs)
9670
+ formatDuration(entry.durationMs),
9671
+ formatRetries(entry.retries)
9297
9672
  ]);
9298
9673
  const widths = headers.map(
9299
9674
  (header, index) => Math.max(
@@ -9463,7 +9838,7 @@ async function runHuntTool(args, configPath) {
9463
9838
 
9464
9839
  // src/mcp/projects.ts
9465
9840
  var import_node_fs21 = __toESM(require("fs"), 1);
9466
- var import_node_os5 = __toESM(require("os"), 1);
9841
+ var import_node_os6 = __toESM(require("os"), 1);
9467
9842
  var import_node_path25 = __toESM(require("path"), 1);
9468
9843
  var import_yaml3 = __toESM(require("yaml"), 1);
9469
9844
  var import_zod2 = require("zod");
@@ -9476,10 +9851,10 @@ var projectRegistrySchema = import_zod2.z.object({
9476
9851
  projects: import_zod2.z.record(import_zod2.z.string().min(1), projectEntrySchema)
9477
9852
  }).strict();
9478
9853
  function defaultRegistryPath() {
9479
- return import_node_path25.default.join(import_node_os5.default.homedir(), ".prowl", "projects.yml");
9854
+ return import_node_path25.default.join(import_node_os6.default.homedir(), ".prowl", "projects.yml");
9480
9855
  }
9481
9856
  function legacyRegistryPath() {
9482
- return import_node_path25.default.join(import_node_os5.default.homedir(), ".prowlqa", "projects.yml");
9857
+ return import_node_path25.default.join(import_node_os6.default.homedir(), ".prowlqa", "projects.yml");
9483
9858
  }
9484
9859
  function resolveProjectConfigPath(root) {
9485
9860
  const preferred = import_node_path25.default.join(root, ".prowl", "config.yml");
@@ -9533,7 +9908,7 @@ function listRegisteredProjects(registry) {
9533
9908
  function textResult(data) {
9534
9909
  return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
9535
9910
  }
9536
- function errorMessage(error) {
9911
+ function errorMessage3(error) {
9537
9912
  return error instanceof Error ? error.message : String(error);
9538
9913
  }
9539
9914
  function safeStringify(value) {
@@ -9547,7 +9922,7 @@ async function toolResult(toolName, args, action) {
9547
9922
  try {
9548
9923
  return textResult(await action());
9549
9924
  } catch (error) {
9550
- throw new Error(`${toolName} failed for args=${safeStringify(args)}: ${errorMessage(error)}`, {
9925
+ throw new Error(`${toolName} failed for args=${safeStringify(args)}: ${errorMessage3(error)}`, {
9551
9926
  cause: error
9552
9927
  });
9553
9928
  }
@@ -9624,7 +9999,7 @@ async function startMcpServer(options = {}) {
9624
9999
  }
9625
10000
 
9626
10001
  // src/cli/commands/mcp.ts
9627
- function errorMessage2(error) {
10002
+ function errorMessage4(error) {
9628
10003
  return error instanceof Error ? error.message : String(error);
9629
10004
  }
9630
10005
  function buildMcpCommand() {
@@ -9635,7 +10010,7 @@ function buildMcpCommand() {
9635
10010
  try {
9636
10011
  await startMcpServer({ registryPath: options.projects });
9637
10012
  } catch (error) {
9638
- console.error(`Failed to start MCP server: ${errorMessage2(error)}`);
10013
+ console.error(`Failed to start MCP server: ${errorMessage4(error)}`);
9639
10014
  process.exit(1);
9640
10015
  }
9641
10016
  });
@@ -9649,7 +10024,7 @@ var import_commander13 = require("commander");
9649
10024
  var import_node_child_process7 = require("child_process");
9650
10025
  var import_node_crypto3 = require("crypto");
9651
10026
  var import_node_fs22 = __toESM(require("fs"), 1);
9652
- var import_node_os6 = __toESM(require("os"), 1);
10027
+ var import_node_os7 = __toESM(require("os"), 1);
9653
10028
  var import_node_path26 = __toESM(require("path"), 1);
9654
10029
  var import_node_util = require("util");
9655
10030
  var execFileAsync = (0, import_node_util.promisify)(import_node_child_process7.execFile);
@@ -9815,7 +10190,7 @@ The download was rejected and discarded; re-run the install, and report it if it
9815
10190
  }
9816
10191
  async function installMacdriver(options = {}) {
9817
10192
  const version = validateMacdriverVersion(options.version ?? MACDRIVER_VERSION);
9818
- const homedir = options.homedir ?? import_node_os6.default.homedir();
10193
+ const homedir = options.homedir ?? import_node_os7.default.homedir();
9819
10194
  const listArchiveEntries = options.listArchiveEntries ?? zipinfoArchiveLister;
9820
10195
  const extract = options.extract ?? dittoExtractor;
9821
10196
  const verifySignature = options.verifySignature ?? codesignVerifier;
@@ -9895,7 +10270,7 @@ var runVersionProbe = async (binaryPath) => {
9895
10270
  };
9896
10271
  async function collectMacdriverStatus(options = {}) {
9897
10272
  const env = options.env ?? process.env;
9898
- const homedir = options.homedir ?? import_node_os6.default.homedir();
10273
+ const homedir = options.homedir ?? import_node_os7.default.homedir();
9899
10274
  const probe = options.probe ?? runVersionProbe;
9900
10275
  let resolved = null;
9901
10276
  try {
@@ -10008,10 +10383,402 @@ function buildMacdriverCommand() {
10008
10383
  return command;
10009
10384
  }
10010
10385
 
10386
+ // src/cli/commands/doctor.ts
10387
+ var import_chalk16 = __toESM(require("chalk"), 1);
10388
+ var import_commander14 = require("commander");
10389
+
10390
+ // src/doctor/checks.ts
10391
+ var import_node_child_process8 = require("child_process");
10392
+ var import_node_module3 = require("module");
10393
+ var import_node_fs23 = __toESM(require("fs"), 1);
10394
+ var import_node_path27 = __toESM(require("path"), 1);
10395
+ var import_node_util2 = require("util");
10396
+ init_loader();
10397
+ var import_meta5 = {};
10398
+ var execFileAsync2 = (0, import_node_util2.promisify)(import_node_child_process8.execFile);
10399
+ var MIN_NODE_MAJOR = 20;
10400
+ var CHECK_NODE = "Node.js version";
10401
+ var CHECK_PLAYWRIGHT = "Playwright installed";
10402
+ var CHECK_CHROMIUM = "Chromium browser";
10403
+ var CHECK_PROWL_DIR = ".prowl/ directory";
10404
+ var CHECK_CONFIG = "config.yml valid";
10405
+ var CHECK_MACOS = "macOS helper (prowl-macdriver)";
10406
+ var CHECK_IOS = "iOS tooling (xcrun simctl)";
10407
+ var CHECK_ANDROID = "Android tooling (adb)";
10408
+ var INSTALL_CHROMIUM_TIMEOUT_MS = 3e5;
10409
+ function nodeMajor(version) {
10410
+ const cleaned = version.startsWith("v") ? version.slice(1) : version;
10411
+ return Number.parseInt(cleaned.split(".")[0] ?? "", 10);
10412
+ }
10413
+ function checkNode(nodeVersion) {
10414
+ const major = nodeMajor(nodeVersion);
10415
+ if (Number.isFinite(major) && major >= MIN_NODE_MAJOR) {
10416
+ return {
10417
+ name: CHECK_NODE,
10418
+ status: "pass",
10419
+ message: `Node ${nodeVersion} (>= ${MIN_NODE_MAJOR} required)`,
10420
+ fixable: false
10421
+ };
10422
+ }
10423
+ return {
10424
+ name: CHECK_NODE,
10425
+ status: "fail",
10426
+ message: `Node ${nodeVersion} is too old \u2014 Prowl needs Node >= ${MIN_NODE_MAJOR}. Upgrade with nvm (\`nvm install 20\`), Homebrew (\`brew install node\`), or nodejs.org.`,
10427
+ fixable: false
10428
+ };
10429
+ }
10430
+ function checkPlaywright(probe) {
10431
+ if (probe.playwrightInstalled) {
10432
+ return { name: CHECK_PLAYWRIGHT, status: "pass", message: "Playwright is installed", fixable: false };
10433
+ }
10434
+ return {
10435
+ name: CHECK_PLAYWRIGHT,
10436
+ status: "fail",
10437
+ message: "Playwright is not installed. It ships as a dependency of prowl-tools \u2014 reinstall with `npm install` (local) or `npm install -g prowl-tools` (global).",
10438
+ fixable: false
10439
+ };
10440
+ }
10441
+ function checkChromium(probe) {
10442
+ if (!probe.playwrightInstalled) {
10443
+ return {
10444
+ name: CHECK_CHROMIUM,
10445
+ status: "skip",
10446
+ message: "Skipped \u2014 Playwright is not installed",
10447
+ fixable: false
10448
+ };
10449
+ }
10450
+ if (probe.chromiumInstalled) {
10451
+ return {
10452
+ name: CHECK_CHROMIUM,
10453
+ status: "pass",
10454
+ message: `Chromium is available${probe.chromiumPath ? ` (${probe.chromiumPath})` : ""}`,
10455
+ fixable: false
10456
+ };
10457
+ }
10458
+ return {
10459
+ name: CHECK_CHROMIUM,
10460
+ status: "fail",
10461
+ message: "Chromium is not installed. Run `npx playwright install chromium` (or `prowl doctor --fix`).",
10462
+ fixable: true
10463
+ };
10464
+ }
10465
+ function checkProwlDir(exists) {
10466
+ if (exists) {
10467
+ return { name: CHECK_PROWL_DIR, status: "pass", message: `${CONFIG_DIR}/ exists`, fixable: false };
10468
+ }
10469
+ return {
10470
+ name: CHECK_PROWL_DIR,
10471
+ status: "warn",
10472
+ message: `No ${CONFIG_DIR}/ directory here. Run \`prowl init\` (or \`prowl doctor --fix\`) to scaffold config and starter hunts.`,
10473
+ fixable: true
10474
+ };
10475
+ }
10476
+ function checkConfig(prowlDirExists, loadConfig2) {
10477
+ if (!prowlDirExists) {
10478
+ return {
10479
+ result: {
10480
+ name: CHECK_CONFIG,
10481
+ status: "skip",
10482
+ message: `Skipped \u2014 no ${CONFIG_DIR}/ directory`,
10483
+ fixable: false
10484
+ },
10485
+ config: null
10486
+ };
10487
+ }
10488
+ try {
10489
+ const { config } = loadConfig2();
10490
+ return {
10491
+ result: {
10492
+ name: CHECK_CONFIG,
10493
+ status: "pass",
10494
+ message: `Valid (target: ${config.target.type})`,
10495
+ fixable: false
10496
+ },
10497
+ config
10498
+ };
10499
+ } catch (error) {
10500
+ const detail = error instanceof Error ? error.message : String(error);
10501
+ return {
10502
+ result: {
10503
+ name: CHECK_CONFIG,
10504
+ status: "fail",
10505
+ message: `Invalid config: ${detail}`,
10506
+ fixable: false
10507
+ },
10508
+ config: null
10509
+ };
10510
+ }
10511
+ }
10512
+ function macdriverSourceLabel(source) {
10513
+ switch (source) {
10514
+ case "env":
10515
+ return "PROWL_MACDRIVER_BIN override";
10516
+ case "user-install":
10517
+ return "user install (~/.prowl/macdriver)";
10518
+ case "source-build":
10519
+ return "repo source build";
10520
+ }
10521
+ }
10522
+ async function checkMacTarget(collect2, env) {
10523
+ let status;
10524
+ try {
10525
+ status = await collect2({ env });
10526
+ } catch (error) {
10527
+ const detail = error instanceof Error ? error.message : String(error);
10528
+ return {
10529
+ name: CHECK_MACOS,
10530
+ status: "fail",
10531
+ message: `Could not inspect prowl-macdriver status (${detail}). Run \`prowl macdriver status\` for details.`,
10532
+ fixable: false
10533
+ };
10534
+ }
10535
+ if (status.resolved && status.probedVersion) {
10536
+ return {
10537
+ name: CHECK_MACOS,
10538
+ status: "pass",
10539
+ message: `Resolved via ${macdriverSourceLabel(status.resolved.source)}: ${status.resolved.path} (reports ${status.probedVersion}). Verify Accessibility/Screen Recording permissions with \`prowl macdriver status\`.`,
10540
+ fixable: false
10541
+ };
10542
+ }
10543
+ if (status.resolved) {
10544
+ return {
10545
+ name: CHECK_MACOS,
10546
+ status: "fail",
10547
+ message: `prowl-macdriver resolved via ${macdriverSourceLabel(status.resolved.source)} (${status.resolved.path}), but the helper version probe failed. Run \`prowl macdriver status\` for details.`,
10548
+ fixable: false
10549
+ };
10550
+ }
10551
+ return {
10552
+ name: CHECK_MACOS,
10553
+ status: "fail",
10554
+ message: "prowl-macdriver helper not found. Install it with `prowl macdriver install`, then check permissions with `prowl macdriver status`.",
10555
+ fixable: false
10556
+ };
10557
+ }
10558
+ async function checkIosTarget(platform, run) {
10559
+ if (platform !== "darwin") {
10560
+ return {
10561
+ name: CHECK_IOS,
10562
+ status: "fail",
10563
+ message: `The iOS target requires macOS; xcrun simctl is unavailable on ${platform}.`,
10564
+ fixable: false
10565
+ };
10566
+ }
10567
+ try {
10568
+ await run("xcrun", ["simctl", "help"]);
10569
+ return { name: CHECK_IOS, status: "pass", message: "xcrun simctl is reachable", fixable: false };
10570
+ } catch (error) {
10571
+ const detail = error instanceof Error ? error.message : String(error);
10572
+ return {
10573
+ name: CHECK_IOS,
10574
+ status: "fail",
10575
+ message: `xcrun simctl is not reachable (${detail}). Install Xcode and its command-line tools (\`xcode-select --install\`), then open Xcode once to finish setup.`,
10576
+ fixable: false
10577
+ };
10578
+ }
10579
+ }
10580
+ async function checkAndroidTarget(run) {
10581
+ try {
10582
+ await run("adb", ["version"]);
10583
+ return { name: CHECK_ANDROID, status: "pass", message: "adb is reachable", fixable: false };
10584
+ } catch (error) {
10585
+ const detail = error instanceof Error ? error.message : String(error);
10586
+ return {
10587
+ name: CHECK_ANDROID,
10588
+ status: "fail",
10589
+ message: `adb is not reachable (${detail}). Install Android platform-tools and add it to your PATH (e.g. \`brew install --cask android-platform-tools\`, or via Android Studio's SDK Manager).`,
10590
+ fixable: false
10591
+ };
10592
+ }
10593
+ }
10594
+ async function runDoctorChecks(deps) {
10595
+ const results = [];
10596
+ const probe = await deps.probePlaywright();
10597
+ results.push(checkNode(deps.nodeVersion));
10598
+ results.push(checkPlaywright(probe));
10599
+ results.push(checkChromium(probe));
10600
+ const prowlExists = deps.prowlDirExists(deps.cwd);
10601
+ results.push(checkProwlDir(prowlExists));
10602
+ const { result: configResult, config } = checkConfig(prowlExists, deps.loadConfig);
10603
+ results.push(configResult);
10604
+ if (config) {
10605
+ switch (config.target.type) {
10606
+ case "macos":
10607
+ results.push(await checkMacTarget(deps.collectMacdriverStatus, deps.env));
10608
+ break;
10609
+ case "ios":
10610
+ results.push(await checkIosTarget(deps.platform, deps.runCommand));
10611
+ break;
10612
+ case "android":
10613
+ results.push(await checkAndroidTarget(deps.runCommand));
10614
+ break;
10615
+ default:
10616
+ break;
10617
+ }
10618
+ }
10619
+ return results;
10620
+ }
10621
+ async function runDoctor(deps, options = {}) {
10622
+ let results = await runDoctorChecks(deps);
10623
+ const fixes = [];
10624
+ if (options.fix) {
10625
+ for (const result of results) {
10626
+ if (!result.fixable || result.status === "pass" || result.status === "skip") {
10627
+ continue;
10628
+ }
10629
+ try {
10630
+ if (result.name === CHECK_CHROMIUM) {
10631
+ await deps.installChromium();
10632
+ fixes.push({ name: result.name });
10633
+ } else if (result.name === CHECK_PROWL_DIR) {
10634
+ deps.scaffoldProwlDir(deps.cwd);
10635
+ fixes.push({ name: result.name });
10636
+ }
10637
+ } catch (error) {
10638
+ fixes.push({
10639
+ name: result.name,
10640
+ error: error instanceof Error ? error.message : String(error)
10641
+ });
10642
+ }
10643
+ }
10644
+ if (fixes.length > 0) {
10645
+ results = await runDoctorChecks(deps);
10646
+ }
10647
+ }
10648
+ const ok = !results.some((result) => result.status === "fail");
10649
+ return { results, fixes, ok };
10650
+ }
10651
+ async function defaultProbePlaywright() {
10652
+ try {
10653
+ const pw = await import("playwright");
10654
+ let chromiumPath = null;
10655
+ try {
10656
+ chromiumPath = pw.chromium.executablePath();
10657
+ } catch {
10658
+ chromiumPath = null;
10659
+ }
10660
+ const chromiumInstalled = chromiumPath ? import_node_fs23.default.existsSync(chromiumPath) : false;
10661
+ return { playwrightInstalled: true, chromiumPath, chromiumInstalled };
10662
+ } catch {
10663
+ return { playwrightInstalled: false, chromiumPath: null, chromiumInstalled: false };
10664
+ }
10665
+ }
10666
+ var defaultRunCommand = async (file, args) => execFileAsync2(file, args, { timeout: 5e3 });
10667
+ function createLocalRequire() {
10668
+ if (typeof __filename === "string") {
10669
+ return (0, import_node_module3.createRequire)(__filename);
10670
+ }
10671
+ return (0, import_node_module3.createRequire)(import_meta5.url);
10672
+ }
10673
+ function resolvePlaywrightCliPath(requireFn = createLocalRequire()) {
10674
+ return import_node_path27.default.join(import_node_path27.default.dirname(requireFn.resolve("playwright/package.json")), "cli.js");
10675
+ }
10676
+ function playwrightInstallChromiumCommand() {
10677
+ return {
10678
+ file: process.execPath,
10679
+ args: [resolvePlaywrightCliPath(), "install", "chromium"]
10680
+ };
10681
+ }
10682
+ async function defaultInstallChromium() {
10683
+ const command = playwrightInstallChromiumCommand();
10684
+ await execFileAsync2(command.file, command.args, { timeout: INSTALL_CHROMIUM_TIMEOUT_MS });
10685
+ }
10686
+ function defaultDoctorDeps() {
10687
+ return {
10688
+ nodeVersion: process.versions.node,
10689
+ platform: process.platform,
10690
+ cwd: process.cwd(),
10691
+ env: process.env,
10692
+ probePlaywright: defaultProbePlaywright,
10693
+ runCommand: defaultRunCommand,
10694
+ prowlDirExists: (cwd) => import_node_fs23.default.existsSync(import_node_path27.default.join(cwd, CONFIG_DIR)),
10695
+ loadConfig: () => loadConfig(),
10696
+ collectMacdriverStatus,
10697
+ installChromium: defaultInstallChromium,
10698
+ scaffoldProwlDir
10699
+ };
10700
+ }
10701
+
10702
+ // src/cli/commands/doctor.ts
10703
+ function symbol(status) {
10704
+ switch (status) {
10705
+ case "pass":
10706
+ return import_chalk16.default.green("\u2713");
10707
+ // ✓
10708
+ case "fail":
10709
+ return import_chalk16.default.red("\u2717");
10710
+ // ✗
10711
+ case "warn":
10712
+ return import_chalk16.default.yellow("\u26A0");
10713
+ // ⚠
10714
+ case "skip":
10715
+ return import_chalk16.default.gray("\u25CB");
10716
+ }
10717
+ }
10718
+ function renderResult(result) {
10719
+ console.log(` ${symbol(result.status)} ${import_chalk16.default.bold(result.name)}`);
10720
+ console.log(` ${import_chalk16.default.gray(result.message)}`);
10721
+ }
10722
+ function renderReport(report, fixRequested) {
10723
+ console.log(import_chalk16.default.bold("\nprowl doctor\n"));
10724
+ for (const result of report.results) {
10725
+ renderResult(result);
10726
+ }
10727
+ if (fixRequested) {
10728
+ console.log("");
10729
+ if (report.fixes.length === 0) {
10730
+ console.log(import_chalk16.default.gray(" No auto-fixable issues to repair."));
10731
+ } else {
10732
+ console.log(import_chalk16.default.bold(" Attempted fixes:"));
10733
+ for (const fix of report.fixes) {
10734
+ if (fix.error) {
10735
+ console.log(` ${import_chalk16.default.red("\u2717")} ${fix.name} \u2014 ${import_chalk16.default.gray(fix.error)}`);
10736
+ } else {
10737
+ console.log(` ${import_chalk16.default.green("\u2713")} ${fix.name}`);
10738
+ }
10739
+ }
10740
+ }
10741
+ }
10742
+ const counts = { pass: 0, warn: 0, fail: 0, skip: 0 };
10743
+ for (const result of report.results) {
10744
+ counts[result.status] += 1;
10745
+ }
10746
+ const parts = [
10747
+ import_chalk16.default.green(`${counts.pass} passed`),
10748
+ counts.warn > 0 ? import_chalk16.default.yellow(`${counts.warn} warning${counts.warn === 1 ? "" : "s"}`) : null,
10749
+ counts.fail > 0 ? import_chalk16.default.red(`${counts.fail} failed`) : null,
10750
+ counts.skip > 0 ? import_chalk16.default.gray(`${counts.skip} skipped`) : null
10751
+ ].filter((part) => part !== null);
10752
+ console.log("\n " + parts.join(import_chalk16.default.gray(", ")));
10753
+ if (counts.fail > 0) {
10754
+ console.log(import_chalk16.default.red.bold(" Environment has problems \u2014 fix the failures above, then re-run `prowl doctor`.\n"));
10755
+ } else if (counts.warn > 0) {
10756
+ console.log(import_chalk16.default.yellow.bold(" Environment is usable, with warnings.\n"));
10757
+ } else {
10758
+ console.log(import_chalk16.default.green.bold(" Environment is healthy.\n"));
10759
+ }
10760
+ }
10761
+ function buildDoctorCommand(deps = defaultDoctorDeps()) {
10762
+ return new import_commander14.Command("doctor").description("Check that your environment is set up to run Prowl hunts").option("--fix", "Attempt safe auto-repairs: install Chromium and scaffold a missing .prowl/").action(async (options) => {
10763
+ try {
10764
+ const report = await runDoctor(deps, { fix: options.fix });
10765
+ renderReport(report, Boolean(options.fix));
10766
+ if (!report.ok) {
10767
+ process.exitCode = 1;
10768
+ }
10769
+ } catch (error) {
10770
+ const message = error instanceof Error ? error.message : "doctor failed";
10771
+ console.error(import_chalk16.default.red(`Error: ${message}
10772
+ Re-run \`prowl doctor\`; if it fails again, report the error.`));
10773
+ process.exitCode = 1;
10774
+ }
10775
+ });
10776
+ }
10777
+
10011
10778
  // src/cli/program.ts
10012
10779
  var CLI_VERSION = package_default.version;
10013
10780
  function buildProgram() {
10014
- const program2 = new import_commander14.Command();
10781
+ const program2 = new import_commander15.Command();
10015
10782
  program2.name("prowl").description("CLI-first QA testing tool for deterministic Playwright flows").version(CLI_VERSION);
10016
10783
  program2.addCommand(buildRunCommand());
10017
10784
  program2.addCommand(buildCiCommand());
@@ -10026,6 +10793,7 @@ function buildProgram() {
10026
10793
  program2.addCommand(buildFlakyCommand());
10027
10794
  program2.addCommand(buildMcpCommand());
10028
10795
  program2.addCommand(buildMacdriverCommand());
10796
+ program2.addCommand(buildDoctorCommand());
10029
10797
  return program2;
10030
10798
  }
10031
10799