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/lib.cjs CHANGED
@@ -394,6 +394,9 @@ function createMacDriver(client, options = {}) {
394
394
  waitForNetworkIdle() {
395
395
  return rejectUnsupported("waitForNetworkIdle");
396
396
  },
397
+ waitForResponse() {
398
+ return rejectUnsupported("waitForResponse");
399
+ },
397
400
  // scripting & artifacts ------------------------------------------------
398
401
  evaluate() {
399
402
  return rejectUnsupported("evalScript");
@@ -524,10 +527,15 @@ ${macdriverBuildInstructions()}`
524
527
  ${macdriverBuildInstructions()}`);
525
528
  }
526
529
  var DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
530
+ function errorMessage(error) {
531
+ return error instanceof Error ? error.message : String(error);
532
+ }
527
533
  var SpawnMacHelperClient = class {
528
534
  child;
529
535
  pending = /* @__PURE__ */ new Map();
530
536
  requestTimeoutMs;
537
+ onEvent;
538
+ onEventError;
531
539
  stdoutBuffer = "";
532
540
  stderrBuffer = "";
533
541
  nextId = 1;
@@ -535,6 +543,9 @@ var SpawnMacHelperClient = class {
535
543
  terminalError;
536
544
  constructor(binaryPath, options = {}) {
537
545
  this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
546
+ this.onEvent = options.onEvent;
547
+ this.onEventError = options.onEventError ?? ((message) => process.stderr.write(`${message}
548
+ `));
538
549
  this.child = (0, import_node_child_process.spawn)(binaryPath, ["serve"], { stdio: ["pipe", "pipe", "pipe"] });
539
550
  this.child.stdout?.setEncoding("utf-8");
540
551
  this.child.stderr?.setEncoding("utf-8");
@@ -571,6 +582,10 @@ var SpawnMacHelperClient = class {
571
582
  } catch {
572
583
  return;
573
584
  }
585
+ if (typeof message.event === "string" && typeof message.id !== "number") {
586
+ this.handleEvent(message);
587
+ return;
588
+ }
574
589
  const id = typeof message.id === "number" ? message.id : void 0;
575
590
  if (id === void 0) {
576
591
  return;
@@ -587,6 +602,18 @@ var SpawnMacHelperClient = class {
587
602
  pending.reject(new Error(typeof message.error === "string" ? message.error : "prowl-macdriver error"));
588
603
  }
589
604
  }
605
+ handleEvent(event) {
606
+ try {
607
+ this.onEvent?.(event);
608
+ } catch (error) {
609
+ try {
610
+ this.onEventError(
611
+ `prowl-macdriver event sink failed for event "${event.event}": ${errorMessage(error)}`
612
+ );
613
+ } catch {
614
+ }
615
+ }
616
+ }
590
617
  failAll(error) {
591
618
  for (const pending of this.pending.values()) {
592
619
  clearTimeout(pending.timer);
@@ -1587,6 +1614,9 @@ function createAndroidDriver(client, options = {}) {
1587
1614
  waitForNetworkIdle() {
1588
1615
  return rejectUnsupported("waitForNetworkIdle");
1589
1616
  },
1617
+ waitForResponse() {
1618
+ return rejectUnsupported("waitForResponse");
1619
+ },
1590
1620
  // scripting & artifacts ------------------------------------------------
1591
1621
  evaluate() {
1592
1622
  return rejectUnsupported("evalScript");
@@ -2050,6 +2080,7 @@ var WEB_ONLY_STEP_TYPES = /* @__PURE__ */ new Set([
2050
2080
  "navigate",
2051
2081
  "waitForUrl",
2052
2082
  "waitForNetworkIdle",
2083
+ "waitForResponse",
2053
2084
  "mockRoute",
2054
2085
  "unmockRoute",
2055
2086
  "evalScript",
@@ -2668,6 +2699,9 @@ function createIosDriver(client, options) {
2668
2699
  waitForNetworkIdle() {
2669
2700
  return rejectUnsupported("waitForNetworkIdle");
2670
2701
  },
2702
+ waitForResponse() {
2703
+ return rejectUnsupported("waitForResponse");
2704
+ },
2671
2705
  // scripting & artifacts ------------------------------------------------
2672
2706
  evaluate() {
2673
2707
  return rejectUnsupported("evalScript");
@@ -3673,6 +3707,21 @@ var waitForUrlStepSchema = import_zod.z.object({
3673
3707
  var waitForNetworkIdleStepSchema = import_zod.z.object({
3674
3708
  waitForNetworkIdle: import_zod.z.object({ timeout: import_zod.z.number().optional() }).strict()
3675
3709
  }).strict();
3710
+ var waitForResponseStatusSchema = import_zod.z.number().int().min(100).max(599);
3711
+ var waitForResponseTimeoutSchema = import_zod.z.number().int().min(0).finite();
3712
+ var waitForResponseStepSchema = import_zod.z.object({
3713
+ waitForResponse: import_zod.z.object({
3714
+ // Matched against each response URL as a glob (`*`/`**` match any run of
3715
+ // characters including `/`, `?` matches one character); matching is
3716
+ // unanchored, so a pattern with no wildcards behaves as a substring match.
3717
+ url: import_zod.z.string().min(1).describe(
3718
+ "URL glob to wait for; unanchored, so a wildcard-free pattern is a substring match"
3719
+ ),
3720
+ // Optional status filter: only resolve on a response with this exact HTTP status.
3721
+ status: waitForResponseStatusSchema.optional(),
3722
+ timeout: waitForResponseTimeoutSchema.optional()
3723
+ }).strict()
3724
+ }).strict();
3676
3725
  var selectOptionStepSchema = import_zod.z.object({
3677
3726
  selectOption: import_zod.z.object({ selector: import_zod.z.string().min(1), value: import_zod.z.string() }).strict()
3678
3727
  }).strict();
@@ -3834,6 +3883,7 @@ var stepSchema = import_zod.z.union([
3834
3883
  waitForSelectorStepSchema,
3835
3884
  waitForUrlStepSchema,
3836
3885
  waitForNetworkIdleStepSchema,
3886
+ waitForResponseStepSchema,
3837
3887
  hoverStepSchema,
3838
3888
  scrollStepSchema,
3839
3889
  scrollToStepSchema,
@@ -4068,6 +4118,16 @@ function interpolateStep(step, vars, stepPath2, redacted) {
4068
4118
  if ("waitForNetworkIdle" in step) {
4069
4119
  return { waitForNetworkIdle: { timeout: step.waitForNetworkIdle.timeout } };
4070
4120
  }
4121
+ if ("waitForResponse" in step) {
4122
+ const urlResult = interpolateString(step.waitForResponse.url, vars);
4123
+ return {
4124
+ waitForResponse: {
4125
+ url: urlResult.value,
4126
+ ...step.waitForResponse.status !== void 0 ? { status: step.waitForResponse.status } : {},
4127
+ timeout: step.waitForResponse.timeout
4128
+ }
4129
+ };
4130
+ }
4071
4131
  if ("hover" in step) {
4072
4132
  const selectorResult = interpolateString(step.hover.selector, vars);
4073
4133
  return { hover: { selector: selectorResult.value } };
@@ -4687,6 +4747,9 @@ function createPlaywrightDriver(page) {
4687
4747
  async waitForNetworkIdle(options) {
4688
4748
  await page.waitForLoadState("networkidle", { timeout: options?.timeout });
4689
4749
  },
4750
+ async waitForResponse(predicate, options) {
4751
+ await page.waitForResponse((response) => predicate(response), { timeout: options?.timeout });
4752
+ },
4690
4753
  evaluate(pageFunction, arg) {
4691
4754
  const raw = page.evaluate;
4692
4755
  const result = arg === void 0 ? raw.call(page, pageFunction) : raw.call(page, pageFunction, arg);
@@ -5148,6 +5211,7 @@ function getStepType(step) {
5148
5211
  if ("waitForSelector" in step) return "waitForSelector";
5149
5212
  if ("waitForUrl" in step) return "waitForUrl";
5150
5213
  if ("waitForNetworkIdle" in step) return "waitForNetworkIdle";
5214
+ if ("waitForResponse" in step) return "waitForResponse";
5151
5215
  if ("hover" in step) return "hover";
5152
5216
  if ("scroll" in step) return "scroll";
5153
5217
  if ("scrollTo" in step) return "scrollTo";
@@ -5164,6 +5228,11 @@ function getStepType(step) {
5164
5228
  if ("waitForDownload" in step) return "waitForDownload";
5165
5229
  return "step";
5166
5230
  }
5231
+ function compileResponseUrlMatcher(pattern) {
5232
+ const regexBody = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\\\*/g, ".*").replace(/\\\?/g, ".");
5233
+ const regex = new RegExp(regexBody);
5234
+ return (url) => regex.test(url);
5235
+ }
5167
5236
  var RUNTIME_VAR_PATTERN = /\{\{([A-Z0-9_]+)\}\}/g;
5168
5237
  function substituteRuntimeVars(input, vars) {
5169
5238
  return input.replace(RUNTIME_VAR_PATTERN, (match, name) => {
@@ -5202,6 +5271,15 @@ function applyRuntimeVars(step, vars) {
5202
5271
  if ("waitForSelector" in step) {
5203
5272
  return { waitForSelector: { selector: sub(step.waitForSelector.selector), timeout: step.waitForSelector.timeout } };
5204
5273
  }
5274
+ if ("waitForResponse" in step) {
5275
+ return {
5276
+ waitForResponse: {
5277
+ url: sub(step.waitForResponse.url),
5278
+ ...step.waitForResponse.status !== void 0 ? { status: step.waitForResponse.status } : {},
5279
+ ...step.waitForResponse.timeout !== void 0 ? { timeout: step.waitForResponse.timeout } : {}
5280
+ }
5281
+ };
5282
+ }
5205
5283
  if ("evalScript" in step) {
5206
5284
  if (typeof step.evalScript === "string") return { evalScript: sub(step.evalScript) };
5207
5285
  return {
@@ -5811,6 +5889,32 @@ var STEP_HANDLERS = {
5811
5889
  };
5812
5890
  }
5813
5891
  },
5892
+ waitForResponse: {
5893
+ capabilities: ["response", "wait"],
5894
+ run: async (h) => {
5895
+ if (!("waitForResponse" in h.step)) unknownStep();
5896
+ const { url: pattern, status, timeout } = h.step.waitForResponse;
5897
+ const matchesResponseUrl = compileResponseUrlMatcher(pattern);
5898
+ try {
5899
+ await h.driver.waitForResponse(
5900
+ (response) => matchesResponseUrl(response.url()) && (status === void 0 || response.status() === status),
5901
+ { timeout }
5902
+ );
5903
+ } catch (err) {
5904
+ const isTimeout = err instanceof Error && (err.name === "TimeoutError" || /timeout/i.test(err.message));
5905
+ if (!isTimeout) throw err;
5906
+ const statusPart = status !== void 0 ? ` with status ${status}` : "";
5907
+ const timeoutPart = timeout !== void 0 ? ` within ${timeout}ms` : "";
5908
+ throw new Error(
5909
+ `waitForResponse: no response matching "${pattern}"${statusPart} was received${timeoutPart}.`
5910
+ );
5911
+ }
5912
+ return {
5913
+ kind: "result",
5914
+ result: { type: "waitForResponse", status: "pass", durationMs: Date.now() - h.stepStart, value: pattern }
5915
+ };
5916
+ }
5917
+ },
5814
5918
  waitForNetworkIdle: {
5815
5919
  capabilities: ["wait"],
5816
5920
  run: async (h) => {
@@ -6601,6 +6705,15 @@ function formatStep(step) {
6601
6705
  const error = step.error ? ` error=${escapeMd(step.error)}` : "";
6602
6706
  return `${base}${selector}${healed}${value}${error}`;
6603
6707
  }
6708
+ function formatRetryAttempt(attempt) {
6709
+ const base = `- attempt ${attempt.attempt}: ${attempt.status.toUpperCase()} (${attempt.durationMs}ms)`;
6710
+ if (attempt.status === "pass") {
6711
+ return base;
6712
+ }
6713
+ const step = attempt.failedStep ? ` failed-step=[${attempt.failedStep.index}] ${escapeMd(attempt.failedStep.type)}` : "";
6714
+ const error = attempt.error ? ` error=${escapeMd(attempt.error)}` : "";
6715
+ return `${base}${step}${error}`;
6716
+ }
6604
6717
  function formatAssertion(assertion) {
6605
6718
  const value = assertion.value !== void 0 ? ` value=${typeof assertion.value === "string" ? escapeMd(assertion.value) : assertion.value}` : "";
6606
6719
  const error = assertion.error ? ` error=${escapeMd(assertion.error)}` : "";
@@ -6615,6 +6728,16 @@ function writeSummary(runDir, result) {
6615
6728
  lines.push(`Target: ${result.targetUrl}`);
6616
6729
  lines.push(`Started: ${result.startedAt}`);
6617
6730
  lines.push(`Duration: ${result.durationMs}ms`);
6731
+ if (result.retryHistory && result.retryHistory.length > 1) {
6732
+ lines.push("");
6733
+ lines.push("## Retries");
6734
+ if (result.retrySummary) {
6735
+ lines.push(escapeMd(result.retrySummary));
6736
+ }
6737
+ for (const attempt of result.retryHistory) {
6738
+ lines.push(formatRetryAttempt(attempt));
6739
+ }
6740
+ }
6618
6741
  lines.push("");
6619
6742
  lines.push("## Steps");
6620
6743
  for (const step of result.steps) {
@@ -6775,7 +6898,8 @@ function isHistoryEntry(value) {
6775
6898
  return false;
6776
6899
  }
6777
6900
  const entry = value;
6778
- 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");
6901
+ 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`.
6902
+ (entry.retries === void 0 || typeof entry.retries === "number" && Number.isInteger(entry.retries) && entry.retries >= 0);
6779
6903
  }
6780
6904
  function readHistory(configDir) {
6781
6905
  const filePath = historyPath(configDir);
@@ -7027,6 +7151,60 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
7027
7151
  function delay3(ms) {
7028
7152
  return new Promise((resolve) => setTimeout(resolve, ms));
7029
7153
  }
7154
+ function toRetryAttempt(attempt, result) {
7155
+ const record = {
7156
+ attempt,
7157
+ status: result.status,
7158
+ durationMs: result.durationMs
7159
+ };
7160
+ if (result.status === "fail") {
7161
+ const failedIndex = result.steps.findIndex((step) => step.status === "fail");
7162
+ if (failedIndex >= 0) {
7163
+ const failed = result.steps[failedIndex];
7164
+ record.failedStep = { index: failedIndex, type: failed.type };
7165
+ if (failed.error) {
7166
+ record.error = failed.error;
7167
+ }
7168
+ }
7169
+ if (!record.error) {
7170
+ const failedAssertion = result.assertions.find((assertion) => assertion.status === "fail");
7171
+ if (failedAssertion?.error) {
7172
+ record.error = failedAssertion.error;
7173
+ }
7174
+ }
7175
+ }
7176
+ return record;
7177
+ }
7178
+ function describeAttemptFailure(attempt) {
7179
+ if (attempt.status !== "fail") {
7180
+ return void 0;
7181
+ }
7182
+ const step = attempt.failedStep?.type;
7183
+ if (step && attempt.error) {
7184
+ return `${step} (${attempt.error})`;
7185
+ }
7186
+ return step ?? attempt.error;
7187
+ }
7188
+ function buildRetrySummary(attempts, maxRetries) {
7189
+ const final = attempts[attempts.length - 1];
7190
+ const firstFailure = describeAttemptFailure(attempts[0]);
7191
+ const suffix = firstFailure ? ` \u2014 first failure: ${firstFailure}` : "";
7192
+ if (final.status === "pass") {
7193
+ return `Passed on attempt ${final.attempt} of ${maxRetries + 1}${suffix}`;
7194
+ }
7195
+ return `Failed after ${attempts.length} attempts${suffix}`;
7196
+ }
7197
+ function finalizeRetryOutcome(outcome, attempts, maxRetries, junit) {
7198
+ outcome.result = writeReports(
7199
+ outcome.runDir,
7200
+ {
7201
+ ...outcome.result,
7202
+ retryHistory: attempts,
7203
+ retrySummary: buildRetrySummary(attempts, maxRetries)
7204
+ },
7205
+ { junit }
7206
+ );
7207
+ }
7030
7208
  async function runHunt(options) {
7031
7209
  const { config, configDir } = loadConfig(options.configPath);
7032
7210
  if (config.target.type === "macos") {
@@ -7056,7 +7234,9 @@ async function runHunt(options) {
7056
7234
  }
7057
7235
  const maxRetries = hunt.retry?.maxRetries ?? 0;
7058
7236
  const retryDelay = hunt.retry?.delay ?? 0;
7237
+ const junit = options.junit ?? config.artifacts.junit;
7059
7238
  let lastResult;
7239
+ const attempts = [];
7060
7240
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
7061
7241
  if (attempt > 0 && retryDelay > 0) {
7062
7242
  await delay3(retryDelay);
@@ -7072,19 +7252,20 @@ async function runHunt(options) {
7072
7252
  targetUrl,
7073
7253
  allowedDomains
7074
7254
  );
7255
+ attempts.push(toRetryAttempt(attempt + 1, lastResult.result));
7075
7256
  if (lastResult.result.status === "pass") {
7076
- if (attempt > 0) {
7077
- lastResult.result.artifacts.summary = `Passed on attempt ${attempt + 1} of ${maxRetries + 1}`;
7257
+ if (attempts.length > 1) {
7258
+ finalizeRetryOutcome(lastResult, attempts, maxRetries, junit);
7078
7259
  }
7079
- recordHistory(configDir, lastResult, config.history.maxRuns);
7260
+ recordHistory(configDir, lastResult, config.history.maxRuns, attempts.length - 1);
7080
7261
  return lastResult;
7081
7262
  }
7082
7263
  }
7083
- if (maxRetries > 0 && lastResult) {
7084
- lastResult.result.artifacts.summary = `Failed after ${maxRetries + 1} attempts`;
7085
- }
7086
7264
  if (lastResult) {
7087
- recordHistory(configDir, lastResult, config.history.maxRuns);
7265
+ if (attempts.length > 1) {
7266
+ finalizeRetryOutcome(lastResult, attempts, maxRetries, junit);
7267
+ }
7268
+ recordHistory(configDir, lastResult, config.history.maxRuns, attempts.length - 1);
7088
7269
  }
7089
7270
  return lastResult;
7090
7271
  }
@@ -7288,7 +7469,9 @@ async function runNativeHunt(options, config, configDir, target, native) {
7288
7469
  }
7289
7470
  const maxRetries = hunt.retry?.maxRetries ?? 0;
7290
7471
  const retryDelay = hunt.retry?.delay ?? 0;
7472
+ const junit = options.junit ?? config.artifacts.junit;
7291
7473
  let lastResult;
7474
+ const attempts = [];
7292
7475
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
7293
7476
  if (attempt > 0 && retryDelay > 0) {
7294
7477
  await delay3(retryDelay);
@@ -7303,23 +7486,24 @@ async function runNativeHunt(options, config, configDir, target, native) {
7303
7486
  randomVars,
7304
7487
  config.guardrails.allowedApps
7305
7488
  );
7489
+ attempts.push(toRetryAttempt(attempt + 1, lastResult.result));
7306
7490
  if (lastResult.result.status === "pass") {
7307
- if (attempt > 0) {
7308
- lastResult.result.artifacts.summary = `Passed on attempt ${attempt + 1} of ${maxRetries + 1}`;
7491
+ if (attempts.length > 1) {
7492
+ finalizeRetryOutcome(lastResult, attempts, maxRetries, junit);
7309
7493
  }
7310
- recordHistory(configDir, lastResult, config.history.maxRuns);
7494
+ recordHistory(configDir, lastResult, config.history.maxRuns, attempts.length - 1);
7311
7495
  return lastResult;
7312
7496
  }
7313
7497
  }
7314
- if (maxRetries > 0 && lastResult) {
7315
- lastResult.result.artifacts.summary = `Failed after ${maxRetries + 1} attempts`;
7316
- }
7317
7498
  if (lastResult) {
7318
- recordHistory(configDir, lastResult, config.history.maxRuns);
7499
+ if (attempts.length > 1) {
7500
+ finalizeRetryOutcome(lastResult, attempts, maxRetries, junit);
7501
+ }
7502
+ recordHistory(configDir, lastResult, config.history.maxRuns, attempts.length - 1);
7319
7503
  }
7320
7504
  return lastResult;
7321
7505
  }
7322
- function recordHistory(configDir, outcome, maxRuns) {
7506
+ function recordHistory(configDir, outcome, maxRuns, retries = 0) {
7323
7507
  try {
7324
7508
  const relativeRunDir = import_node_path16.default.relative(configDir, outcome.runDir);
7325
7509
  appendEntry(
@@ -7329,7 +7513,9 @@ function recordHistory(configDir, outcome, maxRuns) {
7329
7513
  status: outcome.result.status,
7330
7514
  durationMs: outcome.result.durationMs,
7331
7515
  startedAt: outcome.result.startedAt,
7332
- runDir: relativeRunDir || void 0
7516
+ runDir: relativeRunDir || void 0,
7517
+ // Omit when no retry ran so entries stay additive/backward-compatible.
7518
+ retries: retries > 0 ? retries : void 0
7333
7519
  },
7334
7520
  maxRuns
7335
7521
  );
@@ -8397,6 +8583,7 @@ var STEP_REFERENCE = `
8397
8583
  - waitForSelector: { selector: "#el", timeout: 5000 }
8398
8584
  - waitForUrl: { value: "/path", timeout: 5000 }
8399
8585
  - waitForNetworkIdle: { timeout: 5000 }
8586
+ - waitForResponse: { url: "**/api/orders", status: 200, timeout: 10000 } \u2014 wait for a matching network response (url is a glob/substring; status is optional)
8400
8587
 
8401
8588
  ### Interaction
8402
8589
  - click: "Button Text" \u2014 click by text (tries role=button first)