prowl-tools 0.1.5 → 0.1.7

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
@@ -43,23 +43,38 @@ var init_types = __esm({
43
43
  function isValidHuntName(name) {
44
44
  return HUNT_NAME_PATTERN.test(name);
45
45
  }
46
+ function normalizeHuntName(input) {
47
+ let name = input.trim();
48
+ if (name.startsWith("./")) {
49
+ name = name.slice(2);
50
+ }
51
+ const isSupportedHuntPath = HUNT_FILE_EXTENSION_PATTERN.test(name);
52
+ if (isSupportedHuntPath && name.startsWith(".prowl/hunts/")) {
53
+ name = name.slice(".prowl/hunts/".length);
54
+ } else if (isSupportedHuntPath && name.startsWith("hunts/")) {
55
+ name = name.slice("hunts/".length);
56
+ }
57
+ name = name.replace(HUNT_FILE_EXTENSION_PATTERN, "");
58
+ return name;
59
+ }
46
60
  function assertValidHuntName(name) {
47
61
  if (!isValidHuntName(name)) {
48
62
  throw new Error(
49
- `Invalid hunt name: "${name}". Use only letters, numbers, hyphens, underscores, and forward slashes.`
63
+ `Invalid hunt name: "${name}". A hunt name is its file name under .prowl/hunts/ (letters, numbers, hyphens, underscores, and forward slashes for nested hunts). Accepted forms: a bare name (homepage), a nested name (admin/users), or a path (.prowl/hunts/homepage.yml or hunts/homepage.yml).`
50
64
  );
51
65
  }
52
66
  }
53
- var HUNT_NAME_PATTERN;
67
+ var HUNT_NAME_PATTERN, HUNT_FILE_EXTENSION_PATTERN;
54
68
  var init_hunt_name = __esm({
55
69
  "src/config/hunt-name.ts"() {
56
70
  "use strict";
57
71
  HUNT_NAME_PATTERN = /^[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)*$/;
72
+ HUNT_FILE_EXTENSION_PATTERN = /\.yml$/i;
58
73
  }
59
74
  });
60
75
 
61
76
  // src/config/schema.ts
62
- 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, 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, selectOptionStepSchema, selectStepSchema, onDialogStepSchema, setInputFilesStepSchema, inlineAssertStepSchema, runHuntStepSchema, hoverStepSchema, scrollStepSchema, scrollToStepSchema, screenshotStepSchema, ifStepSchema, repeatStepSchema, mockRouteStepSchema, unmockRouteStepSchema, evalScriptStepSchema, runScriptStepSchema, assertScreenshotStepSchema, assertWithAiStepSchema, copyTextStepSchema, waitForDownloadStepSchema, stepSchema, assertionSchema, huntSchema;
63
78
  var init_schema = __esm({
64
79
  "src/config/schema.ts"() {
65
80
  "use strict";
@@ -311,6 +326,9 @@ var init_schema = __esm({
311
326
  threshold: import_zod.z.number().min(0).max(1).optional()
312
327
  }).strict()
313
328
  }).strict();
329
+ assertWithAiStepSchema = import_zod.z.object({
330
+ assertWithAI: import_zod.z.string().min(1)
331
+ }).strict();
314
332
  copyTextStepSchema = import_zod.z.object({
315
333
  copyText: import_zod.z.object({
316
334
  selector: import_zod.z.string().min(1),
@@ -353,6 +371,7 @@ var init_schema = __esm({
353
371
  evalScriptStepSchema,
354
372
  runScriptStepSchema,
355
373
  assertScreenshotStepSchema,
374
+ assertWithAiStepSchema,
356
375
  copyTextStepSchema,
357
376
  waitForDownloadStepSchema
358
377
  ]);
@@ -695,29 +714,29 @@ async function compareScreenshots(baselinePath, currentPath, diffPath, threshold
695
714
  };
696
715
  }
697
716
  function ensureBaselineDir(configDir) {
698
- const baselineDir = import_node_path8.default.join(configDir, "baselines");
717
+ const baselineDir = import_node_path9.default.join(configDir, "baselines");
699
718
  import_node_fs8.default.mkdirSync(baselineDir, { recursive: true });
700
719
  return baselineDir;
701
720
  }
702
- var import_node_fs8, import_node_path8, import_pngjs, import_pixelmatch;
721
+ var import_node_fs8, import_node_path9, import_pngjs, import_pixelmatch;
703
722
  var init_visual = __esm({
704
723
  "src/runner/visual.ts"() {
705
724
  "use strict";
706
725
  import_node_fs8 = __toESM(require("fs"), 1);
707
- import_node_path8 = __toESM(require("path"), 1);
726
+ import_node_path9 = __toESM(require("path"), 1);
708
727
  import_pngjs = require("pngjs");
709
728
  import_pixelmatch = __toESM(require("pixelmatch"), 1);
710
729
  }
711
730
  });
712
731
 
713
732
  // src/cli/program.ts
714
- var import_commander13 = require("commander");
733
+ var import_commander14 = require("commander");
715
734
 
716
735
  // package.json
717
736
  var package_default = {
718
737
  name: "prowl-tools",
719
- version: "0.1.5",
720
- description: "CLI-first QA testing tool for deterministic Playwright flows.",
738
+ version: "0.1.7",
739
+ description: "E2E testing for native macOS apps and web apps from declarative YAML hunts.",
721
740
  type: "module",
722
741
  license: "Apache-2.0",
723
742
  author: "Prowl Tools",
@@ -732,9 +751,12 @@ var package_default = {
732
751
  keywords: [
733
752
  "testing",
734
753
  "qa",
754
+ "e2e",
755
+ "macos",
756
+ "desktop-testing",
757
+ "accessibility",
735
758
  "playwright",
736
759
  "yaml",
737
- "e2e",
738
760
  "browser-testing",
739
761
  "automation",
740
762
  "web-testing",
@@ -808,7 +830,7 @@ var import_chalk3 = __toESM(require("chalk"), 1);
808
830
 
809
831
  // src/runner/index.ts
810
832
  var import_node_fs14 = __toESM(require("fs"), 1);
811
- var import_node_path14 = __toESM(require("path"), 1);
833
+ var import_node_path15 = __toESM(require("path"), 1);
812
834
  init_loader();
813
835
 
814
836
  // src/config/interpolate.ts
@@ -1105,6 +1127,11 @@ function interpolateStep(step, vars, stepPath2, redacted) {
1105
1127
  }
1106
1128
  };
1107
1129
  }
1130
+ if ("assertWithAI" in step) {
1131
+ return {
1132
+ assertWithAI: interpolateString(step.assertWithAI, vars).value
1133
+ };
1134
+ }
1108
1135
  if ("copyText" in step) {
1109
1136
  return {
1110
1137
  copyText: {
@@ -1597,7 +1624,8 @@ function createPlaywrightDriver(page) {
1597
1624
  // src/browser/mac-helper.ts
1598
1625
  var import_node_child_process2 = require("child_process");
1599
1626
  var import_node_fs4 = __toESM(require("fs"), 1);
1600
- var import_node_path4 = __toESM(require("path"), 1);
1627
+ var import_node_os2 = __toESM(require("os"), 1);
1628
+ var import_node_path5 = __toESM(require("path"), 1);
1601
1629
  var import_node_url = require("url");
1602
1630
 
1603
1631
  // src/browser/mac-driver.ts
@@ -1776,24 +1804,67 @@ function createMacDriver(client, options = {}) {
1776
1804
  };
1777
1805
  }
1778
1806
 
1807
+ // src/browser/macdriver-release.ts
1808
+ var import_node_os = __toESM(require("os"), 1);
1809
+ var import_node_path4 = __toESM(require("path"), 1);
1810
+ var HELPER_BINARY = "prowl-macdriver";
1811
+ var MACDRIVER_VERSION = "0.1.0";
1812
+ var MACDRIVER_REPO = "prowl-tools/prowl";
1813
+ var MACDRIVER_SIGNING_IDENTIFIER = "tools.prowl.macdriver";
1814
+ var MACDRIVER_SIGNING_AUTHORITY_PREFIX = "Developer ID Application: Genkei Labs";
1815
+ var MACDRIVER_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z][0-9A-Za-z.-]*)?(?:\+[0-9A-Za-z][0-9A-Za-z.-]*)?$/;
1816
+ function validateMacdriverVersion(version) {
1817
+ if (!MACDRIVER_VERSION_PATTERN.test(version)) {
1818
+ throw new Error(
1819
+ `Invalid prowl-macdriver version "${version}". Expected a release version like 0.1.0.`
1820
+ );
1821
+ }
1822
+ return version;
1823
+ }
1824
+ function macdriverReleaseTag(version = MACDRIVER_VERSION) {
1825
+ return `macdriver-v${validateMacdriverVersion(version)}`;
1826
+ }
1827
+ function macdriverAssetName(version = MACDRIVER_VERSION) {
1828
+ return `prowl-macdriver-v${validateMacdriverVersion(version)}-universal.zip`;
1829
+ }
1830
+ function macdriverChecksumName(version = MACDRIVER_VERSION) {
1831
+ return `${macdriverAssetName(version)}.sha256`;
1832
+ }
1833
+ function macdriverAssetUrl(assetName, version = MACDRIVER_VERSION) {
1834
+ return `https://github.com/${MACDRIVER_REPO}/releases/download/${macdriverReleaseTag(version)}/${assetName}`;
1835
+ }
1836
+ function macdriverInstallRoot(homedir = import_node_os.default.homedir()) {
1837
+ return import_node_path4.default.join(homedir, ".prowl", "macdriver");
1838
+ }
1839
+ function macdriverVersionDir(version = MACDRIVER_VERSION, homedir = import_node_os.default.homedir()) {
1840
+ const root = import_node_path4.default.resolve(macdriverInstallRoot(homedir));
1841
+ const versionDir = import_node_path4.default.resolve(root, validateMacdriverVersion(version));
1842
+ if (!versionDir.startsWith(root + import_node_path4.default.sep)) {
1843
+ throw new Error(`Resolved prowl-macdriver version directory escaped install root: ${versionDir}`);
1844
+ }
1845
+ return versionDir;
1846
+ }
1847
+ function macdriverInstalledBinary(version = MACDRIVER_VERSION, homedir = import_node_os.default.homedir()) {
1848
+ return import_node_path4.default.join(macdriverVersionDir(version, homedir), HELPER_BINARY);
1849
+ }
1850
+
1779
1851
  // src/browser/mac-helper.ts
1780
1852
  var import_meta = {};
1781
- var HELPER_BINARY = "prowl-macdriver";
1782
1853
  function macdriverBuildInstructions() {
1783
- return "The macOS target requires the experimental `prowl-macdriver` helper, which is not shipped in the npm package. Build it locally:\n cd macdriver && swift build -c release\nor point Prowl at a prebuilt binary via the PROWL_MACDRIVER_BIN environment variable.";
1854
+ return "The macOS target needs the `prowl-macdriver` helper. Install the prebuilt, signed binary (recommended):\n prowl macdriver install\nContributors building from source can instead run:\n cd macdriver && swift build -c release\nor point Prowl at a prebuilt binary via the PROWL_MACDRIVER_BIN environment variable.";
1784
1855
  }
1785
1856
  function getPackageRoot() {
1786
- let dir = import_node_path4.default.dirname((0, import_node_url.fileURLToPath)(import_meta.url));
1787
- const root = import_node_path4.default.parse(dir).root;
1857
+ let dir = import_node_path5.default.dirname((0, import_node_url.fileURLToPath)(import_meta.url));
1858
+ const root = import_node_path5.default.parse(dir).root;
1788
1859
  while (dir !== root) {
1789
- if (import_node_fs4.default.existsSync(import_node_path4.default.join(dir, "package.json"))) {
1860
+ if (import_node_fs4.default.existsSync(import_node_path5.default.join(dir, "package.json"))) {
1790
1861
  return dir;
1791
1862
  }
1792
- dir = import_node_path4.default.dirname(dir);
1863
+ dir = import_node_path5.default.dirname(dir);
1793
1864
  }
1794
1865
  return root;
1795
1866
  }
1796
- function resolveHelperBinary(env = process.env) {
1867
+ function resolveHelperBinary(env = process.env, options = {}) {
1797
1868
  const override = env.PROWL_MACDRIVER_BIN;
1798
1869
  if (override) {
1799
1870
  if (!import_node_fs4.default.existsSync(override)) {
@@ -1804,10 +1875,15 @@ ${macdriverBuildInstructions()}`
1804
1875
  }
1805
1876
  return override;
1806
1877
  }
1878
+ const homedir = options.homedir ?? import_node_os2.default.homedir();
1879
+ const userBinary = macdriverInstalledBinary(MACDRIVER_VERSION, homedir);
1880
+ if (import_node_fs4.default.existsSync(userBinary)) {
1881
+ return userBinary;
1882
+ }
1807
1883
  const root = getPackageRoot();
1808
1884
  const candidates = [
1809
- import_node_path4.default.join(root, "macdriver", ".build", "release", HELPER_BINARY),
1810
- import_node_path4.default.join(root, "macdriver", ".build", "debug", HELPER_BINARY)
1885
+ import_node_path5.default.join(root, "macdriver", ".build", "release", HELPER_BINARY),
1886
+ import_node_path5.default.join(root, "macdriver", ".build", "debug", HELPER_BINARY)
1811
1887
  ];
1812
1888
  for (const candidate of candidates) {
1813
1889
  if (import_node_fs4.default.existsSync(candidate)) {
@@ -1983,10 +2059,217 @@ async function closeMacSession(session) {
1983
2059
  var import_node_module = require("module");
1984
2060
  var import_node_child_process4 = require("child_process");
1985
2061
  var import_node_fs6 = __toESM(require("fs"), 1);
1986
- var import_node_path5 = __toESM(require("path"), 1);
2062
+ var import_node_path6 = __toESM(require("path"), 1);
1987
2063
 
1988
2064
  // src/browser/android-driver.ts
1989
2065
  var import_node_fs5 = __toESM(require("fs"), 1);
2066
+
2067
+ // src/analyzer/xml.ts
2068
+ var ENTITIES = {
2069
+ amp: "&",
2070
+ lt: "<",
2071
+ gt: ">",
2072
+ quot: '"',
2073
+ apos: "'"
2074
+ };
2075
+ function decodeXmlEntities(value) {
2076
+ if (!value.includes("&")) {
2077
+ return value;
2078
+ }
2079
+ return value.replace(/&(#(?:[xX][0-9a-fA-F]+|[0-9]+)|[a-zA-Z]+);/g, (match, code) => {
2080
+ if (code[0] === "#") {
2081
+ const hex = code[1] === "x" || code[1] === "X";
2082
+ const num2 = Number.parseInt(code.slice(hex ? 2 : 1), hex ? 16 : 10);
2083
+ return Number.isSafeInteger(num2) && num2 <= 1114111 ? String.fromCodePoint(num2) : match;
2084
+ }
2085
+ const named = ENTITIES[code];
2086
+ return named ?? match;
2087
+ });
2088
+ }
2089
+ var ATTR_RE = /([^\s=/]+)\s*=\s*("([^"]*)"|'([^']*)')/g;
2090
+ function parseTagBody(body) {
2091
+ const trimmed = body.trim();
2092
+ const nameMatch = /^([^\s/>]+)/.exec(trimmed);
2093
+ const tag = nameMatch ? nameMatch[1] : "";
2094
+ const attrs = {};
2095
+ const rest = trimmed.slice(tag.length);
2096
+ ATTR_RE.lastIndex = 0;
2097
+ let m;
2098
+ while ((m = ATTR_RE.exec(rest)) !== null) {
2099
+ const rawValue = m[3] !== void 0 ? m[3] : m[4] ?? "";
2100
+ attrs[m[1]] = decodeXmlEntities(rawValue);
2101
+ }
2102
+ return { tag, attrs };
2103
+ }
2104
+ function parseXml(input) {
2105
+ const n = input.length;
2106
+ const stack = [];
2107
+ let root = null;
2108
+ let i = 0;
2109
+ while (i < n) {
2110
+ const lt = input.indexOf("<", i);
2111
+ if (lt < 0) {
2112
+ break;
2113
+ }
2114
+ i = lt + 1;
2115
+ const ch = input[i];
2116
+ if (ch === "?") {
2117
+ const end = input.indexOf("?>", i);
2118
+ i = end < 0 ? n : end + 2;
2119
+ continue;
2120
+ }
2121
+ if (ch === "!") {
2122
+ if (input.startsWith("!--", i)) {
2123
+ const end = input.indexOf("-->", i);
2124
+ i = end < 0 ? n : end + 3;
2125
+ } else {
2126
+ const end = input.indexOf(">", i);
2127
+ i = end < 0 ? n : end + 1;
2128
+ }
2129
+ continue;
2130
+ }
2131
+ if (ch === "/") {
2132
+ const gt = input.indexOf(">", i);
2133
+ i = gt < 0 ? n : gt + 1;
2134
+ stack.pop();
2135
+ continue;
2136
+ }
2137
+ let j = i;
2138
+ let quote2 = null;
2139
+ while (j < n) {
2140
+ const c = input[j];
2141
+ if (quote2 !== null) {
2142
+ if (c === quote2) {
2143
+ quote2 = null;
2144
+ }
2145
+ } else if (c === '"' || c === "'") {
2146
+ quote2 = c;
2147
+ } else if (c === ">") {
2148
+ break;
2149
+ }
2150
+ j += 1;
2151
+ }
2152
+ const inner = input.slice(i, j);
2153
+ i = j + 1;
2154
+ const selfClose = inner.endsWith("/");
2155
+ const body = selfClose ? inner.slice(0, -1) : inner;
2156
+ const { tag, attrs } = parseTagBody(body);
2157
+ if (tag.length === 0) {
2158
+ continue;
2159
+ }
2160
+ const element = { tag, attrs, children: [] };
2161
+ const parent = stack[stack.length - 1];
2162
+ if (parent) {
2163
+ parent.children.push(element);
2164
+ }
2165
+ if (root === null) {
2166
+ root = element;
2167
+ }
2168
+ if (!selfClose) {
2169
+ stack.push(element);
2170
+ }
2171
+ }
2172
+ return root;
2173
+ }
2174
+
2175
+ // src/selector/native.ts
2176
+ function unquoteSelectorValue(value) {
2177
+ const trimmed = value.trim();
2178
+ const first = trimmed[0];
2179
+ if ((first === '"' || first === "'") && trimmed.endsWith(first) && trimmed.length >= 2) {
2180
+ return trimmed.slice(1, -1);
2181
+ }
2182
+ return trimmed;
2183
+ }
2184
+ function quoteSelectorValue(value) {
2185
+ return `"${value}"`;
2186
+ }
2187
+ var ROLE_SELECTOR_RE = /^role=([A-Za-z][\w.$-]*)(?:\[name=(.+)\])?$/s;
2188
+ var NATIVE_SELECTOR_PREFIX_RE = /^(id|label|text|role)=/;
2189
+ function invalidSelectorMessage(selector, reason) {
2190
+ return `Invalid native selector ${JSON.stringify(selector)}: ${reason}.`;
2191
+ }
2192
+ function parseNativeSelector(selector) {
2193
+ const trimmed = selector.trim();
2194
+ if (trimmed.length === 0) {
2195
+ throw new Error(
2196
+ invalidSelectorMessage(
2197
+ selector,
2198
+ "selector is empty; use id=, label=, text=, role=, :focus, or a bare text value"
2199
+ )
2200
+ );
2201
+ }
2202
+ if (trimmed === ":focus") {
2203
+ return { kind: "focused" };
2204
+ }
2205
+ const idMatch = /^id=(.+)$/s.exec(trimmed);
2206
+ if (idMatch) {
2207
+ return { kind: "id", value: unquoteSelectorValue(idMatch[1]) };
2208
+ }
2209
+ const roleMatch = ROLE_SELECTOR_RE.exec(trimmed);
2210
+ if (roleMatch) {
2211
+ const name = roleMatch[2] !== void 0 ? unquoteSelectorValue(roleMatch[2]) : void 0;
2212
+ return name !== void 0 && name.length > 0 ? { kind: "role", role: roleMatch[1], name } : { kind: "role", role: roleMatch[1] };
2213
+ }
2214
+ const labelMatch = /^label=(.+)$/s.exec(trimmed);
2215
+ if (labelMatch) {
2216
+ return { kind: "label", value: unquoteSelectorValue(labelMatch[1]) };
2217
+ }
2218
+ const textMatch = /^text=(.+)$/s.exec(trimmed);
2219
+ if (textMatch) {
2220
+ return { kind: "text", value: unquoteSelectorValue(textMatch[1]) };
2221
+ }
2222
+ const prefix = NATIVE_SELECTOR_PREFIX_RE.exec(trimmed);
2223
+ if (prefix) {
2224
+ throw new Error(
2225
+ invalidSelectorMessage(
2226
+ selector,
2227
+ `malformed ${prefix[1]}= selector; expected id=<value>, label=<value>, text=<value>, or role=<Type>[name=<value>]`
2228
+ )
2229
+ );
2230
+ }
2231
+ return { kind: "text", value: trimmed };
2232
+ }
2233
+ function unwrapNativeTextSelector(selector) {
2234
+ const trimmed = selector.trim();
2235
+ if (!trimmed.startsWith("text=")) {
2236
+ return null;
2237
+ }
2238
+ return unquoteSelectorValue(trimmed.slice("text=".length));
2239
+ }
2240
+ function qualifyResourceId(value, appPackage) {
2241
+ if (value.includes(":") || !appPackage) {
2242
+ return value;
2243
+ }
2244
+ return `${appPackage}:id/${value}`;
2245
+ }
2246
+ function normalizeXcuiClassName(role) {
2247
+ return role.startsWith("XCUIElementType") ? role : `XCUIElementType${role}`;
2248
+ }
2249
+ function shortIosType(type) {
2250
+ return type.startsWith("XCUIElementType") ? type.slice("XCUIElementType".length) : type;
2251
+ }
2252
+ function rankNativeSelectors(fields) {
2253
+ const selectors = [];
2254
+ if (fields.id) {
2255
+ selectors.push(`id=${fields.id}`);
2256
+ }
2257
+ if (fields.label) {
2258
+ selectors.push(`label=${quoteSelectorValue(fields.label)}`);
2259
+ }
2260
+ if (fields.role && fields.name) {
2261
+ selectors.push(`role=${fields.role}[name=${quoteSelectorValue(fields.name)}]`);
2262
+ }
2263
+ if (fields.name) {
2264
+ selectors.push(`text=${quoteSelectorValue(fields.name)}`);
2265
+ }
2266
+ if (selectors.length === 0 && fields.role) {
2267
+ selectors.push(`role=${fields.role}`);
2268
+ }
2269
+ return selectors;
2270
+ }
2271
+
2272
+ // src/browser/android-driver.ts
1990
2273
  var ANDROID_CAPABILITIES = /* @__PURE__ */ new Set([
1991
2274
  "query",
1992
2275
  "interact",
@@ -2020,50 +2303,29 @@ var ANDROID_KEYCODES = {
2020
2303
  pageup: 92,
2021
2304
  pagedown: 93
2022
2305
  };
2023
- function unquote2(value) {
2024
- const trimmed = value.trim();
2025
- const first = trimmed[0];
2026
- if ((first === '"' || first === "'") && trimmed.endsWith(first) && trimmed.length >= 2) {
2027
- return trimmed.slice(1, -1);
2028
- }
2029
- return trimmed;
2030
- }
2031
2306
  function escapeUiSelectorArg(value) {
2032
2307
  return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
2033
2308
  }
2034
- function parseAndroidSelector(selector) {
2035
- const trimmed = selector.trim();
2036
- if (trimmed === ":focus") {
2037
- return { by: "focused" };
2038
- }
2039
- const idMatch = /^id=(.+)$/s.exec(trimmed);
2040
- if (idMatch) {
2041
- return { by: "id", value: unquote2(idMatch[1]) };
2042
- }
2043
- const roleMatch = /^role=([A-Za-z][\w.$-]*)(?:\[name=(.+)\])?$/s.exec(trimmed);
2044
- if (roleMatch) {
2045
- const name = roleMatch[2] !== void 0 ? unquote2(roleMatch[2]) : void 0;
2046
- return name !== void 0 && name.length > 0 ? { by: "role", role: roleMatch[1], name } : { by: "role", role: roleMatch[1] };
2047
- }
2048
- const labelMatch = /^label=(.+)$/s.exec(trimmed);
2049
- if (labelMatch) {
2050
- return { by: "accessibilityId", value: unquote2(labelMatch[1]) };
2051
- }
2052
- const textMatch = /^text=(.+)$/s.exec(trimmed);
2053
- if (textMatch) {
2054
- return { by: "text", value: unquote2(textMatch[1]) };
2309
+ function toAndroidQuery(selector) {
2310
+ switch (selector.kind) {
2311
+ case "focused":
2312
+ return { by: "focused" };
2313
+ case "id":
2314
+ return { by: "id", value: selector.value };
2315
+ case "role":
2316
+ return selector.name !== void 0 ? { by: "role", role: selector.role, name: selector.name } : { by: "role", role: selector.role };
2317
+ case "label":
2318
+ return { by: "accessibilityId", value: selector.value };
2319
+ case "text":
2320
+ return { by: "text", value: selector.value };
2055
2321
  }
2056
- return { by: "text", value: trimmed };
2322
+ }
2323
+ function parseAndroidSelector(selector) {
2324
+ return toAndroidQuery(parseNativeSelector(selector));
2057
2325
  }
2058
2326
  function locator(strategy, selector) {
2059
2327
  return { strategy, selector, context: "" };
2060
2328
  }
2061
- function qualifyResourceId(value, appPackage) {
2062
- if (value.includes(":") || !appPackage) {
2063
- return value;
2064
- }
2065
- return `${appPackage}:id/${value}`;
2066
- }
2067
2329
  function androidQueryToLocator(query, options = {}) {
2068
2330
  switch (query.by) {
2069
2331
  case "id":
@@ -2090,11 +2352,7 @@ function androidQueryToLocator(query, options = {}) {
2090
2352
  }
2091
2353
  }
2092
2354
  function unwrapAndroidTextSelector(selector) {
2093
- const trimmed = selector.trim();
2094
- if (!trimmed.startsWith("text=")) {
2095
- return null;
2096
- }
2097
- return unquote2(trimmed.slice(5));
2355
+ return unwrapNativeTextSelector(selector);
2098
2356
  }
2099
2357
  function keyCodeFor(key) {
2100
2358
  const code = ANDROID_KEYCODES[key.trim().toLowerCase()];
@@ -2369,22 +2627,63 @@ async function installApk(runner, serial, apkPath) {
2369
2627
  );
2370
2628
  }
2371
2629
  }
2630
+ function formatUnknownError(error) {
2631
+ return error instanceof Error ? error.message : String(error);
2632
+ }
2633
+ function runnerPhaseError(message, error) {
2634
+ return new Error(`${message}: ${formatUnknownError(error)}`, { cause: error });
2635
+ }
2636
+ function parseResolvedComponent(stdout, pkg) {
2637
+ const lines = stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
2638
+ for (let i = lines.length - 1; i >= 0; i--) {
2639
+ if (/^[\w.]+\/[\w.$]+$/.test(lines[i]) && lines[i].startsWith(`${pkg}/`)) {
2640
+ return lines[i];
2641
+ }
2642
+ }
2643
+ return null;
2644
+ }
2372
2645
  async function launchPackage(runner, serial, pkg) {
2373
- const result = await runner(
2374
- withSerial(serial, [
2375
- "shell",
2376
- "monkey",
2377
- "-p",
2378
- pkg,
2379
- "-c",
2380
- "android.intent.category.LAUNCHER",
2381
- "1"
2382
- ]),
2383
- { timeoutMs: 3e4 }
2384
- );
2385
- if (result.code !== 0 || /no activities found|aborted|cannot launch/i.test(result.stdout + result.stderr)) {
2646
+ let resolved;
2647
+ try {
2648
+ resolved = await runner(
2649
+ withSerial(serial, [
2650
+ "shell",
2651
+ "cmd",
2652
+ "package",
2653
+ "resolve-activity",
2654
+ "--brief",
2655
+ "-c",
2656
+ "android.intent.category.LAUNCHER",
2657
+ pkg
2658
+ ]),
2659
+ { timeoutMs: 3e4 }
2660
+ );
2661
+ } catch (error) {
2662
+ throw runnerPhaseError(
2663
+ `Failed to resolve the launcher activity for Android package "${pkg}" via adb`,
2664
+ error
2665
+ );
2666
+ }
2667
+ const component = parseResolvedComponent(resolved.stdout, pkg);
2668
+ if (resolved.code !== 0 || !component) {
2669
+ throw new Error(
2670
+ `Could not resolve a launcher activity for Android package "${pkg}" (exit ${resolved.code})${(resolved.stdout + resolved.stderr).trim() ? `: ${(resolved.stdout + resolved.stderr).trim().slice(0, 300)}` : ""}. Is it installed?`
2671
+ );
2672
+ }
2673
+ let start;
2674
+ try {
2675
+ start = await runner(withSerial(serial, ["shell", "am", "start", "-n", component]), {
2676
+ timeoutMs: 3e4
2677
+ });
2678
+ } catch (error) {
2679
+ throw runnerPhaseError(
2680
+ `Failed to launch Android package "${pkg}" with \`am start\` (${component}) via adb`,
2681
+ error
2682
+ );
2683
+ }
2684
+ if (start.code !== 0 || /error|does not exist|cannot start/i.test(start.stdout + start.stderr)) {
2386
2685
  throw new Error(
2387
- `Failed to launch Android package "${pkg}" (exit ${result.code}): ${(result.stdout + result.stderr).trim().slice(0, 400)}. Is it installed?`
2686
+ `Failed to launch Android package "${pkg}" (${component}, exit ${start.code}): ${(start.stdout + start.stderr).trim().slice(0, 400)}.`
2388
2687
  );
2389
2688
  }
2390
2689
  }
@@ -2446,12 +2745,12 @@ var Uia2Transport = class {
2446
2745
  * {@link Uia2HttpError} on a non-2xx response, or a timeout error when the
2447
2746
  * per-request deadline elapses.
2448
2747
  */
2449
- async request(method, path25, body, timeoutMs) {
2748
+ async request(method, path27, body, timeoutMs) {
2450
2749
  const requestTimeoutMs = timeoutMs ?? this.requestTimeoutMs;
2451
2750
  const controller = new AbortController();
2452
2751
  const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
2453
2752
  timer.unref?.();
2454
- const url = `${this.baseUrl}${path25}`;
2753
+ const url = `${this.baseUrl}${path27}`;
2455
2754
  let response;
2456
2755
  try {
2457
2756
  response = await this.fetchImpl(url, {
@@ -2463,7 +2762,7 @@ var Uia2Transport = class {
2463
2762
  } catch (error) {
2464
2763
  if (controller.signal.aborted) {
2465
2764
  const shown = requestTimeoutMs >= 1e3 ? `${Math.round(requestTimeoutMs / 1e3)}s` : `${requestTimeoutMs}ms`;
2466
- throw new Error(`uiautomator2 request ${method} ${path25} timed out after ${shown}`);
2765
+ throw new Error(`uiautomator2 request ${method} ${path27} timed out after ${shown}`);
2467
2766
  }
2468
2767
  throw error instanceof Error ? error : new Error(String(error));
2469
2768
  } finally {
@@ -2474,7 +2773,7 @@ var Uia2Transport = class {
2474
2773
  if (!response.ok) {
2475
2774
  const wdError = extractWebdriverError(parsed);
2476
2775
  throw new Uia2HttpError(
2477
- `uiautomator2 ${method} ${path25} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
2776
+ `uiautomator2 ${method} ${path27} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
2478
2777
  response.status,
2479
2778
  wdError
2480
2779
  );
@@ -2557,10 +2856,10 @@ function sleep(ms) {
2557
2856
  }
2558
2857
  function createUia2AgentClient(transport, sessionId, options = {}) {
2559
2858
  const base = `/session/${sessionId}`;
2560
- async function locate(query, path25) {
2859
+ async function locate(query, path27) {
2561
2860
  return transport.request(
2562
2861
  "POST",
2563
- `${base}${path25}`,
2862
+ `${base}${path27}`,
2564
2863
  androidQueryToLocator(query, { appPackage: options.appPackage })
2565
2864
  );
2566
2865
  }
@@ -2602,6 +2901,13 @@ function createUia2AgentClient(transport, sessionId, options = {}) {
2602
2901
  }
2603
2902
  return Buffer.from(value, "base64");
2604
2903
  },
2904
+ async source() {
2905
+ const value = await transport.request("GET", `${base}/source`);
2906
+ if (typeof value !== "string") {
2907
+ throw new Error("uiautomator2 /source did not return XML text; cannot analyze Android UI hierarchy");
2908
+ }
2909
+ return value;
2910
+ },
2605
2911
  async close() {
2606
2912
  await transport.request("DELETE", base).catch(() => void 0);
2607
2913
  }
@@ -2620,10 +2926,10 @@ function resolveAgentApks(requireFn = (0, import_node_module.createRequire)(impo
2620
2926
  "The Android target requires the `appium-uiautomator2-server` package (its prebuilt APKs). It is an optional dependency of prowl-tools; it may have been skipped (--omit=optional) or failed to install. Restore it for a global Prowl install with: npm install -g appium-uiautomator2-server@10.6.2. If Prowl is installed locally in a project, run: npm install appium-uiautomator2-server@10.6.2"
2621
2927
  );
2622
2928
  }
2623
- const pkgDir = import_node_path5.default.dirname(pkgJsonPath);
2929
+ const pkgDir = import_node_path6.default.dirname(pkgJsonPath);
2624
2930
  const version = requireFn(pkgJsonPath).version;
2625
- const serverApk = import_node_path5.default.join(pkgDir, "apks", `appium-uiautomator2-server-v${version}.apk`);
2626
- const testApk = import_node_path5.default.join(pkgDir, "apks", "appium-uiautomator2-server-debug-androidTest.apk");
2931
+ const serverApk = import_node_path6.default.join(pkgDir, "apks", `appium-uiautomator2-server-v${version}.apk`);
2932
+ const testApk = import_node_path6.default.join(pkgDir, "apks", "appium-uiautomator2-server-debug-androidTest.apk");
2627
2933
  for (const apk of [serverApk, testApk]) {
2628
2934
  if (!import_node_fs6.default.existsSync(apk)) {
2629
2935
  throw new Error(`Expected uiautomator2 agent APK is missing: ${apk}. Reinstall dependencies.`);
@@ -2671,7 +2977,7 @@ async function resolvePackage(app, runner, serial, aaptResolver, allowedApps) {
2671
2977
  assertAndroidAppAllowed(allowedApps, app);
2672
2978
  return app;
2673
2979
  }
2674
- const apkPath = import_node_path5.default.resolve(app);
2980
+ const apkPath = import_node_path6.default.resolve(app);
2675
2981
  if (!import_node_fs6.default.existsSync(apkPath)) {
2676
2982
  throw new Error(`APK not found: ${apkPath}`);
2677
2983
  }
@@ -2745,9 +3051,10 @@ async function closeAndroidSession(session) {
2745
3051
 
2746
3052
  // src/browser/ios-helper.ts
2747
3053
  var import_node_module2 = require("module");
3054
+ var import_node_child_process6 = require("child_process");
2748
3055
  var import_node_fs7 = __toESM(require("fs"), 1);
2749
- var import_node_os2 = __toESM(require("os"), 1);
2750
- var import_node_path7 = __toESM(require("path"), 1);
3056
+ var import_node_os4 = __toESM(require("os"), 1);
3057
+ var import_node_path8 = __toESM(require("path"), 1);
2751
3058
 
2752
3059
  // src/browser/ios-driver.ts
2753
3060
  var IOS_CAPABILITIES = /* @__PURE__ */ new Set([
@@ -2759,43 +3066,25 @@ var IOS_CAPABILITIES = /* @__PURE__ */ new Set([
2759
3066
  var WAIT_POLL_INTERVAL_MS2 = 250;
2760
3067
  var DEFAULT_WAIT_TIMEOUT_MS2 = 5e3;
2761
3068
  var IOS_PRESS_KEYS = ["backspace", "del", "delete", "enter", "home", "return"];
2762
- function unquote3(value) {
2763
- const trimmed = value.trim();
2764
- const first = trimmed[0];
2765
- if ((first === '"' || first === "'") && trimmed.endsWith(first) && trimmed.length >= 2) {
2766
- return trimmed.slice(1, -1);
2767
- }
2768
- return trimmed;
2769
- }
2770
3069
  function escapePredicateArg(value) {
2771
3070
  return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
2772
3071
  }
2773
- function normalizeXcuiClassName(role) {
2774
- return role.startsWith("XCUIElementType") ? role : `XCUIElementType${role}`;
3072
+ function toIosQuery(selector) {
3073
+ switch (selector.kind) {
3074
+ case "focused":
3075
+ return { by: "focused" };
3076
+ case "id":
3077
+ return { by: "accessibilityId", value: selector.value };
3078
+ case "role":
3079
+ return selector.name !== void 0 ? { by: "role", role: selector.role, name: selector.name } : { by: "role", role: selector.role };
3080
+ case "label":
3081
+ return { by: "label", value: selector.value };
3082
+ case "text":
3083
+ return { by: "text", value: selector.value };
3084
+ }
2775
3085
  }
2776
3086
  function parseIosSelector(selector) {
2777
- const trimmed = selector.trim();
2778
- if (trimmed === ":focus") {
2779
- return { by: "focused" };
2780
- }
2781
- const idMatch = /^id=(.+)$/s.exec(trimmed);
2782
- if (idMatch) {
2783
- return { by: "accessibilityId", value: unquote3(idMatch[1]) };
2784
- }
2785
- const roleMatch = /^role=([A-Za-z][\w.$-]*)(?:\[name=(.+)\])?$/s.exec(trimmed);
2786
- if (roleMatch) {
2787
- const name = roleMatch[2] !== void 0 ? unquote3(roleMatch[2]) : void 0;
2788
- return name !== void 0 && name.length > 0 ? { by: "role", role: roleMatch[1], name } : { by: "role", role: roleMatch[1] };
2789
- }
2790
- const labelMatch = /^label=(.+)$/s.exec(trimmed);
2791
- if (labelMatch) {
2792
- return { by: "label", value: unquote3(labelMatch[1]) };
2793
- }
2794
- const textMatch = /^text=(.+)$/s.exec(trimmed);
2795
- if (textMatch) {
2796
- return { by: "text", value: unquote3(textMatch[1]) };
2797
- }
2798
- return { by: "text", value: trimmed };
3087
+ return toIosQuery(parseNativeSelector(selector));
2799
3088
  }
2800
3089
  function iosQueryToLocator(query) {
2801
3090
  switch (query.by) {
@@ -2827,11 +3116,7 @@ function iosQueryToLocator(query) {
2827
3116
  }
2828
3117
  }
2829
3118
  function unwrapIosTextSelector(selector) {
2830
- const trimmed = selector.trim();
2831
- if (!trimmed.startsWith("text=")) {
2832
- return null;
2833
- }
2834
- return unquote3(trimmed.slice(5));
3119
+ return unwrapNativeTextSelector(selector);
2835
3120
  }
2836
3121
  function delay2(ms) {
2837
3122
  return new Promise((resolve) => {
@@ -2993,9 +3278,22 @@ function createIosDriver(client, options) {
2993
3278
  var import_node_child_process5 = require("child_process");
2994
3279
  var import_promises = require("fs/promises");
2995
3280
  var import_node_net = __toESM(require("net"), 1);
2996
- var import_node_os = __toESM(require("os"), 1);
2997
- var import_node_path6 = __toESM(require("path"), 1);
2998
- var DEFAULT_SIMULATOR_LOCK_ROOT = import_node_path6.default.join(import_node_os.default.tmpdir(), "prowl-ios-simulator-locks");
3281
+ var import_node_os3 = __toESM(require("os"), 1);
3282
+ var import_node_path7 = __toESM(require("path"), 1);
3283
+ var spawnXcrunProcess = (args, options) => {
3284
+ const child = (0, import_node_child_process5.spawn)("xcrun", args, {
3285
+ stdio: "ignore",
3286
+ env: options?.env ? { ...process.env, ...options.env } : process.env
3287
+ });
3288
+ child.on("error", () => {
3289
+ });
3290
+ return {
3291
+ kill: () => {
3292
+ child.kill();
3293
+ }
3294
+ };
3295
+ };
3296
+ var DEFAULT_SIMULATOR_LOCK_ROOT = import_node_path7.default.join(import_node_os3.default.tmpdir(), "prowl-ios-simulator-locks");
2999
3297
  var SIMULATOR_LOCK_OWNER_FILE = "owner.json";
3000
3298
  var execFileXcrunRunner = (args, options) => new Promise((resolve) => {
3001
3299
  (0, import_node_child_process5.execFile)(
@@ -3035,7 +3333,7 @@ function isProcessAlive(pid) {
3035
3333
  }
3036
3334
  async function removeStaleSimulatorLock(lockPath) {
3037
3335
  try {
3038
- const ownerText = await (0, import_promises.readFile)(import_node_path6.default.join(lockPath, SIMULATOR_LOCK_OWNER_FILE), "utf8");
3336
+ const ownerText = await (0, import_promises.readFile)(import_node_path7.default.join(lockPath, SIMULATOR_LOCK_OWNER_FILE), "utf8");
3039
3337
  const owner = JSON.parse(ownerText);
3040
3338
  if (typeof owner.pid === "number" && Number.isInteger(owner.pid) && owner.pid > 0 && !isProcessAlive(owner.pid)) {
3041
3339
  await (0, import_promises.rm)(lockPath, { recursive: true, force: true });
@@ -3053,7 +3351,7 @@ function simulatorReservedError(udid) {
3053
3351
  }
3054
3352
  async function reserveSimulatorUdid(udid, options = {}) {
3055
3353
  const lockRoot = options.lockRoot ?? DEFAULT_SIMULATOR_LOCK_ROOT;
3056
- const lockPath = import_node_path6.default.join(lockRoot, simulatorLockName(udid));
3354
+ const lockPath = import_node_path7.default.join(lockRoot, simulatorLockName(udid));
3057
3355
  await (0, import_promises.mkdir)(lockRoot, { recursive: true });
3058
3356
  for (let attempt = 0; attempt < 2; attempt += 1) {
3059
3357
  try {
@@ -3077,7 +3375,7 @@ async function reserveSimulatorUdid(udid, options = {}) {
3077
3375
  };
3078
3376
  try {
3079
3377
  await (0, import_promises.writeFile)(
3080
- import_node_path6.default.join(lockPath, SIMULATOR_LOCK_OWNER_FILE),
3378
+ import_node_path7.default.join(lockPath, SIMULATOR_LOCK_OWNER_FILE),
3081
3379
  `${JSON.stringify({ pid: process.pid, udid, createdAt: (/* @__PURE__ */ new Date()).toISOString() })}
3082
3380
  `,
3083
3381
  { flag: "wx" }
@@ -3269,15 +3567,15 @@ var WdaTransport = class {
3269
3567
  * {@link WdaHttpError} on a non-2xx response, or a timeout error when the
3270
3568
  * per-request deadline elapses.
3271
3569
  */
3272
- async requestFull(method, path25, body, timeoutMs) {
3570
+ async requestFull(method, path27, body, timeoutMs) {
3273
3571
  const requestTimeoutMs = timeoutMs ?? this.requestTimeoutMs;
3274
3572
  const controller = new AbortController();
3275
3573
  const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
3276
3574
  timer.unref?.();
3277
- const url = `${this.baseUrl}${path25}`;
3575
+ const url = `${this.baseUrl}${path27}`;
3278
3576
  const timeoutError = () => {
3279
3577
  const shown = requestTimeoutMs >= 1e3 ? `${Math.round(requestTimeoutMs / 1e3)}s` : `${requestTimeoutMs}ms`;
3280
- return new Error(`WebDriverAgent request ${method} ${path25} timed out after ${shown}`);
3578
+ return new Error(`WebDriverAgent request ${method} ${path27} timed out after ${shown}`);
3281
3579
  };
3282
3580
  let response;
3283
3581
  try {
@@ -3309,7 +3607,7 @@ var WdaTransport = class {
3309
3607
  if (!response.ok) {
3310
3608
  const wdError = extractWebdriverError2(parsed);
3311
3609
  throw new WdaHttpError(
3312
- `WebDriverAgent ${method} ${path25} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
3610
+ `WebDriverAgent ${method} ${path27} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
3313
3611
  response.status,
3314
3612
  wdError
3315
3613
  );
@@ -3317,8 +3615,8 @@ var WdaTransport = class {
3317
3615
  return parsed;
3318
3616
  }
3319
3617
  /** Like {@link requestFull} but returns just the `value` field. */
3320
- async request(method, path25, body, timeoutMs) {
3321
- const parsed = await this.requestFull(method, path25, body, timeoutMs);
3618
+ async request(method, path27, body, timeoutMs) {
3619
+ const parsed = await this.requestFull(method, path27, body, timeoutMs);
3322
3620
  return parsed?.value;
3323
3621
  }
3324
3622
  };
@@ -3394,8 +3692,8 @@ function sleep2(ms) {
3394
3692
  }
3395
3693
  function createWdaAgentClient(transport, sessionId) {
3396
3694
  const base = `/session/${sessionId}`;
3397
- async function locate(query, path25) {
3398
- return transport.request("POST", `${base}${path25}`, iosQueryToLocator(query));
3695
+ async function locate(query, path27) {
3696
+ return transport.request("POST", `${base}${path27}`, iosQueryToLocator(query));
3399
3697
  }
3400
3698
  return {
3401
3699
  async findElement(query) {
@@ -3431,6 +3729,13 @@ function createWdaAgentClient(transport, sessionId) {
3431
3729
  async homescreen() {
3432
3730
  await transport.request("POST", "/wda/homescreen", {});
3433
3731
  },
3732
+ async source() {
3733
+ const value = await transport.request("GET", "/source");
3734
+ if (typeof value !== "string") {
3735
+ throw new Error("WebDriverAgent /source did not return XML text; cannot analyze iOS UI hierarchy");
3736
+ }
3737
+ return value;
3738
+ },
3434
3739
  async close() {
3435
3740
  await transport.request("DELETE", base).catch(() => void 0);
3436
3741
  }
@@ -3440,8 +3745,8 @@ function createWdaAgentClient(transport, sessionId) {
3440
3745
  // src/browser/ios-helper.ts
3441
3746
  var import_meta3 = {};
3442
3747
  var WDA_RUNNER_BUNDLE_ID = "com.facebook.WebDriverAgentRunner.xctrunner";
3443
- var WDA_RUNNER_APP_NAME = "WebDriverAgentRunner-Runner.app";
3444
3748
  var WDA_USE_PORT_ENV = "USE_PORT";
3749
+ var PREPARED_XCTESTRUN_PREFIX = "prowl-wda-xctestrun-";
3445
3750
  var WDA_STARTUP_ATTEMPTS = 2;
3446
3751
  var defaultIosAgentConnector = async ({
3447
3752
  host,
@@ -3464,32 +3769,63 @@ function resolveWdaProject(requireFn = (0, import_node_module2.createRequire)(im
3464
3769
  "The iOS target requires the `appium-webdriveragent` package (its WDA Xcode project). It is an optional dependency of prowl-tools; it may have been skipped (--omit=optional) or failed to install. Restore it for a global Prowl install with: npm install -g appium-webdriveragent@16.4.0. If Prowl is installed locally in a project, run: npm install appium-webdriveragent@16.4.0"
3465
3770
  );
3466
3771
  }
3467
- const pkgDir = import_node_path7.default.dirname(pkgJsonPath);
3772
+ const pkgDir = import_node_path8.default.dirname(pkgJsonPath);
3468
3773
  const version = requireFn(pkgJsonPath).version;
3469
- const projectPath = import_node_path7.default.join(pkgDir, "WebDriverAgent.xcodeproj");
3774
+ const projectPath = import_node_path8.default.join(pkgDir, "WebDriverAgent.xcodeproj");
3470
3775
  if (!import_node_fs7.default.existsSync(projectPath)) {
3471
3776
  throw new Error(`Expected WebDriverAgent project is missing: ${projectPath}. Reinstall dependencies.`);
3472
3777
  }
3473
3778
  return { projectPath, version };
3474
3779
  }
3475
- function wdaCacheDir(wdaVersion, xcode, homeDir = import_node_os2.default.homedir()) {
3476
- return import_node_path7.default.join(homeDir, ".prowl", "wda", `${wdaVersion}-xcode${xcode}`);
3780
+ function wdaCacheDir(wdaVersion, xcode, homeDir = import_node_os4.default.homedir()) {
3781
+ return import_node_path8.default.join(homeDir, ".prowl", "wda", `${wdaVersion}-xcode${xcode}`);
3782
+ }
3783
+ function productsDir(derivedDataPath) {
3784
+ return import_node_path8.default.join(derivedDataPath, "Build", "Products");
3785
+ }
3786
+ function findXctestrunIn(dir) {
3787
+ let entries;
3788
+ try {
3789
+ entries = import_node_fs7.default.readdirSync(dir);
3790
+ } catch {
3791
+ return null;
3792
+ }
3793
+ const match = entries.filter((name) => name.endsWith(".xctestrun") && !name.startsWith(PREPARED_XCTESTRUN_PREFIX)).sort()[0];
3794
+ return match ? import_node_path8.default.join(dir, match) : null;
3477
3795
  }
3478
- function runnerAppPath(derivedDataPath) {
3479
- return import_node_path7.default.join(derivedDataPath, "Build", "Products", "Debug-iphonesimulator", WDA_RUNNER_APP_NAME);
3796
+ function resolveOverrideXctestrun(override) {
3797
+ if (!import_node_fs7.default.existsSync(override)) {
3798
+ throw new Error(`PROWL_WDA_RUNNER points at a missing path: ${override}`);
3799
+ }
3800
+ if (override.endsWith(".xctestrun")) {
3801
+ return override;
3802
+ }
3803
+ const candidates = [];
3804
+ const stat = import_node_fs7.default.statSync(override);
3805
+ if (override.endsWith(".app")) {
3806
+ candidates.push(import_node_path8.default.dirname(import_node_path8.default.dirname(override)));
3807
+ } else if (stat.isDirectory()) {
3808
+ candidates.push(override, productsDir(override));
3809
+ }
3810
+ for (const dir of candidates) {
3811
+ const found = findXctestrunIn(dir);
3812
+ if (found) {
3813
+ return found;
3814
+ }
3815
+ }
3816
+ throw new Error(
3817
+ `PROWL_WDA_RUNNER (${override}) does not resolve to a WebDriverAgent .xctestrun. Point it at the generated \`*.xctestrun\`, at the \`-derivedDataPath\` directory from \`xcodebuild build-for-testing\`, or at that build's runner \`.app\`.`
3818
+ );
3480
3819
  }
3481
- async function resolveWdaRunner(options = {}) {
3820
+ async function resolveWdaTestRun(options = {}) {
3482
3821
  const runner = options.runner ?? execFileXcrunRunner;
3483
3822
  const env = options.env ?? process.env;
3484
- const homeDir = options.homeDir ?? import_node_os2.default.homedir();
3823
+ const homeDir = options.homeDir ?? import_node_os4.default.homedir();
3485
3824
  const log = options.logger ?? ((message) => process.stderr.write(`${message}
3486
3825
  `));
3487
3826
  const override = env.PROWL_WDA_RUNNER;
3488
3827
  if (override) {
3489
- if (!import_node_fs7.default.existsSync(override)) {
3490
- throw new Error(`PROWL_WDA_RUNNER points at a missing path: ${override}`);
3491
- }
3492
- return override;
3828
+ return resolveOverrideXctestrun(override);
3493
3829
  }
3494
3830
  const { projectPath, version } = resolveWdaProject(options.requireFn);
3495
3831
  const xcode = await xcodeVersion(runner);
@@ -3499,9 +3835,9 @@ async function resolveWdaRunner(options = {}) {
3499
3835
  );
3500
3836
  }
3501
3837
  const cacheDir = wdaCacheDir(version, xcode, homeDir);
3502
- const cachedRunner = runnerAppPath(cacheDir);
3503
- if (import_node_fs7.default.existsSync(cachedRunner)) {
3504
- return cachedRunner;
3838
+ const cached = findXctestrunIn(productsDir(cacheDir));
3839
+ if (cached) {
3840
+ return cached;
3505
3841
  }
3506
3842
  log(
3507
3843
  `Prowl: building WebDriverAgent for the iOS target (first run only; this can take a few minutes)\u2026`
@@ -3528,16 +3864,110 @@ async function resolveWdaRunner(options = {}) {
3528
3864
  `Failed to build WebDriverAgent with \`xcodebuild build-for-testing\`. Ensure a full Xcode (not just the command-line tools) is installed and selected (\`xcode-select -p\`). Details: ${(result.stderr.trim() || result.stdout.trim()).slice(-800)}`
3529
3865
  );
3530
3866
  }
3531
- if (!import_node_fs7.default.existsSync(cachedRunner)) {
3867
+ const built = findXctestrunIn(productsDir(cacheDir));
3868
+ if (!built) {
3532
3869
  throw new Error(
3533
- `WebDriverAgent build succeeded but the runner app was not found at ${cachedRunner}. This may indicate an Xcode layout change; set PROWL_WDA_RUNNER to a prebuilt runner.`
3870
+ `WebDriverAgent build succeeded but no .xctestrun was found under ${productsDir(cacheDir)}. This may indicate an Xcode layout change; set PROWL_WDA_RUNNER to a prebuilt runner/xctestrun.`
3534
3871
  );
3535
3872
  }
3536
- return cachedRunner;
3873
+ return built;
3537
3874
  }
3538
- async function resolveBundleId(app, runner, udid, coldStart, allowedApps) {
3539
- if (!looksLikeIosAppPath(app)) {
3540
- assertIosAppAllowed(allowedApps, app);
3875
+ function injectUsePortIntoXctestrun(plist, port) {
3876
+ const clone = structuredClone(plist);
3877
+ let injected = 0;
3878
+ const visit = (node) => {
3879
+ if (Array.isArray(node)) {
3880
+ for (const entry of node) {
3881
+ visit(entry);
3882
+ }
3883
+ return;
3884
+ }
3885
+ if (!node || typeof node !== "object") {
3886
+ return;
3887
+ }
3888
+ const obj = node;
3889
+ const isTarget = typeof obj.TestBundlePath === "string" || typeof obj.TestHostPath === "string";
3890
+ const existingEnv = obj.EnvironmentVariables && typeof obj.EnvironmentVariables === "object" && !Array.isArray(obj.EnvironmentVariables) ? obj.EnvironmentVariables : void 0;
3891
+ if (isTarget || existingEnv) {
3892
+ const env = existingEnv ?? {};
3893
+ env[WDA_USE_PORT_ENV] = String(port);
3894
+ obj.EnvironmentVariables = env;
3895
+ injected += 1;
3896
+ }
3897
+ for (const value of Object.values(obj)) {
3898
+ visit(value);
3899
+ }
3900
+ };
3901
+ visit(clone);
3902
+ if (injected === 0) {
3903
+ throw new Error(
3904
+ "Could not find a test target in the WebDriverAgent .xctestrun to set USE_PORT. The xctestrun format may have changed; set PROWL_WDA_RUNNER to a compatible runner."
3905
+ );
3906
+ }
3907
+ return clone;
3908
+ }
3909
+ function runPlutil(args) {
3910
+ return new Promise((resolve, reject) => {
3911
+ (0, import_node_child_process6.execFile)(
3912
+ "plutil",
3913
+ args,
3914
+ { encoding: "utf-8", maxBuffer: 32 * 1024 * 1024 },
3915
+ (error, stdout, stderr) => {
3916
+ if (error) {
3917
+ reject(
3918
+ new Error(
3919
+ `\`plutil ${args.join(" ")}\` failed: ${(stderr || error.message).slice(0, 400)}`
3920
+ )
3921
+ );
3922
+ return;
3923
+ }
3924
+ resolve(stdout ?? "");
3925
+ }
3926
+ );
3927
+ });
3928
+ }
3929
+ var defaultWdaTestRunPreparer = async ({ xctestrunPath, port }) => {
3930
+ const json = await runPlutil(["-convert", "json", "-o", "-", xctestrunPath]);
3931
+ let parsed;
3932
+ try {
3933
+ parsed = JSON.parse(json);
3934
+ } catch {
3935
+ throw new Error(`Could not parse the WebDriverAgent .xctestrun as JSON: ${xctestrunPath}`);
3936
+ }
3937
+ const injected = injectUsePortIntoXctestrun(parsed, port);
3938
+ const stem = `${PREPARED_XCTESTRUN_PREFIX}${port}-${process.pid}`;
3939
+ const outPath = import_node_path8.default.join(import_node_path8.default.dirname(xctestrunPath), `${stem}.xctestrun`);
3940
+ const jsonPath = import_node_path8.default.join(import_node_os4.default.tmpdir(), `${stem}.json`);
3941
+ import_node_fs7.default.writeFileSync(jsonPath, JSON.stringify(injected));
3942
+ try {
3943
+ await runPlutil(["-convert", "xml1", jsonPath, "-o", outPath]);
3944
+ } finally {
3945
+ import_node_fs7.default.rmSync(jsonPath, { force: true });
3946
+ }
3947
+ return outPath;
3948
+ };
3949
+ function cleanupPreparedTestRun(preparedPath) {
3950
+ if (!preparedPath) {
3951
+ return;
3952
+ }
3953
+ if (!import_node_path8.default.basename(preparedPath).startsWith(PREPARED_XCTESTRUN_PREFIX)) {
3954
+ return;
3955
+ }
3956
+ import_node_fs7.default.rmSync(preparedPath, { force: true });
3957
+ }
3958
+ function wdaTestRunArgs(xctestrunPath, udid) {
3959
+ return [
3960
+ "xcodebuild",
3961
+ "test-without-building",
3962
+ "-xctestrun",
3963
+ xctestrunPath,
3964
+ "-destination",
3965
+ `id=${udid}`
3966
+ ];
3967
+ }
3968
+ async function resolveBundleId(app, runner, udid, coldStart, allowedApps) {
3969
+ if (!looksLikeIosAppPath(app)) {
3970
+ assertIosAppAllowed(allowedApps, app);
3541
3971
  if (coldStart) {
3542
3972
  throw new Error(
3543
3973
  `coldStart requires target.app to be a built .app bundle path (a bare bundle id like "${app}" cannot be reinstalled). Point target.app at the .app, or drop coldStart.`
@@ -3545,7 +3975,7 @@ async function resolveBundleId(app, runner, udid, coldStart, allowedApps) {
3545
3975
  }
3546
3976
  return app;
3547
3977
  }
3548
- const appPath = import_node_path7.default.resolve(app);
3978
+ const appPath = import_node_path8.default.resolve(app);
3549
3979
  if (!import_node_fs7.default.existsSync(appPath)) {
3550
3980
  throw new Error(`.app bundle not found: ${appPath}`);
3551
3981
  }
@@ -3564,6 +3994,8 @@ async function resolveBundleId(app, runner, udid, coldStart, allowedApps) {
3564
3994
  }
3565
3995
  async function launchIosSession(options) {
3566
3996
  const runner = options.runner ?? execFileXcrunRunner;
3997
+ const spawner = options.spawner ?? spawnXcrunProcess;
3998
+ const preparer = options.testRunPreparer ?? defaultWdaTestRunPreparer;
3567
3999
  const portAllocator = options.portAllocator ?? findFreePort;
3568
4000
  const connector = options.agentConnector ?? defaultIosAgentConnector;
3569
4001
  const requestTimeoutMs = Math.max(options.timeoutMs ?? 1e4, DEFAULT_WDA_REQUEST_TIMEOUT_MS) + 5e3;
@@ -3587,14 +4019,14 @@ async function launchIosSession(options) {
3587
4019
  options.coldStart ?? false,
3588
4020
  options.allowedApps ?? []
3589
4021
  );
3590
- const wdaRunnerApp = options.wdaRunnerApp ?? await resolveWdaRunner({ runner, logger: options.logger });
3591
- await installApp(runner, udid, wdaRunnerApp);
4022
+ const baseTestRun = options.wdaTestRun ?? await resolveWdaTestRun({ runner, logger: options.logger });
3592
4023
  for (let attempt = 1; attempt <= WDA_STARTUP_ATTEMPTS; attempt += 1) {
3593
4024
  let client;
4025
+ let wdaProcess;
4026
+ let preparedTestRun;
3594
4027
  let tornDown = false;
3595
- let terminateWda = false;
3596
4028
  let terminateTarget = false;
3597
- let stage = "port";
4029
+ let stage = "prepare";
3598
4030
  const teardownAttempt = async () => {
3599
4031
  if (tornDown) {
3600
4032
  return;
@@ -3603,18 +4035,18 @@ async function launchIosSession(options) {
3603
4035
  if (client) {
3604
4036
  await client.close().catch(() => void 0);
3605
4037
  }
3606
- if (terminateWda) {
3607
- await terminateApp(runner, udid, WDA_RUNNER_BUNDLE_ID);
3608
- }
4038
+ wdaProcess?.kill();
4039
+ await terminateApp(runner, udid, WDA_RUNNER_BUNDLE_ID);
3609
4040
  if (terminateTarget) {
3610
4041
  await terminateApp(runner, udid, bundleId);
3611
4042
  }
4043
+ cleanupPreparedTestRun(preparedTestRun);
3612
4044
  };
3613
4045
  try {
3614
4046
  const port = await portAllocator();
4047
+ preparedTestRun = await preparer({ xctestrunPath: baseTestRun, port });
3615
4048
  stage = "wda-launch";
3616
- terminateWda = true;
3617
- await launchApp(runner, udid, WDA_RUNNER_BUNDLE_ID, { [WDA_USE_PORT_ENV]: String(port) });
4049
+ wdaProcess = spawner(wdaTestRunArgs(preparedTestRun, udid));
3618
4050
  stage = "target-launch";
3619
4051
  terminateTarget = true;
3620
4052
  await launchApp(runner, udid, bundleId);
@@ -3648,7 +4080,7 @@ async function closeIosSession(session) {
3648
4080
 
3649
4081
  // src/runner/steps.ts
3650
4082
  var import_node_fs9 = __toESM(require("fs"), 1);
3651
- var import_node_path9 = __toESM(require("path"), 1);
4083
+ var import_node_path10 = __toESM(require("path"), 1);
3652
4084
  init_loader();
3653
4085
 
3654
4086
  // src/runner/healing.ts
@@ -3802,6 +4234,246 @@ function createRunPolicy(driver, options) {
3802
4234
  };
3803
4235
  }
3804
4236
 
4237
+ // src/generator/ai.ts
4238
+ var DEFAULT_BASE_URL = {
4239
+ anthropic: "https://api.anthropic.com",
4240
+ openai: "https://api.openai.com"
4241
+ };
4242
+ var AI_REQUEST_TIMEOUT_MS = 3e4;
4243
+ function apiRoot(config) {
4244
+ return config.baseUrl ?? DEFAULT_BASE_URL[config.provider];
4245
+ }
4246
+ function defaultModelFor(provider) {
4247
+ return provider === "anthropic" ? "claude-sonnet-4-5-20250929" : "gpt-4o";
4248
+ }
4249
+ function resolveAiEnv() {
4250
+ const provider = process.env.PROWL_AI_PROVIDER ?? "anthropic";
4251
+ if (provider !== "anthropic" && provider !== "openai") {
4252
+ throw new Error(`Unsupported AI provider: ${provider}. Use "anthropic" or "openai".`);
4253
+ }
4254
+ const model = process.env.PROWL_AI_MODEL ?? defaultModelFor(provider);
4255
+ const baseUrl = normalizeBaseUrl(process.env.PROWL_AI_BASE_URL) ?? DEFAULT_BASE_URL[provider];
4256
+ return { provider, model, baseUrl, apiKey: process.env.PROWL_AI_KEY };
4257
+ }
4258
+ function normalizeBaseUrl(value) {
4259
+ if (value === void 0) return void 0;
4260
+ const trimmed = value.trim().replace(/\/+$/, "");
4261
+ return trimmed.length > 0 ? trimmed : void 0;
4262
+ }
4263
+ function resolveAiConfig() {
4264
+ const env = resolveAiEnv();
4265
+ if (!env.apiKey) {
4266
+ throw new Error(
4267
+ "PROWL_AI_KEY environment variable is required. Set it to your Anthropic or OpenAI API key."
4268
+ );
4269
+ }
4270
+ return { provider: env.provider, model: env.model, apiKey: env.apiKey, baseUrl: env.baseUrl };
4271
+ }
4272
+ function tryResolveAiConfig() {
4273
+ const env = resolveAiEnv();
4274
+ if (!env.apiKey) {
4275
+ return null;
4276
+ }
4277
+ return { provider: env.provider, model: env.model, apiKey: env.apiKey, baseUrl: env.baseUrl };
4278
+ }
4279
+ async function generateWithAi(prompt, config) {
4280
+ if (config.provider === "anthropic") {
4281
+ return generateWithAnthropic(prompt, config);
4282
+ }
4283
+ return generateWithOpenAi(prompt, config);
4284
+ }
4285
+ async function generateWithAnthropic(prompt, config) {
4286
+ const data = await postJson(
4287
+ "Anthropic",
4288
+ `${apiRoot(config)}/v1/messages`,
4289
+ anthropicHeaders(config),
4290
+ {
4291
+ model: config.model,
4292
+ max_tokens: 4096,
4293
+ messages: [
4294
+ { role: "user", content: prompt }
4295
+ ]
4296
+ }
4297
+ );
4298
+ return extractAnthropicText(data);
4299
+ }
4300
+ async function generateWithOpenAi(prompt, config) {
4301
+ const data = await postJson(
4302
+ "OpenAI",
4303
+ `${apiRoot(config)}/v1/chat/completions`,
4304
+ openAiHeaders(config),
4305
+ {
4306
+ model: config.model,
4307
+ messages: [
4308
+ { role: "user", content: prompt }
4309
+ ],
4310
+ max_tokens: 4096
4311
+ }
4312
+ );
4313
+ return extractOpenAiText(data);
4314
+ }
4315
+ function buildVisionPrompt(assertion) {
4316
+ return [
4317
+ "You are a meticulous QA reviewer. You are given a screenshot of an application",
4318
+ "and a single assertion describing what should be true about it.",
4319
+ "",
4320
+ "Assertion:",
4321
+ assertion,
4322
+ "",
4323
+ "Decide whether the assertion holds for the screenshot. Judge ONLY what is",
4324
+ "visible; do not assume behavior you cannot see. Be strict: if the assertion",
4325
+ "is not clearly satisfied, it fails.",
4326
+ "",
4327
+ "Reply with ONLY a single JSON object on one line, no markdown, no code fences:",
4328
+ '{"pass": <true|false>, "reason": "<one concise sentence explaining the verdict>"}'
4329
+ ].join("\n");
4330
+ }
4331
+ function parseVisionVerdict(raw) {
4332
+ const cleaned = stripCodeFences(raw).trim();
4333
+ const candidate = extractJsonObject(cleaned);
4334
+ if (candidate === null) {
4335
+ throw new Error(
4336
+ `Could not parse a JSON verdict from the AI response: ${truncateForError(raw)}`
4337
+ );
4338
+ }
4339
+ let parsed;
4340
+ try {
4341
+ parsed = JSON.parse(candidate);
4342
+ } catch {
4343
+ throw new Error(
4344
+ `AI verdict was not valid JSON: ${truncateForError(raw)}`
4345
+ );
4346
+ }
4347
+ if (typeof parsed !== "object" || parsed === null) {
4348
+ throw new Error(`AI verdict was not a JSON object: ${truncateForError(raw)}`);
4349
+ }
4350
+ const record = parsed;
4351
+ if (typeof record.pass !== "boolean") {
4352
+ throw new Error(
4353
+ `AI verdict is missing a boolean "pass" field: ${truncateForError(raw)}`
4354
+ );
4355
+ }
4356
+ const reason = typeof record.reason === "string" && record.reason.trim().length > 0 ? record.reason.trim() : record.pass ? "Assertion satisfied." : "Assertion not satisfied.";
4357
+ return { pass: record.pass, reason };
4358
+ }
4359
+ function stripCodeFences(text) {
4360
+ const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
4361
+ return fence ? fence[1] : text;
4362
+ }
4363
+ function extractJsonObject(text) {
4364
+ const trimmed = text.trim();
4365
+ if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
4366
+ return trimmed;
4367
+ }
4368
+ const first = trimmed.indexOf("{");
4369
+ const last = trimmed.lastIndexOf("}");
4370
+ if (first === -1 || last === -1 || last <= first) {
4371
+ return null;
4372
+ }
4373
+ return trimmed.slice(first, last + 1);
4374
+ }
4375
+ function truncateForError(text) {
4376
+ const collapsed = text.replace(/\s+/g, " ").trim();
4377
+ return collapsed.length > 200 ? `${collapsed.slice(0, 199)}\u2026` : collapsed;
4378
+ }
4379
+ async function assertWithAiVision(input, config) {
4380
+ const raw = config.provider === "anthropic" ? await visionWithAnthropic(input, config) : await visionWithOpenAi(input, config);
4381
+ return parseVisionVerdict(raw);
4382
+ }
4383
+ async function visionWithAnthropic(input, config) {
4384
+ const data = await postJson(
4385
+ "Anthropic",
4386
+ `${apiRoot(config)}/v1/messages`,
4387
+ anthropicHeaders(config),
4388
+ {
4389
+ model: config.model,
4390
+ max_tokens: 1024,
4391
+ temperature: 0,
4392
+ messages: [
4393
+ {
4394
+ role: "user",
4395
+ content: [
4396
+ {
4397
+ type: "image",
4398
+ source: {
4399
+ type: "base64",
4400
+ media_type: input.mediaType,
4401
+ data: input.imageBase64
4402
+ }
4403
+ },
4404
+ { type: "text", text: buildVisionPrompt(input.assertion) }
4405
+ ]
4406
+ }
4407
+ ]
4408
+ }
4409
+ );
4410
+ return extractAnthropicText(data);
4411
+ }
4412
+ async function visionWithOpenAi(input, config) {
4413
+ const data = await postJson(
4414
+ "OpenAI",
4415
+ `${apiRoot(config)}/v1/chat/completions`,
4416
+ openAiHeaders(config),
4417
+ {
4418
+ model: config.model,
4419
+ max_tokens: 1024,
4420
+ temperature: 0,
4421
+ messages: [
4422
+ {
4423
+ role: "user",
4424
+ content: [
4425
+ { type: "text", text: buildVisionPrompt(input.assertion) },
4426
+ {
4427
+ type: "image_url",
4428
+ image_url: { url: `data:${input.mediaType};base64,${input.imageBase64}` }
4429
+ }
4430
+ ]
4431
+ }
4432
+ ]
4433
+ }
4434
+ );
4435
+ return extractOpenAiText(data);
4436
+ }
4437
+ async function postJson(provider, url, headers, body) {
4438
+ const response = await fetch(url, {
4439
+ method: "POST",
4440
+ headers,
4441
+ body: JSON.stringify(body),
4442
+ signal: AbortSignal.timeout(AI_REQUEST_TIMEOUT_MS)
4443
+ });
4444
+ if (!response.ok) {
4445
+ const responseBody = await response.text();
4446
+ throw new Error(`${provider} API error (${response.status}): ${responseBody}`);
4447
+ }
4448
+ return await response.json();
4449
+ }
4450
+ function anthropicHeaders(config) {
4451
+ return {
4452
+ "Content-Type": "application/json",
4453
+ "x-api-key": config.apiKey,
4454
+ "anthropic-version": "2023-06-01"
4455
+ };
4456
+ }
4457
+ function openAiHeaders(config) {
4458
+ return {
4459
+ "Content-Type": "application/json",
4460
+ "Authorization": `Bearer ${config.apiKey}`
4461
+ };
4462
+ }
4463
+ function extractAnthropicText(data) {
4464
+ const textBlock = data.content?.find((c) => c.type === "text");
4465
+ if (!textBlock?.text) {
4466
+ throw new Error("Anthropic API returned no text content");
4467
+ }
4468
+ return textBlock.text;
4469
+ }
4470
+ function extractOpenAiText(data) {
4471
+ if (!data.choices?.[0]?.message?.content) {
4472
+ throw new Error("OpenAI API returned no content");
4473
+ }
4474
+ return data.choices[0].message.content;
4475
+ }
4476
+
3805
4477
  // src/runner/steps.ts
3806
4478
  function getStepType(step) {
3807
4479
  if ("navigate" in step) return "navigate";
@@ -3830,6 +4502,7 @@ function getStepType(step) {
3830
4502
  if ("evalScript" in step) return "evalScript";
3831
4503
  if ("runScript" in step) return "runScript";
3832
4504
  if ("assertScreenshot" in step) return "assertScreenshot";
4505
+ if ("assertWithAI" in step) return "assertWithAI";
3833
4506
  if ("copyText" in step) return "copyText";
3834
4507
  if ("waitForDownload" in step) return "waitForDownload";
3835
4508
  return "step";
@@ -3889,6 +4562,9 @@ function applyRuntimeVars(step, vars) {
3889
4562
  }
3890
4563
  };
3891
4564
  }
4565
+ if ("assertWithAI" in step) {
4566
+ return { assertWithAI: sub(step.assertWithAI) };
4567
+ }
3892
4568
  if ("copyText" in step) {
3893
4569
  return { copyText: { selector: sub(step.copyText.selector), as: step.copyText.as } };
3894
4570
  }
@@ -4139,7 +4815,7 @@ async function runInlineAssert(driver, policy, assertion) {
4139
4815
  throw new Error("assert step is missing an assertion type");
4140
4816
  }
4141
4817
  function screenshotPath(screenshotsDir, fileName) {
4142
- return import_node_path9.default.join(screenshotsDir, fileName);
4818
+ return import_node_path10.default.join(screenshotsDir, fileName);
4143
4819
  }
4144
4820
  function stepPath(prefix, index) {
4145
4821
  return prefix ? `${prefix}.${index}` : `${index}`;
@@ -4156,7 +4832,7 @@ function validateDownloadFilename(suggestedFilename) {
4156
4832
  const safeFilename = suggestedFilename.trim();
4157
4833
  const allowedFilenamePattern = /^[^<>:"/\\|?*]+$/;
4158
4834
  const hasControlCharacter = Array.from(safeFilename).some((char) => char.charCodeAt(0) < 32);
4159
- if (safeFilename.length === 0 || safeFilename !== suggestedFilename || safeFilename !== import_node_path9.default.basename(safeFilename) || safeFilename.includes("..") || /[/\\]/.test(safeFilename) || hasControlCharacter || !allowedFilenamePattern.test(safeFilename)) {
4835
+ if (safeFilename.length === 0 || safeFilename !== suggestedFilename || safeFilename !== import_node_path10.default.basename(safeFilename) || safeFilename.includes("..") || /[/\\]/.test(safeFilename) || hasControlCharacter || !allowedFilenamePattern.test(safeFilename)) {
4160
4836
  throw new Error(`Invalid download filename: "${suggestedFilename}"`);
4161
4837
  }
4162
4838
  return safeFilename;
@@ -4336,7 +5012,7 @@ var STEP_HANDLERS = {
4336
5012
  if (!("setInputFiles" in h.step)) unknownStep();
4337
5013
  const resolvedInput = await h.policy.resolveActionSelector(h.step.setInputFiles.selector);
4338
5014
  const rawFiles = h.step.setInputFiles.files;
4339
- const resolveFile = (f) => import_node_path9.default.isAbsolute(f) ? f : import_node_path9.default.join(h.context.configDir, f);
5015
+ const resolveFile = (f) => import_node_path10.default.isAbsolute(f) ? f : import_node_path10.default.join(h.context.configDir, f);
4340
5016
  const resolvedFiles = Array.isArray(rawFiles) ? rawFiles.map(resolveFile) : resolveFile(rawFiles);
4341
5017
  await h.driver.setInputFiles(resolvedInput.selector, resolvedFiles);
4342
5018
  h.policy.ensureLocationAllowed(h.driver);
@@ -4698,11 +5374,11 @@ var STEP_HANDLERS = {
4698
5374
  if (!responseFile) {
4699
5375
  throw new Error("mock.response must include either body or file");
4700
5376
  }
4701
- const candidateFilePath = import_node_path9.default.isAbsolute(responseFile) ? responseFile : import_node_path9.default.join(h.context.configDir, responseFile);
4702
- const resolvedConfigDir = import_node_path9.default.resolve(h.context.configDir);
4703
- const resolvedFilePath = import_node_path9.default.resolve(candidateFilePath);
4704
- const relativePath = import_node_path9.default.relative(resolvedConfigDir, resolvedFilePath);
4705
- const isWithinConfigDir = relativePath === "" || relativePath !== ".." && !relativePath.startsWith(`..${import_node_path9.default.sep}`) && !import_node_path9.default.isAbsolute(relativePath);
5377
+ const candidateFilePath = import_node_path10.default.isAbsolute(responseFile) ? responseFile : import_node_path10.default.join(h.context.configDir, responseFile);
5378
+ const resolvedConfigDir = import_node_path10.default.resolve(h.context.configDir);
5379
+ const resolvedFilePath = import_node_path10.default.resolve(candidateFilePath);
5380
+ const relativePath = import_node_path10.default.relative(resolvedConfigDir, resolvedFilePath);
5381
+ const isWithinConfigDir = relativePath === "" || relativePath !== ".." && !relativePath.startsWith(`..${import_node_path10.default.sep}`) && !import_node_path10.default.isAbsolute(relativePath);
4706
5382
  if (!isWithinConfigDir) {
4707
5383
  throw new Error("mock.response.file must resolve within config directory");
4708
5384
  }
@@ -4769,7 +5445,7 @@ var STEP_HANDLERS = {
4769
5445
  capabilities: ["evaluate"],
4770
5446
  run: async (h) => {
4771
5447
  if (!("runScript" in h.step)) unknownStep();
4772
- const filePath = import_node_path9.default.isAbsolute(h.step.runScript.file) ? h.step.runScript.file : import_node_path9.default.join(h.context.configDir, h.step.runScript.file);
5448
+ const filePath = import_node_path10.default.isAbsolute(h.step.runScript.file) ? h.step.runScript.file : import_node_path10.default.join(h.context.configDir, h.step.runScript.file);
4773
5449
  const fileContents = import_node_fs9.default.readFileSync(filePath, "utf-8");
4774
5450
  await h.driver.evaluate(fileContents);
4775
5451
  return {
@@ -4791,11 +5467,11 @@ var STEP_HANDLERS = {
4791
5467
  const name = h.step.assertScreenshot.name;
4792
5468
  const threshold = h.step.assertScreenshot.threshold ?? 0.1;
4793
5469
  const baselineDir = ensureBaselineDir2(h.context.configDir);
4794
- const baselinePath = import_node_path9.default.join(baselineDir, `${name}.png`);
4795
- const currentScreenshotPath = import_node_path9.default.join(h.context.runDir, "screenshots", `${name}-current.png`);
4796
- import_node_fs9.default.mkdirSync(import_node_path9.default.dirname(currentScreenshotPath), { recursive: true });
5470
+ const baselinePath = import_node_path10.default.join(baselineDir, `${name}.png`);
5471
+ const currentScreenshotPath = import_node_path10.default.join(h.context.runDir, "screenshots", `${name}-current.png`);
5472
+ import_node_fs9.default.mkdirSync(import_node_path10.default.dirname(currentScreenshotPath), { recursive: true });
4797
5473
  await h.driver.screenshot({ path: currentScreenshotPath, fullPage: true });
4798
- h.screenshots.push(import_node_path9.default.join("screenshots", `${name}-current.png`));
5474
+ h.screenshots.push(import_node_path10.default.join("screenshots", `${name}-current.png`));
4799
5475
  if (!import_node_fs9.default.existsSync(baselinePath)) {
4800
5476
  import_node_fs9.default.copyFileSync(currentScreenshotPath, baselinePath);
4801
5477
  return {
@@ -4803,7 +5479,7 @@ var STEP_HANDLERS = {
4803
5479
  result: { type: "assertScreenshot", status: "pass", durationMs: Date.now() - h.stepStart, value: "baseline created" }
4804
5480
  };
4805
5481
  }
4806
- const diffPath = import_node_path9.default.join(h.context.runDir, "screenshots", `${name}-diff.png`);
5482
+ const diffPath = import_node_path10.default.join(h.context.runDir, "screenshots", `${name}-diff.png`);
4807
5483
  const comparison = await compareScreenshots2(baselinePath, currentScreenshotPath, diffPath, threshold);
4808
5484
  if (comparison.match) {
4809
5485
  return {
@@ -4816,12 +5492,54 @@ var STEP_HANDLERS = {
4816
5492
  }
4817
5493
  };
4818
5494
  }
4819
- h.screenshots.push(import_node_path9.default.join("screenshots", `${name}-diff.png`));
5495
+ h.screenshots.push(import_node_path10.default.join("screenshots", `${name}-diff.png`));
4820
5496
  throw new Error(
4821
5497
  `Visual regression: ${(comparison.diffPercentage * 100).toFixed(2)}% diff exceeds threshold ${(threshold * 100).toFixed(0)}%`
4822
5498
  );
4823
5499
  }
4824
5500
  },
5501
+ assertWithAI: {
5502
+ capabilities: ["screenshot"],
5503
+ run: async (h) => {
5504
+ if (!("assertWithAI" in h.step)) unknownStep();
5505
+ const assertion = h.step.assertWithAI;
5506
+ const resolve = h.context.resolveAiConfig ?? tryResolveAiConfig;
5507
+ const aiConfig = resolve();
5508
+ if (!aiConfig) {
5509
+ return {
5510
+ kind: "result",
5511
+ result: {
5512
+ type: "assertWithAI",
5513
+ status: "warn",
5514
+ durationMs: Date.now() - h.stepStart,
5515
+ value: `skipped: no AI provider configured (set PROWL_AI_KEY to enable) \u2014 "${assertion}"`
5516
+ }
5517
+ };
5518
+ }
5519
+ const fileName = `assertWithAI_step_${h.index + 1}.png`;
5520
+ const relative = await h.addScreenshot(fileName);
5521
+ const screenshotFullPath = import_node_path10.default.join(h.context.runDir, relative);
5522
+ const imageBase64 = import_node_fs9.default.readFileSync(screenshotFullPath).toString("base64");
5523
+ const assertVision = h.context.assertVision ?? assertWithAiVision;
5524
+ const verdict = await assertVision(
5525
+ { imageBase64, mediaType: "image/png", assertion },
5526
+ aiConfig
5527
+ );
5528
+ if (verdict.pass) {
5529
+ return {
5530
+ kind: "result",
5531
+ result: {
5532
+ type: "assertWithAI",
5533
+ status: "pass",
5534
+ durationMs: Date.now() - h.stepStart,
5535
+ value: verdict.reason,
5536
+ screenshot: relative
5537
+ }
5538
+ };
5539
+ }
5540
+ throw new Error(`AI assertion failed: ${verdict.reason}`);
5541
+ }
5542
+ },
4825
5543
  copyText: {
4826
5544
  capabilities: ["query"],
4827
5545
  run: async (h) => {
@@ -4858,7 +5576,7 @@ var STEP_HANDLERS = {
4858
5576
  `Download filename mismatch: expected "${opts.filename}", got "${suggestedFilename}"`
4859
5577
  );
4860
5578
  }
4861
- const savePath = import_node_path9.default.join(h.context.runDir, suggestedFilename);
5579
+ const savePath = import_node_path10.default.join(h.context.runDir, suggestedFilename);
4862
5580
  await download.saveAs(savePath);
4863
5581
  return {
4864
5582
  kind: "result",
@@ -4887,7 +5605,7 @@ async function executeSteps(context) {
4887
5605
  maxSteps: context.maxSteps,
4888
5606
  selfHealing: context.selfHealing
4889
5607
  });
4890
- const screenshotsDir = import_node_path9.default.join(context.runDir, "screenshots");
5608
+ const screenshotsDir = import_node_path10.default.join(context.runDir, "screenshots");
4891
5609
  import_node_fs9.default.mkdirSync(screenshotsDir, { recursive: true });
4892
5610
  const currentHuntName = context.huntStack?.[context.huntStack.length - 1];
4893
5611
  policy.assertWithinMaxSteps(context.steps.length, currentHuntName);
@@ -4898,7 +5616,7 @@ async function executeSteps(context) {
4898
5616
  const addScreenshot = async (fileName) => {
4899
5617
  const fullPath = screenshotPath(screenshotsDir, fileName);
4900
5618
  await captureScreenshot2(driver, fullPath);
4901
- const relative = import_node_path9.default.join("screenshots", fileName);
5619
+ const relative = import_node_path10.default.join("screenshots", fileName);
4902
5620
  screenshots.push(relative);
4903
5621
  return relative;
4904
5622
  };
@@ -4986,12 +5704,12 @@ async function executeSteps(context) {
4986
5704
  return { results, screenshots, failed: false };
4987
5705
  }
4988
5706
  async function captureFinalScreenshot(page, runDir) {
4989
- const screenshotsDir = import_node_path9.default.join(runDir, "screenshots");
5707
+ const screenshotsDir = import_node_path10.default.join(runDir, "screenshots");
4990
5708
  import_node_fs9.default.mkdirSync(screenshotsDir, { recursive: true });
4991
5709
  const fileName = "final.png";
4992
5710
  const filePath = screenshotPath(screenshotsDir, fileName);
4993
5711
  await captureScreenshot2(page, filePath);
4994
- return import_node_path9.default.join("screenshots", fileName);
5712
+ return import_node_path10.default.join("screenshots", fileName);
4995
5713
  }
4996
5714
 
4997
5715
  // src/runner/assertions.ts
@@ -5113,6 +5831,59 @@ async function evaluateAssertions(options) {
5113
5831
  }
5114
5832
  return results;
5115
5833
  }
5834
+ async function evaluateNativeAssertions(options) {
5835
+ const assertions = mergeAssertions(options.config, options.huntAssertions);
5836
+ const authoredTypes = new Set(
5837
+ (options.huntAssertions ?? []).map((assertion) => Object.keys(assertion)[0])
5838
+ );
5839
+ const results = [];
5840
+ const warnings = [];
5841
+ for (const assertion of assertions) {
5842
+ const type = Object.keys(assertion)[0] ?? "assertion";
5843
+ try {
5844
+ if ("selectorExists" in assertion) {
5845
+ options.assertAllowedSelector?.(assertion.selectorExists);
5846
+ const count = await options.driver.count(assertion.selectorExists);
5847
+ results.push({
5848
+ type: "selectorExists",
5849
+ value: assertion.selectorExists,
5850
+ status: count > 0 ? "pass" : "fail",
5851
+ error: count > 0 ? void 0 : "Selector not found"
5852
+ });
5853
+ continue;
5854
+ }
5855
+ if ("selectorNotExists" in assertion) {
5856
+ options.assertAllowedSelector?.(assertion.selectorNotExists);
5857
+ const count = await options.driver.count(assertion.selectorNotExists);
5858
+ results.push({
5859
+ type: "selectorNotExists",
5860
+ value: assertion.selectorNotExists,
5861
+ status: count === 0 ? "pass" : "fail",
5862
+ error: count === 0 ? void 0 : "Selector exists"
5863
+ });
5864
+ continue;
5865
+ }
5866
+ const rawValue = assertion[type];
5867
+ results.push({
5868
+ type,
5869
+ value: rawValue,
5870
+ status: "skipped",
5871
+ error: `skipped (web-only): not supported on the ${options.targetLabel} target`
5872
+ });
5873
+ if (authoredTypes.has(type)) {
5874
+ warnings.push(`${type} is web-only; skipped on ${options.targetLabel} target`);
5875
+ }
5876
+ } catch (error) {
5877
+ const detail = error instanceof Error ? error.message : "no error details";
5878
+ results.push({
5879
+ type,
5880
+ status: "fail",
5881
+ error: `Native assertion "${type}" failed: ${detail}`
5882
+ });
5883
+ }
5884
+ }
5885
+ return { results, warnings };
5886
+ }
5116
5887
 
5117
5888
  // src/runner/tracing.ts
5118
5889
  var DEFAULT_TRACE_HEADER = "traceparent";
@@ -5152,17 +5923,17 @@ function captureTraceCorrelation(response, headerName, sink, redactionValues = [
5152
5923
 
5153
5924
  // src/reporter/result.ts
5154
5925
  var import_node_fs10 = __toESM(require("fs"), 1);
5155
- var import_node_path10 = __toESM(require("path"), 1);
5926
+ var import_node_path11 = __toESM(require("path"), 1);
5156
5927
  function writeResult(runDir, result) {
5157
5928
  const fileName = "result.json";
5158
- const fullPath = import_node_path10.default.join(runDir, fileName);
5929
+ const fullPath = import_node_path11.default.join(runDir, fileName);
5159
5930
  import_node_fs10.default.writeFileSync(fullPath, JSON.stringify(result, null, 2));
5160
5931
  return fileName;
5161
5932
  }
5162
5933
 
5163
5934
  // src/reporter/summary.ts
5164
5935
  var import_node_fs11 = __toESM(require("fs"), 1);
5165
- var import_node_path11 = __toESM(require("path"), 1);
5936
+ var import_node_path12 = __toESM(require("path"), 1);
5166
5937
  function escapeMd(text) {
5167
5938
  return text.replace(/([|`*_{}[\]()#+\-!\\])/g, "\\$1");
5168
5939
  }
@@ -5240,7 +6011,7 @@ function writeSummary(runDir, result) {
5240
6011
  }
5241
6012
  }
5242
6013
  const fileName = "summary.md";
5243
- const fullPath = import_node_path11.default.join(runDir, fileName);
6014
+ const fullPath = import_node_path12.default.join(runDir, fileName);
5244
6015
  import_node_fs11.default.writeFileSync(fullPath, `${lines.join("\n")}
5245
6016
  `);
5246
6017
  return fileName;
@@ -5248,20 +6019,21 @@ function writeSummary(runDir, result) {
5248
6019
 
5249
6020
  // src/reporter/junit.ts
5250
6021
  var import_node_fs12 = __toESM(require("fs"), 1);
5251
- var import_node_path12 = __toESM(require("path"), 1);
6022
+ var import_node_path13 = __toESM(require("path"), 1);
5252
6023
  function escapeXml(text) {
5253
6024
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
5254
6025
  }
5255
6026
  function writeJunit(runDir, result) {
5256
6027
  const totalTests = result.steps.length + result.assertions.length;
5257
6028
  const failures = result.steps.filter((s) => s.status === "fail").length + result.assertions.filter((a) => a.status === "fail").length;
6029
+ const skipped = result.assertions.filter((a) => a.status === "skipped").length;
5258
6030
  const timeSeconds = (result.durationMs / 1e3).toFixed(3);
5259
6031
  const huntName = escapeXml(result.hunt);
5260
6032
  const lines = [];
5261
6033
  lines.push('<?xml version="1.0" encoding="UTF-8"?>');
5262
6034
  lines.push("<testsuites>");
5263
6035
  lines.push(
5264
- ` <testsuite name="${huntName}" tests="${totalTests}" failures="${failures}" errors="0" time="${timeSeconds}" timestamp="${escapeXml(result.startedAt)}">`
6036
+ ` <testsuite name="${huntName}" tests="${totalTests}" failures="${failures}" errors="0" skipped="${skipped}" time="${timeSeconds}" timestamp="${escapeXml(result.startedAt)}">`
5265
6037
  );
5266
6038
  for (let i = 0; i < result.steps.length; i++) {
5267
6039
  const step = result.steps[i];
@@ -5285,6 +6057,11 @@ function writeJunit(runDir, result) {
5285
6057
  lines.push(` <testcase name="${caseName}" classname="${huntName}" time="0">`);
5286
6058
  lines.push(` <failure message="${escapedFailureText}" type="assertion">${escapedFailureText}</failure>`);
5287
6059
  lines.push(" </testcase>");
6060
+ } else if (assertion.status === "skipped") {
6061
+ const skipText = escapeXml(assertion.error ?? "skipped");
6062
+ lines.push(` <testcase name="${caseName}" classname="${huntName}" time="0">`);
6063
+ lines.push(` <skipped message="${skipText}"/>`);
6064
+ lines.push(" </testcase>");
5288
6065
  } else {
5289
6066
  lines.push(` <testcase name="${caseName}" classname="${huntName}" time="0"/>`);
5290
6067
  }
@@ -5292,7 +6069,7 @@ function writeJunit(runDir, result) {
5292
6069
  lines.push(" </testsuite>");
5293
6070
  lines.push("</testsuites>");
5294
6071
  const fileName = "junit.xml";
5295
- const fullPath = import_node_path12.default.join(runDir, fileName);
6072
+ const fullPath = import_node_path13.default.join(runDir, fileName);
5296
6073
  import_node_fs12.default.writeFileSync(fullPath, `${lines.join("\n")}
5297
6074
  `);
5298
6075
  return fileName;
@@ -5328,14 +6105,14 @@ function timestamp(prefix) {
5328
6105
 
5329
6106
  // src/runner/history.ts
5330
6107
  var import_node_fs13 = __toESM(require("fs"), 1);
5331
- var import_node_path13 = __toESM(require("path"), 1);
6108
+ var import_node_path14 = __toESM(require("path"), 1);
5332
6109
  var HISTORY_FILE = "history.json";
5333
6110
  var LOCK_FILE_SUFFIX = ".lock";
5334
6111
  var LOCK_RETRY_MS = 10;
5335
6112
  var LOCK_TIMEOUT_MS = 5e3;
5336
6113
  var SLEEP_BUFFER = new Int32Array(new SharedArrayBuffer(4));
5337
6114
  function historyPath(configDir) {
5338
- return import_node_path13.default.join(configDir, HISTORY_FILE);
6115
+ return import_node_path14.default.join(configDir, HISTORY_FILE);
5339
6116
  }
5340
6117
  function isHistoryEntry(value) {
5341
6118
  if (!value || typeof value !== "object") {
@@ -5389,7 +6166,7 @@ function sleepSync(ms) {
5389
6166
  function withHistoryLock(configDir, fn) {
5390
6167
  const filePath = historyPath(configDir);
5391
6168
  const lockPath = `${filePath}${LOCK_FILE_SUFFIX}`;
5392
- import_node_fs13.default.mkdirSync(import_node_path13.default.dirname(filePath), { recursive: true });
6169
+ import_node_fs13.default.mkdirSync(import_node_path14.default.dirname(filePath), { recursive: true });
5393
6170
  const startedAt = Date.now();
5394
6171
  while (Date.now() - startedAt < LOCK_TIMEOUT_MS) {
5395
6172
  let fd;
@@ -5437,11 +6214,11 @@ function parseViewportFlag(value) {
5437
6214
  return value;
5438
6215
  }
5439
6216
  function resolvePath(configDir, inputPath) {
5440
- if (import_node_path14.default.isAbsolute(inputPath)) {
6217
+ if (import_node_path15.default.isAbsolute(inputPath)) {
5441
6218
  return inputPath;
5442
6219
  }
5443
- const projectRoot = import_node_path14.default.dirname(configDir);
5444
- return import_node_path14.default.join(projectRoot, inputPath);
6220
+ const projectRoot = import_node_path15.default.dirname(configDir);
6221
+ return import_node_path15.default.join(projectRoot, inputPath);
5445
6222
  }
5446
6223
  function buildRunResult(options) {
5447
6224
  return {
@@ -5460,7 +6237,7 @@ function buildRunResult(options) {
5460
6237
  }
5461
6238
  function writeConsoleLog(runDir, entries) {
5462
6239
  const fileName = "console.log";
5463
- const filePath = import_node_path14.default.join(runDir, fileName);
6240
+ const filePath = import_node_path15.default.join(runDir, fileName);
5464
6241
  const lines = entries.map((entry) => {
5465
6242
  const location = entry.location ? ` (${entry.location})` : "";
5466
6243
  return `[${entry.type}] ${entry.text}${location}`;
@@ -5473,7 +6250,7 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
5473
6250
  const headless = options.headed ? false : config.browser.headless;
5474
6251
  const slowMo = options.slowMo ?? config.browser.slowMo;
5475
6252
  const maxSteps = config.guardrails.maxSteps;
5476
- const runDir = import_node_path14.default.join(configDir, "runs", timestamp());
6253
+ const runDir = import_node_path15.default.join(configDir, "runs", timestamp());
5477
6254
  import_node_fs14.default.mkdirSync(runDir, { recursive: true });
5478
6255
  const storageStatePath = config.auth.storageStatePath ? resolvePath(configDir, config.auth.storageStatePath) : void 0;
5479
6256
  const engine = options.browser ?? config.browser.engine;
@@ -5657,7 +6434,7 @@ async function runHunt(options) {
5657
6434
  }
5658
6435
  async function executeNativeHuntAttempt(options, config, configDir, interpolatedHunt, redactedFillSteps, randomVars, allowedApps, native) {
5659
6436
  const maxSteps = config.guardrails.maxSteps;
5660
- const runDir = import_node_path14.default.join(configDir, "runs", timestamp());
6437
+ const runDir = import_node_path15.default.join(configDir, "runs", timestamp());
5661
6438
  import_node_fs14.default.mkdirSync(runDir, { recursive: true });
5662
6439
  const session = await native.launchSession();
5663
6440
  let result;
@@ -5666,6 +6443,13 @@ async function executeNativeHuntAttempt(options, config, configDir, interpolated
5666
6443
  const appIdentity = native.sessionAppIdentity(session);
5667
6444
  const targetLabel = `${native.targetType}:${appIdentity}`;
5668
6445
  const effectiveAllowedApps = [.../* @__PURE__ */ new Set([...allowedApps, native.targetApp, appIdentity])];
6446
+ const assertionPolicy = createRunPolicy(driver, {
6447
+ forbiddenSelectors: config.guardrails.forbiddenSelectors,
6448
+ allowedDomains: [],
6449
+ allowedApps: effectiveAllowedApps,
6450
+ maxSteps,
6451
+ selfHealing: config.guardrails.selfHealing
6452
+ });
5669
6453
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
5670
6454
  const startTime = Date.now();
5671
6455
  let stepResults = [];
@@ -5705,8 +6489,19 @@ async function executeNativeHuntAttempt(options, config, configDir, interpolated
5705
6489
  } catch {
5706
6490
  finalScreenshot = void 0;
5707
6491
  }
6492
+ const { results: assertionResults, warnings: assertionWarnings } = await evaluateNativeAssertions({
6493
+ driver,
6494
+ config,
6495
+ huntAssertions: interpolatedHunt.assertions,
6496
+ assertAllowedSelector: assertionPolicy.assertAllowedSelector,
6497
+ targetLabel: nativeTargetLabel(native.targetType)
6498
+ });
6499
+ for (const warning of assertionWarnings) {
6500
+ console.warn(warning);
6501
+ }
5708
6502
  const durationMs = Date.now() - startTime;
5709
- const status = stepFailed ? "fail" : "pass";
6503
+ const assertionsFailed = assertionResults.some((assertion) => assertion.status === "fail");
6504
+ const status = stepFailed || assertionsFailed ? "fail" : "pass";
5710
6505
  const artifacts = {
5711
6506
  screenshots: finalScreenshot ? [...stepScreenshots, finalScreenshot] : stepScreenshots
5712
6507
  };
@@ -5717,7 +6512,7 @@ async function executeNativeHuntAttempt(options, config, configDir, interpolated
5717
6512
  hunt: options.huntName,
5718
6513
  targetUrl: targetLabel,
5719
6514
  steps: stepResults,
5720
- assertions: [],
6515
+ assertions: assertionResults,
5721
6516
  artifacts
5722
6517
  });
5723
6518
  result = writeReports(runDir, runResult, { junit: options.junit ?? config.artifacts.junit });
@@ -5830,7 +6625,6 @@ async function runNativeHunt(options, config, configDir, target, native) {
5830
6625
  const hunt = loadHunt(options.huntName, configDir);
5831
6626
  const { hunt: interpolatedHunt, redactedFillSteps, randomVars } = interpolateHunt(hunt, process.env);
5832
6627
  assertStepsSupportedByTarget(interpolatedHunt.steps, native.targetType);
5833
- assertHuntAssertionsSupportedByTarget(interpolatedHunt.assertions, native.targetType);
5834
6628
  native.assertAppAllowed(config.guardrails.allowedApps, target);
5835
6629
  const maxSteps = config.guardrails.maxSteps;
5836
6630
  if (interpolatedHunt.steps.length > maxSteps) {
@@ -5871,7 +6665,7 @@ async function runNativeHunt(options, config, configDir, target, native) {
5871
6665
  }
5872
6666
  function recordHistory(configDir, outcome, maxRuns) {
5873
6667
  try {
5874
- const relativeRunDir = import_node_path14.default.relative(configDir, outcome.runDir);
6668
+ const relativeRunDir = import_node_path15.default.relative(configDir, outcome.runDir);
5875
6669
  appendEntry(
5876
6670
  configDir,
5877
6671
  {
@@ -5889,6 +6683,7 @@ function recordHistory(configDir, outcome, maxRuns) {
5889
6683
 
5890
6684
  // src/cli/commands/run.ts
5891
6685
  init_loader();
6686
+ init_hunt_name();
5892
6687
 
5893
6688
  // src/cli/output.ts
5894
6689
  var import_chalk = __toESM(require("chalk"), 1);
@@ -5964,6 +6759,7 @@ function describeStep(step) {
5964
6759
  const th = step.assertScreenshot.threshold !== void 0 ? ` (threshold: ${step.assertScreenshot.threshold})` : "";
5965
6760
  return `assertScreenshot "${step.assertScreenshot.name}"${th}`;
5966
6761
  }
6762
+ if ("assertWithAI" in step) return `assertWithAI "${truncate(step.assertWithAI, 60)}"`;
5967
6763
  return "unknown step";
5968
6764
  }
5969
6765
  function truncate(text, max) {
@@ -5979,6 +6775,9 @@ function printStepResult(result, step, _index) {
5979
6775
  const duration = import_chalk.default.gray(`(${result.durationMs}ms)`);
5980
6776
  if (result.status === "pass") {
5981
6777
  console.log(` ${import_chalk.default.green("\u2713")} ${label} ${duration}`);
6778
+ } else if (result.status === "warn") {
6779
+ const note = result.value ? import_chalk.default.gray(` \u2014 ${result.value}`) : "";
6780
+ console.log(` ${import_chalk.default.yellow("\u25CB")} ${label} ${duration}${note}`);
5982
6781
  } else {
5983
6782
  const error = result.error ? import_chalk.default.gray(` \u2014 ${result.error}`) : "";
5984
6783
  console.log(` ${import_chalk.default.red("\u2717")} ${label} ${duration}${error}`);
@@ -6030,7 +6829,11 @@ ${bot}`);
6030
6829
 
6031
6830
  // src/cli/commands/run.ts
6032
6831
  function buildRunCommand() {
6033
- const command = new import_commander.Command("run").argument("<hunt-name>", "Hunt name or path (e.g. homepage or admin/users-crud)").option("--url <target>", "Override target URL").option("--headed", "Show browser window").option("--slow-mo <ms>", "Slow down Playwright actions", (value) => Number(value)).option("--trace", "Capture Playwright trace").option("--browser <engine>", "Browser engine: chromium, firefox, or webkit").option("--channel <name>", "Browser channel: chrome, msedge, chrome-beta, etc.").option("--viewport <size>", "Viewport size: WxH (e.g. 1920x1080) or preset (mobile, tablet, desktop)").option("--include-tags <tags>", "Only run hunts matching these tags (comma-separated)").option("--exclude-tags <tags>", "Skip hunts matching these tags (comma-separated)").option("--junit", "Generate JUnit XML report").option("--config <path>", "Custom config path").option("--json", "Output results as JSON").action(async (huntName, options) => {
6832
+ const command = new import_commander.Command("run").argument(
6833
+ "<hunt-name>",
6834
+ "Hunt name or path (e.g. homepage, admin/users-crud, or .prowl/hunts/homepage.yml)"
6835
+ ).option("--url <target>", "Override target URL").option("--headed", "Show browser window").option("--slow-mo <ms>", "Slow down Playwright actions", (value) => Number(value)).option("--trace", "Capture Playwright trace").option("--browser <engine>", "Browser engine: chromium, firefox, or webkit").option("--channel <name>", "Browser channel: chrome, msedge, chrome-beta, etc.").option("--viewport <size>", "Viewport size: WxH (e.g. 1920x1080) or preset (mobile, tablet, desktop)").option("--include-tags <tags>", "Only run hunts matching these tags (comma-separated)").option("--exclude-tags <tags>", "Skip hunts matching these tags (comma-separated)").option("--junit", "Generate JUnit XML report").option("--config <path>", "Custom config path").option("--json", "Output results as JSON").action(async (huntArg, options) => {
6836
+ const huntName = normalizeHuntName(huntArg);
6034
6837
  try {
6035
6838
  if (options.includeTags || options.excludeTags) {
6036
6839
  const { configDir } = loadConfig(options.config);
@@ -6097,7 +6900,7 @@ function buildRunCommand() {
6097
6900
 
6098
6901
  // src/cli/commands/init.ts
6099
6902
  var import_node_fs15 = __toESM(require("fs"), 1);
6100
- var import_node_path15 = __toESM(require("path"), 1);
6903
+ var import_node_path16 = __toESM(require("path"), 1);
6101
6904
  var import_node_url2 = require("url");
6102
6905
  var import_commander2 = require("commander");
6103
6906
  var import_chalk4 = __toESM(require("chalk"), 1);
@@ -6105,27 +6908,27 @@ init_loader();
6105
6908
  var import_meta4 = {};
6106
6909
  function getPackageRoot2() {
6107
6910
  const currentFile = (0, import_node_url2.fileURLToPath)(import_meta4.url);
6108
- let dir = import_node_path15.default.dirname(currentFile);
6109
- const root = import_node_path15.default.parse(dir).root;
6911
+ let dir = import_node_path16.default.dirname(currentFile);
6912
+ const root = import_node_path16.default.parse(dir).root;
6110
6913
  while (dir !== root) {
6111
- if (import_node_fs15.default.existsSync(import_node_path15.default.join(dir, "package.json"))) {
6914
+ if (import_node_fs15.default.existsSync(import_node_path16.default.join(dir, "package.json"))) {
6112
6915
  return dir;
6113
6916
  }
6114
- dir = import_node_path15.default.dirname(dir);
6917
+ dir = import_node_path16.default.dirname(dir);
6115
6918
  }
6116
- if (import_node_fs15.default.existsSync(import_node_path15.default.join(root, "package.json"))) {
6919
+ if (import_node_fs15.default.existsSync(import_node_path16.default.join(root, "package.json"))) {
6117
6920
  return root;
6118
6921
  }
6119
6922
  throw new Error("Cannot find package root. Reinstall prowl-tools.");
6120
6923
  }
6121
6924
  function copyFile(source, destination) {
6122
- import_node_fs15.default.mkdirSync(import_node_path15.default.dirname(destination), { recursive: true });
6925
+ import_node_fs15.default.mkdirSync(import_node_path16.default.dirname(destination), { recursive: true });
6123
6926
  import_node_fs15.default.copyFileSync(source, destination);
6124
6927
  }
6125
6928
  function buildInitCommand() {
6126
6929
  const command = new import_commander2.Command("init").option("--force", `Overwrite existing ${CONFIG_DIR} directory`).action((options) => {
6127
6930
  const root = process.cwd();
6128
- const prowlDir = import_node_path15.default.join(root, CONFIG_DIR);
6931
+ const prowlDir = import_node_path16.default.join(root, CONFIG_DIR);
6129
6932
  if (import_node_fs15.default.existsSync(prowlDir) && !options.force) {
6130
6933
  console.error(
6131
6934
  import_chalk4.default.red(
@@ -6136,20 +6939,20 @@ function buildInitCommand() {
6136
6939
  return;
6137
6940
  }
6138
6941
  const packageRoot = getPackageRoot2();
6139
- const examplesDir = import_node_path15.default.join(packageRoot, "examples");
6140
- const exampleConfig = import_node_path15.default.join(examplesDir, "config.yml");
6141
- const exampleHuntsDir = import_node_path15.default.join(examplesDir, "hunts");
6942
+ const examplesDir = import_node_path16.default.join(packageRoot, "examples");
6943
+ const exampleConfig = import_node_path16.default.join(examplesDir, "config.yml");
6944
+ const exampleHuntsDir = import_node_path16.default.join(examplesDir, "hunts");
6142
6945
  if (!import_node_fs15.default.existsSync(exampleConfig) || !import_node_fs15.default.existsSync(exampleHuntsDir)) {
6143
6946
  console.error(import_chalk4.default.red("Examples not found in package. Reinstall prowl-tools."));
6144
6947
  process.exitCode = 1;
6145
6948
  return;
6146
6949
  }
6147
- copyFile(exampleConfig, import_node_path15.default.join(prowlDir, "config.yml"));
6950
+ copyFile(exampleConfig, import_node_path16.default.join(prowlDir, "config.yml"));
6148
6951
  const huntFiles = import_node_fs15.default.readdirSync(exampleHuntsDir).filter((f) => f.endsWith(".yml"));
6149
6952
  for (const huntFile of huntFiles) {
6150
6953
  copyFile(
6151
- import_node_path15.default.join(exampleHuntsDir, huntFile),
6152
- import_node_path15.default.join(prowlDir, "hunts", huntFile)
6954
+ import_node_path16.default.join(exampleHuntsDir, huntFile),
6955
+ import_node_path16.default.join(prowlDir, "hunts", huntFile)
6153
6956
  );
6154
6957
  }
6155
6958
  const gitignore = [
@@ -6163,27 +6966,27 @@ function buildInitCommand() {
6163
6966
  ".env",
6164
6967
  ""
6165
6968
  ].join("\n");
6166
- import_node_fs15.default.writeFileSync(import_node_path15.default.join(prowlDir, ".gitignore"), gitignore);
6969
+ import_node_fs15.default.writeFileSync(import_node_path16.default.join(prowlDir, ".gitignore"), gitignore);
6167
6970
  console.log(welcomeBanner());
6168
6971
  console.log(import_chalk4.default.green(` Initialized ${CONFIG_DIR} directory.`));
6169
6972
  console.log(import_chalk4.default.gray(" Run ") + import_chalk4.default.bold("prowl run hello") + import_chalk4.default.gray(" to get started."));
6170
- console.log(import_chalk4.default.gray(" Browse hunt templates at ") + import_chalk4.default.cyan("https://hub.prowl.tools") + "\n");
6973
+ 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");
6171
6974
  });
6172
6975
  return command;
6173
6976
  }
6174
6977
 
6175
6978
  // src/cli/commands/login.ts
6176
- var import_node_path16 = __toESM(require("path"), 1);
6979
+ var import_node_path17 = __toESM(require("path"), 1);
6177
6980
  var import_node_readline = __toESM(require("readline"), 1);
6178
6981
  var import_chalk5 = __toESM(require("chalk"), 1);
6179
6982
  var import_commander3 = require("commander");
6180
6983
  init_loader();
6181
6984
  function resolvePath2(configDir, inputPath) {
6182
- if (import_node_path16.default.isAbsolute(inputPath)) {
6985
+ if (import_node_path17.default.isAbsolute(inputPath)) {
6183
6986
  return inputPath;
6184
6987
  }
6185
- const projectRoot = import_node_path16.default.dirname(configDir);
6186
- return import_node_path16.default.join(projectRoot, inputPath);
6988
+ const projectRoot = import_node_path17.default.dirname(configDir);
6989
+ return import_node_path17.default.join(projectRoot, inputPath);
6187
6990
  }
6188
6991
  function waitForEnter(prompt) {
6189
6992
  return new Promise((resolve) => {
@@ -6274,14 +7077,15 @@ var import_node_fs16 = __toESM(require("fs"), 1);
6274
7077
  var import_chalk7 = __toESM(require("chalk"), 1);
6275
7078
  var import_commander5 = require("commander");
6276
7079
  init_loader();
7080
+ init_hunt_name();
6277
7081
 
6278
7082
  // src/cli/watch-utils.ts
6279
- var import_node_path17 = __toESM(require("path"), 1);
7083
+ var import_node_path18 = __toESM(require("path"), 1);
6280
7084
  function getWatchTargets(configDir, huntName) {
6281
7085
  return [
6282
- import_node_path17.default.join(configDir, "hunts", `${huntName}.yml`),
6283
- import_node_path17.default.join(configDir, "config.yml"),
6284
- import_node_path17.default.join(configDir, ".env")
7086
+ import_node_path18.default.join(configDir, "hunts", `${huntName}.yml`),
7087
+ import_node_path18.default.join(configDir, "config.yml"),
7088
+ import_node_path18.default.join(configDir, ".env")
6285
7089
  ];
6286
7090
  }
6287
7091
  function createDebouncer(delayMs, fn) {
@@ -6307,7 +7111,11 @@ function createDebouncer(delayMs, fn) {
6307
7111
 
6308
7112
  // src/cli/commands/watch.ts
6309
7113
  function buildWatchCommand() {
6310
- const command = new import_commander5.Command("watch").argument("<hunt-name>", "Hunt name or path (e.g. homepage or admin/users-crud)").option("--url <target>", "Override target URL").option("--headed", "Show browser window").option("--slow-mo <ms>", "Slow down Playwright actions", (value) => Number(value)).option("--trace", "Capture Playwright trace").option("--config <path>", "Custom config path").action(async (huntName, options) => {
7114
+ const command = new import_commander5.Command("watch").argument(
7115
+ "<hunt-name>",
7116
+ "Hunt name or path (e.g. homepage, admin/users-crud, or .prowl/hunts/homepage.yml)"
7117
+ ).option("--url <target>", "Override target URL").option("--headed", "Show browser window").option("--slow-mo <ms>", "Slow down Playwright actions", (value) => Number(value)).option("--trace", "Capture Playwright trace").option("--config <path>", "Custom config path").action(async (huntArg, options) => {
7118
+ const huntName = normalizeHuntName(huntArg);
6311
7119
  const { configDir } = loadConfig(options.config);
6312
7120
  const watchTargets = getWatchTargets(configDir, huntName);
6313
7121
  let running = false;
@@ -6383,12 +7191,12 @@ var import_commander6 = require("commander");
6383
7191
  var import_chalk9 = __toESM(require("chalk"), 1);
6384
7192
 
6385
7193
  // src/runner/suite.ts
6386
- var import_node_path20 = __toESM(require("path"), 1);
7194
+ var import_node_path21 = __toESM(require("path"), 1);
6387
7195
  init_loader();
6388
7196
 
6389
7197
  // src/reporter/ci-summary.ts
6390
7198
  var import_node_fs17 = __toESM(require("fs"), 1);
6391
- var import_node_path18 = __toESM(require("path"), 1);
7199
+ var import_node_path19 = __toESM(require("path"), 1);
6392
7200
  var import_chalk8 = __toESM(require("chalk"), 1);
6393
7201
  function countCiResults(results) {
6394
7202
  return {
@@ -6453,7 +7261,7 @@ function writeCiResult(ciRunDir, results, startedAt, totalDurationMs, flaky = []
6453
7261
  ...clusters.length > 0 ? { clusters } : {}
6454
7262
  };
6455
7263
  import_node_fs17.default.mkdirSync(ciRunDir, { recursive: true });
6456
- const filePath = import_node_path18.default.join(ciRunDir, "ci-result.json");
7264
+ const filePath = import_node_path19.default.join(ciRunDir, "ci-result.json");
6457
7265
  import_node_fs17.default.writeFileSync(filePath, JSON.stringify(ciResult, null, 2) + "\n");
6458
7266
  return filePath;
6459
7267
  }
@@ -6589,7 +7397,7 @@ function clusterFailures(failures) {
6589
7397
 
6590
7398
  // src/backlog/index.ts
6591
7399
  var import_node_fs18 = __toESM(require("fs"), 1);
6592
- var import_node_path19 = __toESM(require("path"), 1);
7400
+ var import_node_path20 = __toESM(require("path"), 1);
6593
7401
 
6594
7402
  // src/backlog/parse.ts
6595
7403
  var MARKER_FP = /<!--\s*prowl:fp=([0-9a-f]+)/;
@@ -6695,7 +7503,7 @@ function buildFailure(hunt) {
6695
7503
  if (!hunt.runDir) return failure;
6696
7504
  let run;
6697
7505
  try {
6698
- const resultJson = readFileOrEmpty(import_node_path19.default.join(hunt.runDir, "result.json"));
7506
+ const resultJson = readFileOrEmpty(import_node_path20.default.join(hunt.runDir, "result.json"));
6699
7507
  if (!resultJson) return failure;
6700
7508
  run = JSON.parse(resultJson);
6701
7509
  } catch (error) {
@@ -6724,8 +7532,8 @@ function extractFailures(suiteResult) {
6724
7532
  }
6725
7533
  function updateBacklogFromSuite(suiteResult, options = {}) {
6726
7534
  const projectRoot = options.projectRoot ?? process.cwd();
6727
- const backlogPath = options.backlogPath ?? import_node_path19.default.join(projectRoot, "docs", "backlog.md");
6728
- const resolvedPath = options.resolvedPath ?? import_node_path19.default.join(projectRoot, "docs", "resolved.md");
7535
+ const backlogPath = options.backlogPath ?? import_node_path20.default.join(projectRoot, "docs", "backlog.md");
7536
+ const resolvedPath = options.resolvedPath ?? import_node_path20.default.join(projectRoot, "docs", "resolved.md");
6729
7537
  const date = options.date ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
6730
7538
  const summary = { created: [], regressions: [], skipped: [], backlogPath };
6731
7539
  const failures = extractFailures(suiteResult);
@@ -6757,7 +7565,7 @@ function updateBacklogFromSuite(suiteResult, options = {}) {
6757
7565
  }
6758
7566
  }
6759
7567
  if (ticketsToAdd.length > 0) {
6760
- import_node_fs18.default.mkdirSync(import_node_path19.default.dirname(backlogPath), { recursive: true });
7568
+ import_node_fs18.default.mkdirSync(import_node_path20.default.dirname(backlogPath), { recursive: true });
6761
7569
  import_node_fs18.default.writeFileSync(backlogPath, insertTickets(backlogContent, ticketsToAdd));
6762
7570
  }
6763
7571
  return summary;
@@ -6919,7 +7727,7 @@ async function runSuite(options = {}) {
6919
7727
  const clusters = clusterFailures(
6920
7728
  extractFailures({ result: { hunts: results }, resultPath: null })
6921
7729
  ).filter((cluster) => cluster.count > 1);
6922
- const ciRunDir = import_node_path20.default.join(configDir, "runs", timestamp("ci"));
7730
+ const ciRunDir = import_node_path21.default.join(configDir, "runs", timestamp("ci"));
6923
7731
  const resultPath = writeCiResult(ciRunDir, results, startedAt, totalDurationMs, flaky, clusters);
6924
7732
  const { passed, failed, skipped } = countCiResults(results);
6925
7733
  return {
@@ -7038,7 +7846,7 @@ function buildCiCommand() {
7038
7846
 
7039
7847
  // src/cli/commands/update-baselines.ts
7040
7848
  var import_node_fs19 = __toESM(require("fs"), 1);
7041
- var import_node_path21 = __toESM(require("path"), 1);
7849
+ var import_node_path22 = __toESM(require("path"), 1);
7042
7850
  var import_commander7 = require("commander");
7043
7851
  var import_chalk10 = __toESM(require("chalk"), 1);
7044
7852
  init_loader();
@@ -7046,27 +7854,27 @@ function buildUpdateBaselinesCommand() {
7046
7854
  const command = new import_commander7.Command("update-baselines").description("Accept current screenshots as new visual regression baselines").option("--run <dir>", "Specific run directory to use").option("--name <name>", "Update only a specific baseline by name").option("--config <path>", "Custom config path").option("-y, --yes", "Skip confirmation prompt").action(async (options) => {
7047
7855
  try {
7048
7856
  const { configDir } = loadConfig(options.config);
7049
- const baselinesDir = import_node_path21.default.join(configDir, "baselines");
7857
+ const baselinesDir = import_node_path22.default.join(configDir, "baselines");
7050
7858
  import_node_fs19.default.mkdirSync(baselinesDir, { recursive: true });
7051
7859
  let runDir;
7052
7860
  if (options.run) {
7053
- runDir = import_node_path21.default.isAbsolute(options.run) ? options.run : import_node_path21.default.resolve(options.run);
7861
+ runDir = import_node_path22.default.isAbsolute(options.run) ? options.run : import_node_path22.default.resolve(options.run);
7054
7862
  } else {
7055
- const runsDir = import_node_path21.default.join(configDir, "runs");
7863
+ const runsDir = import_node_path22.default.join(configDir, "runs");
7056
7864
  if (!import_node_fs19.default.existsSync(runsDir)) {
7057
7865
  console.error(import_chalk10.default.red(" No runs directory found. Run a hunt first."));
7058
7866
  process.exitCode = 1;
7059
7867
  return;
7060
7868
  }
7061
- const entries = import_node_fs19.default.readdirSync(runsDir).filter((e) => import_node_fs19.default.statSync(import_node_path21.default.join(runsDir, e)).isDirectory()).sort().reverse();
7869
+ const entries = import_node_fs19.default.readdirSync(runsDir).filter((e) => import_node_fs19.default.statSync(import_node_path22.default.join(runsDir, e)).isDirectory()).sort().reverse();
7062
7870
  if (entries.length === 0) {
7063
7871
  console.error(import_chalk10.default.red(" No run directories found. Run a hunt first."));
7064
7872
  process.exitCode = 1;
7065
7873
  return;
7066
7874
  }
7067
- runDir = import_node_path21.default.join(runsDir, entries[0]);
7875
+ runDir = import_node_path22.default.join(runsDir, entries[0]);
7068
7876
  }
7069
- const screenshotsDir = import_node_path21.default.join(runDir, "screenshots");
7877
+ const screenshotsDir = import_node_path22.default.join(runDir, "screenshots");
7070
7878
  if (!import_node_fs19.default.existsSync(screenshotsDir)) {
7071
7879
  console.error(import_chalk10.default.red(` No screenshots found in ${runDir}`));
7072
7880
  process.exitCode = 1;
@@ -7086,8 +7894,8 @@ function buildUpdateBaselinesCommand() {
7086
7894
  let updated = 0;
7087
7895
  for (const file of filtered) {
7088
7896
  const baselineName = file.replace("-current.png", ".png");
7089
- const sourcePath = import_node_path21.default.join(screenshotsDir, file);
7090
- const destPath = import_node_path21.default.join(baselinesDir, baselineName);
7897
+ const sourcePath = import_node_path22.default.join(screenshotsDir, file);
7898
+ const destPath = import_node_path22.default.join(baselinesDir, baselineName);
7091
7899
  const exists = import_node_fs19.default.existsSync(destPath);
7092
7900
  import_node_fs19.default.copyFileSync(sourcePath, destPath);
7093
7901
  updated++;
@@ -7370,14 +8178,228 @@ async function readStatusMenu(client, menuTimeoutSeconds) {
7370
8178
  }
7371
8179
  }
7372
8180
 
7373
- // src/cli/commands/analyze.ts
7374
- init_loader();
7375
- function parseViewportFlag2(value) {
7376
- const match = /^(\d+)x(\d+)$/i.exec(value);
7377
- if (match) {
7378
- return { width: Number(match[1]), height: Number(match[2]) };
8181
+ // src/analyzer/android.ts
8182
+ var ANDROID_INTERACTIVE_CLASSES = /* @__PURE__ */ new Set([
8183
+ "android.widget.Button",
8184
+ "android.widget.ImageButton",
8185
+ "android.widget.EditText",
8186
+ "android.widget.CheckBox",
8187
+ "android.widget.RadioButton",
8188
+ "android.widget.Switch",
8189
+ "android.widget.ToggleButton",
8190
+ "android.widget.Spinner",
8191
+ "android.widget.SeekBar",
8192
+ "android.widget.RatingBar",
8193
+ "android.widget.CompoundButton",
8194
+ "android.widget.AutoCompleteTextView",
8195
+ "android.widget.MultiAutoCompleteTextView",
8196
+ "android.widget.CheckedTextView",
8197
+ "androidx.appcompat.widget.SwitchCompat",
8198
+ "androidx.appcompat.widget.AppCompatButton",
8199
+ "androidx.appcompat.widget.AppCompatEditText"
8200
+ ]);
8201
+ function str2(value) {
8202
+ if (value === void 0) {
8203
+ return void 0;
7379
8204
  }
7380
- return value;
8205
+ const trimmed = value.trim();
8206
+ return trimmed.length > 0 ? trimmed : void 0;
8207
+ }
8208
+ function bool(value) {
8209
+ if (value === void 0) {
8210
+ return void 0;
8211
+ }
8212
+ return value === "true";
8213
+ }
8214
+ function toAndroidNode(element) {
8215
+ const a = element.attrs;
8216
+ return {
8217
+ className: str2(a["class"]),
8218
+ resourceId: str2(a["resource-id"]),
8219
+ contentDesc: str2(a["content-desc"]),
8220
+ text: str2(a.text),
8221
+ package: str2(a.package),
8222
+ clickable: bool(a.clickable),
8223
+ longClickable: bool(a["long-clickable"]),
8224
+ checkable: bool(a.checkable),
8225
+ checked: bool(a.checked),
8226
+ scrollable: bool(a.scrollable),
8227
+ focusable: bool(a.focusable),
8228
+ focused: bool(a.focused),
8229
+ enabled: bool(a.enabled),
8230
+ children: element.children.map(toAndroidNode)
8231
+ };
8232
+ }
8233
+ function parseAndroidHierarchy(xml) {
8234
+ const root = parseXml(xml);
8235
+ return root ? toAndroidNode(root) : null;
8236
+ }
8237
+ function isAndroidInteractive(node) {
8238
+ if (node.clickable || node.longClickable || node.checkable || node.scrollable) {
8239
+ return true;
8240
+ }
8241
+ return node.className !== void 0 && ANDROID_INTERACTIVE_CLASSES.has(node.className);
8242
+ }
8243
+ function rankAndroidSelectors(node) {
8244
+ return rankNativeSelectors({
8245
+ id: node.resourceId,
8246
+ label: node.contentDesc,
8247
+ role: node.className,
8248
+ name: node.text
8249
+ });
8250
+ }
8251
+ function toElement2(node) {
8252
+ return {
8253
+ className: node.className ?? "?",
8254
+ ...node.resourceId ? { resourceId: node.resourceId } : {},
8255
+ ...node.contentDesc ? { contentDesc: node.contentDesc } : {},
8256
+ ...node.text ? { text: node.text } : {},
8257
+ ...node.clickable !== void 0 ? { clickable: node.clickable } : {},
8258
+ ...node.checkable !== void 0 ? { checkable: node.checkable } : {},
8259
+ ...node.scrollable !== void 0 ? { scrollable: node.scrollable } : {},
8260
+ ...node.enabled !== void 0 ? { enabled: node.enabled } : {},
8261
+ selectors: rankAndroidSelectors(node)
8262
+ };
8263
+ }
8264
+ function collectInteractive2(root) {
8265
+ const out = [];
8266
+ const visit = (node) => {
8267
+ if (isAndroidInteractive(node)) {
8268
+ out.push(toElement2(node));
8269
+ }
8270
+ for (const child of node.children) {
8271
+ visit(child);
8272
+ }
8273
+ };
8274
+ visit(root);
8275
+ return out;
8276
+ }
8277
+ async function analyzeAndroidApp(client, options) {
8278
+ const xml = await client.source();
8279
+ const root = parseAndroidHierarchy(xml);
8280
+ const elements = root ? collectInteractive2(root) : [];
8281
+ return { app: options.app, elements };
8282
+ }
8283
+
8284
+ // src/analyzer/ios.ts
8285
+ var IOS_INTERACTIVE_TYPES = /* @__PURE__ */ new Set([
8286
+ "XCUIElementTypeButton",
8287
+ "XCUIElementTypeCell",
8288
+ "XCUIElementTypeTextField",
8289
+ "XCUIElementTypeSecureTextField",
8290
+ "XCUIElementTypeSearchField",
8291
+ "XCUIElementTypeSwitch",
8292
+ "XCUIElementTypeToggle",
8293
+ "XCUIElementTypeLink",
8294
+ "XCUIElementTypeMenuItem",
8295
+ "XCUIElementTypeSlider",
8296
+ "XCUIElementTypeStepper",
8297
+ "XCUIElementTypeTextView",
8298
+ "XCUIElementTypePickerWheel",
8299
+ "XCUIElementTypeTab",
8300
+ "XCUIElementTypeSegmentedControl",
8301
+ "XCUIElementTypeCheckBox",
8302
+ "XCUIElementTypeRadioButton",
8303
+ "XCUIElementTypeKey"
8304
+ ]);
8305
+ var IOS_WINDOW_TYPE = "XCUIElementTypeWindow";
8306
+ function str3(value) {
8307
+ if (value === void 0) {
8308
+ return void 0;
8309
+ }
8310
+ const trimmed = value.trim();
8311
+ return trimmed.length > 0 ? trimmed : void 0;
8312
+ }
8313
+ function bool2(value) {
8314
+ if (value === void 0) {
8315
+ return void 0;
8316
+ }
8317
+ return value === "true" || value === "1";
8318
+ }
8319
+ function toIosNode(element) {
8320
+ const a = element.attrs;
8321
+ return {
8322
+ type: str3(a.type) ?? str3(element.tag),
8323
+ name: str3(a.name),
8324
+ label: str3(a.label),
8325
+ value: str3(a.value),
8326
+ enabled: bool2(a.enabled),
8327
+ visible: bool2(a.visible),
8328
+ children: element.children.map(toIosNode)
8329
+ };
8330
+ }
8331
+ function parseIosHierarchy(xml) {
8332
+ const root = parseXml(xml);
8333
+ return root ? toIosNode(root) : null;
8334
+ }
8335
+ function hasAccessibilityId(node) {
8336
+ return node.name !== void 0 && node.name !== node.label;
8337
+ }
8338
+ function rankIosSelectors(node) {
8339
+ return rankNativeSelectors({
8340
+ id: hasAccessibilityId(node) ? node.name : void 0,
8341
+ label: node.label,
8342
+ role: node.type ? shortIosType(node.type) : void 0,
8343
+ name: node.label ?? node.value
8344
+ });
8345
+ }
8346
+ function toElement3(node) {
8347
+ return {
8348
+ type: node.type ?? "?",
8349
+ ...node.name ? { name: node.name } : {},
8350
+ ...node.label ? { label: node.label } : {},
8351
+ ...node.value ? { value: node.value } : {},
8352
+ ...node.enabled !== void 0 ? { enabled: node.enabled } : {},
8353
+ ...node.visible !== void 0 ? { visible: node.visible } : {},
8354
+ selectors: rankIosSelectors(node)
8355
+ };
8356
+ }
8357
+ function toWindow2(node) {
8358
+ const [best] = rankIosSelectors(node);
8359
+ return {
8360
+ ...node.name ? { name: node.name } : {},
8361
+ ...node.label ? { label: node.label } : {},
8362
+ selector: best ?? `role=${shortIosType(IOS_WINDOW_TYPE)}`
8363
+ };
8364
+ }
8365
+ function isIosInteractive(node) {
8366
+ return node.type !== void 0 && IOS_INTERACTIVE_TYPES.has(node.type);
8367
+ }
8368
+ function collect(root) {
8369
+ const elements = [];
8370
+ const windows = [];
8371
+ const visit = (node) => {
8372
+ if (node.type === IOS_WINDOW_TYPE) {
8373
+ windows.push(toWindow2(node));
8374
+ }
8375
+ if (isIosInteractive(node)) {
8376
+ elements.push(toElement3(node));
8377
+ }
8378
+ for (const child of node.children) {
8379
+ visit(child);
8380
+ }
8381
+ };
8382
+ visit(root);
8383
+ return { elements, windows };
8384
+ }
8385
+ async function analyzeIosApp(client, options) {
8386
+ const xml = await client.source();
8387
+ const root = parseIosHierarchy(xml);
8388
+ if (!root) {
8389
+ return { app: options.app, elements: [], windows: [] };
8390
+ }
8391
+ const { elements, windows } = collect(root);
8392
+ return { app: options.app, elements, windows };
8393
+ }
8394
+
8395
+ // src/cli/commands/analyze.ts
8396
+ init_loader();
8397
+ function parseViewportFlag2(value) {
8398
+ const match = /^(\d+)x(\d+)$/i.exec(value);
8399
+ if (match) {
8400
+ return { width: Number(match[1]), height: Number(match[2]) };
8401
+ }
8402
+ return value;
7381
8403
  }
7382
8404
  function tryLoadConfig(configPath) {
7383
8405
  const configLocation = configPath !== void 0 ? configPath : findConfigPath(process.cwd());
@@ -7391,6 +8413,23 @@ function tryLoadConfig(configPath) {
7391
8413
  throw new Error(`Failed to load config at ${configLocation}: ${message}`);
7392
8414
  }
7393
8415
  }
8416
+ function looksLikeApk2(app) {
8417
+ return /\.apk$/i.test(app.trim());
8418
+ }
8419
+ function pickNativePlatform(platformFlag, config, app) {
8420
+ if (platformFlag !== void 0) {
8421
+ const normalized = platformFlag.trim().toLowerCase();
8422
+ if (normalized === "macos" || normalized === "android" || normalized === "ios") {
8423
+ return normalized;
8424
+ }
8425
+ throw new Error(`Unknown --platform "${platformFlag}". Use one of: macos, android, ios.`);
8426
+ }
8427
+ const configType = config?.target.type;
8428
+ if (configType === "macos" || configType === "android" || configType === "ios") {
8429
+ return configType;
8430
+ }
8431
+ return looksLikeApk2(app) ? "android" : "macos";
8432
+ }
7394
8433
  async function runWebAnalyze(url, options) {
7395
8434
  const engine = parseBrowserEngine(options.browser);
7396
8435
  const channel = options.channel;
@@ -7511,14 +8550,141 @@ async function runMacAnalyze(app, config, options) {
7511
8550
  await session.client.close();
7512
8551
  }
7513
8552
  }
8553
+ function printAndroidElement(el) {
8554
+ const role = import_chalk11.default.cyan(el.className);
8555
+ const [best] = el.selectors;
8556
+ const selectorStr = best ? import_chalk11.default.yellow(best) : import_chalk11.default.gray("(no selector)");
8557
+ const label = el.contentDesc ?? el.text;
8558
+ const labelStr = label ? ` ${import_chalk11.default.gray(`"${label}"`)}` : "";
8559
+ const disabled = el.enabled === false ? import_chalk11.default.gray(" (disabled)") : "";
8560
+ console.log(` ${role} ${selectorStr}${labelStr}${disabled}`);
8561
+ }
8562
+ async function runAndroidAnalyze(app, config, options, deviceSerial) {
8563
+ const allowedApps = config?.guardrails.allowedApps ?? [];
8564
+ if (!looksLikeApk2(app)) {
8565
+ assertAndroidAppAllowed(allowedApps, app);
8566
+ }
8567
+ const session = await launchAndroidSession({
8568
+ app,
8569
+ ...deviceSerial ? { deviceSerial } : {},
8570
+ timeoutMs: config?.browser.timeout,
8571
+ allowedApps
8572
+ });
8573
+ try {
8574
+ const sourceFn = session.client.source;
8575
+ if (typeof sourceFn !== "function") {
8576
+ throw new Error(
8577
+ "The Android agent client does not expose source(); a live uiautomator2 session is required to analyze."
8578
+ );
8579
+ }
8580
+ const result = await analyzeAndroidApp(
8581
+ { source: () => sourceFn.call(session.client) },
8582
+ { app: session.package }
8583
+ );
8584
+ if (options.json) {
8585
+ console.log(JSON.stringify(result, null, 2));
8586
+ } else {
8587
+ console.log(`
8588
+ ${import_chalk11.default.bold("App Analysis:")} ${result.app}
8589
+ `);
8590
+ if (result.elements.length > 0) {
8591
+ console.log(import_chalk11.default.bold(" Interactive Elements:"));
8592
+ for (const el of result.elements) {
8593
+ printAndroidElement(el);
8594
+ }
8595
+ console.log();
8596
+ }
8597
+ console.log(import_chalk11.default.gray(` ${result.elements.length} elements
8598
+ `));
8599
+ }
8600
+ } finally {
8601
+ await session.teardown();
8602
+ }
8603
+ }
8604
+ function printIosElement(el) {
8605
+ const role = import_chalk11.default.cyan(el.type);
8606
+ const [best] = el.selectors;
8607
+ const selectorStr = best ? import_chalk11.default.yellow(best) : import_chalk11.default.gray("(no selector)");
8608
+ const label = el.label ?? el.value ?? el.name;
8609
+ const labelStr = label ? ` ${import_chalk11.default.gray(`"${label}"`)}` : "";
8610
+ const disabled = el.enabled === false ? import_chalk11.default.gray(" (disabled)") : "";
8611
+ console.log(` ${role} ${selectorStr}${labelStr}${disabled}`);
8612
+ }
8613
+ async function runIosAnalyze(app, config, options, udid) {
8614
+ const allowedApps = config?.guardrails.allowedApps ?? [];
8615
+ if (!/\//.test(app) && !/\.app$/i.test(app.trim())) {
8616
+ assertIosAppAllowed(allowedApps, app);
8617
+ }
8618
+ const session = await launchIosSession({
8619
+ app,
8620
+ ...udid ? { udid } : {},
8621
+ timeoutMs: config?.browser.timeout,
8622
+ allowedApps
8623
+ });
8624
+ try {
8625
+ const sourceFn = session.client.source;
8626
+ if (typeof sourceFn !== "function") {
8627
+ throw new Error(
8628
+ "The iOS agent client does not expose source(); a live WebDriverAgent session is required to analyze."
8629
+ );
8630
+ }
8631
+ const result = await analyzeIosApp(
8632
+ { source: () => sourceFn.call(session.client) },
8633
+ { app: session.bundleId }
8634
+ );
8635
+ if (options.json) {
8636
+ console.log(JSON.stringify(result, null, 2));
8637
+ } else {
8638
+ console.log(`
8639
+ ${import_chalk11.default.bold("App Analysis:")} ${result.app}
8640
+ `);
8641
+ if (result.windows.length > 0) {
8642
+ console.log(import_chalk11.default.bold(" Windows:"));
8643
+ for (const win of result.windows) {
8644
+ const title = win.label ?? win.name;
8645
+ const titleStr = title ? import_chalk11.default.gray(`"${title}"`) : import_chalk11.default.gray("(untitled)");
8646
+ console.log(` ${titleStr} ${import_chalk11.default.yellow(win.selector)}`);
8647
+ }
8648
+ console.log();
8649
+ }
8650
+ if (result.elements.length > 0) {
8651
+ console.log(import_chalk11.default.bold(" Interactive Elements:"));
8652
+ for (const el of result.elements) {
8653
+ printIosElement(el);
8654
+ }
8655
+ console.log();
8656
+ }
8657
+ console.log(import_chalk11.default.gray(` ${result.elements.length} elements, ${result.windows.length} windows
8658
+ `));
8659
+ }
8660
+ } finally {
8661
+ await session.teardown();
8662
+ }
8663
+ }
8664
+ async function runNativeAnalyze(platform, app, config, options, device) {
8665
+ if (platform === "android") {
8666
+ await runAndroidAnalyze(app, config, options, device.deviceSerial);
8667
+ return;
8668
+ }
8669
+ if (platform === "ios") {
8670
+ await runIosAnalyze(app, config, options, device.udid);
8671
+ return;
8672
+ }
8673
+ await runMacAnalyze(app, config, options);
8674
+ }
7514
8675
  function buildAnalyzeCommand() {
7515
- const command = new import_commander8.Command("analyze").argument("[url]", "URL to analyze (web target)").description("Analyze a page or macOS app to discover interactive elements and selectors").option("--json", "Output as JSON").option("--app <app>", "macOS app to analyze: bundle id or /path/to/App.app (macOS target)").option("--browser <engine>", "Browser engine: chromium, firefox, or webkit").option("--channel <name>", "Browser channel: chrome, msedge, etc.").option("--viewport <size>", "Viewport size: WxH or preset (mobile, tablet, desktop)").option("--headed", "Show browser window").option("--config <path>", "Custom config path").action(async (url, options) => {
8676
+ const command = new import_commander8.Command("analyze").argument("[url]", "URL to analyze (web target)").description("Analyze a page or native app to discover interactive elements and selectors").option("--json", "Output as JSON").option("--app <app>", "Native app to analyze: bundle id / package / .app / .apk (macOS, Android, or iOS)").option("--platform <name>", "Native platform for --app: macos, android, or ios (else inferred)").option("--device <serial>", "Android adb device serial (native Android target)").option("--udid <udid>", "iOS simulator UDID (native iOS target)").option("--browser <engine>", "Browser engine: chromium, firefox, or webkit").option("--channel <name>", "Browser channel: chrome, msedge, etc.").option("--viewport <size>", "Viewport size: WxH or preset (mobile, tablet, desktop)").option("--headed", "Show browser window").option("--config <path>", "Custom config path").action(async (url, options) => {
7516
8677
  try {
7517
8678
  if (options.app && url) {
7518
- throw new Error("Pass either a URL argument (web) or --app (macOS), not both.");
8679
+ throw new Error("Pass either a URL argument (web) or --app (native), not both.");
7519
8680
  }
7520
8681
  if (options.app) {
7521
- await runMacAnalyze(options.app, tryLoadConfig(options.config), options);
8682
+ const config2 = tryLoadConfig(options.config);
8683
+ const platform = pickNativePlatform(options.platform, config2, options.app);
8684
+ await runNativeAnalyze(platform, options.app, config2, options, {
8685
+ deviceSerial: options.device,
8686
+ udid: options.udid
8687
+ });
7522
8688
  return;
7523
8689
  }
7524
8690
  if (url) {
@@ -7526,12 +8692,21 @@ function buildAnalyzeCommand() {
7526
8692
  return;
7527
8693
  }
7528
8694
  const config = tryLoadConfig(options.config);
7529
- if (config?.target.type === "macos") {
7530
- await runMacAnalyze(config.target.app, config, options);
8695
+ const target = config?.target;
8696
+ if (target?.type === "macos") {
8697
+ await runMacAnalyze(target.app, config, options);
8698
+ return;
8699
+ }
8700
+ if (target?.type === "android") {
8701
+ await runAndroidAnalyze(target.app, config, options, options.device ?? target.deviceSerial);
8702
+ return;
8703
+ }
8704
+ if (target?.type === "ios") {
8705
+ await runIosAnalyze(target.app, config, options, options.udid ?? target.udid);
7531
8706
  return;
7532
8707
  }
7533
8708
  throw new Error(
7534
- "analyze needs a target: pass a URL for the web target, or use --app <bundle-id|.app-path> (or set target.type: macos in .prowl/config.yml) for the macOS target."
8709
+ "analyze needs a target: pass a URL for the web target, or use --app <bundle-id|package|.app|.apk> (optionally with --platform), or set a native target.type in .prowl/config.yml."
7535
8710
  );
7536
8711
  } catch (error) {
7537
8712
  const message = error instanceof Error ? error.message : "Analysis failed";
@@ -7550,7 +8725,7 @@ function buildAnalyzeCommand() {
7550
8725
 
7551
8726
  // src/cli/commands/generate.ts
7552
8727
  var import_node_fs20 = __toESM(require("fs"), 1);
7553
- var import_node_path22 = __toESM(require("path"), 1);
8728
+ var import_node_path23 = __toESM(require("path"), 1);
7554
8729
  var import_commander9 = require("commander");
7555
8730
  var import_chalk12 = __toESM(require("chalk"), 1);
7556
8731
  var import_ora = __toESM(require("ora"), 1);
@@ -7601,6 +8776,7 @@ var STEP_REFERENCE = `
7601
8776
 
7602
8777
  ### Visual Regression
7603
8778
  - assertScreenshot: { name: "baseline-name", threshold: 0.1 }
8779
+ - assertWithAI: "The login form should show email and password fields" \u2014 AI checks the screenshot against the claim
7604
8780
 
7605
8781
  ### Control Flow
7606
8782
  - if: { visible: ".banner", then: [steps...] }
@@ -7642,81 +8818,6 @@ function extractYamlFromResponse(response) {
7642
8818
  return response.trim();
7643
8819
  }
7644
8820
 
7645
- // src/generator/ai.ts
7646
- function resolveAiConfig() {
7647
- const provider = process.env.PROWL_AI_PROVIDER ?? "anthropic";
7648
- if (provider !== "anthropic" && provider !== "openai") {
7649
- throw new Error(`Unsupported AI provider: ${provider}. Use "anthropic" or "openai".`);
7650
- }
7651
- const apiKey = process.env.PROWL_AI_KEY;
7652
- if (!apiKey) {
7653
- throw new Error(
7654
- "PROWL_AI_KEY environment variable is required. Set it to your Anthropic or OpenAI API key."
7655
- );
7656
- }
7657
- const defaultModel = provider === "anthropic" ? "claude-sonnet-4-5-20250929" : "gpt-4o";
7658
- const model = process.env.PROWL_AI_MODEL ?? defaultModel;
7659
- return { provider, model, apiKey };
7660
- }
7661
- async function generateWithAi(prompt, config) {
7662
- if (config.provider === "anthropic") {
7663
- return generateWithAnthropic(prompt, config);
7664
- }
7665
- return generateWithOpenAi(prompt, config);
7666
- }
7667
- async function generateWithAnthropic(prompt, config) {
7668
- const response = await fetch("https://api.anthropic.com/v1/messages", {
7669
- method: "POST",
7670
- headers: {
7671
- "Content-Type": "application/json",
7672
- "x-api-key": config.apiKey,
7673
- "anthropic-version": "2023-06-01"
7674
- },
7675
- body: JSON.stringify({
7676
- model: config.model,
7677
- max_tokens: 4096,
7678
- messages: [
7679
- { role: "user", content: prompt }
7680
- ]
7681
- })
7682
- });
7683
- if (!response.ok) {
7684
- const body = await response.text();
7685
- throw new Error(`Anthropic API error (${response.status}): ${body}`);
7686
- }
7687
- const data = await response.json();
7688
- const textBlock = data.content.find((c) => c.type === "text");
7689
- if (!textBlock?.text) {
7690
- throw new Error("Anthropic API returned no text content");
7691
- }
7692
- return textBlock.text;
7693
- }
7694
- async function generateWithOpenAi(prompt, config) {
7695
- const response = await fetch("https://api.openai.com/v1/chat/completions", {
7696
- method: "POST",
7697
- headers: {
7698
- "Content-Type": "application/json",
7699
- "Authorization": `Bearer ${config.apiKey}`
7700
- },
7701
- body: JSON.stringify({
7702
- model: config.model,
7703
- messages: [
7704
- { role: "user", content: prompt }
7705
- ],
7706
- max_tokens: 4096
7707
- })
7708
- });
7709
- if (!response.ok) {
7710
- const body = await response.text();
7711
- throw new Error(`OpenAI API error (${response.status}): ${body}`);
7712
- }
7713
- const data = await response.json();
7714
- if (!data.choices?.[0]?.message?.content) {
7715
- throw new Error("OpenAI API returned no content");
7716
- }
7717
- return data.choices[0].message.content;
7718
- }
7719
-
7720
8821
  // src/generator/index.ts
7721
8822
  init_loader();
7722
8823
  init_schema();
@@ -7819,12 +8920,12 @@ function buildGenerateCommand() {
7819
8920
  const result = loadConfig2(options.config);
7820
8921
  configDir = result.configDir;
7821
8922
  } catch {
7822
- configDir = import_node_path22.default.join(process.cwd(), ".prowl");
8923
+ configDir = import_node_path23.default.join(process.cwd(), ".prowl");
7823
8924
  }
7824
- const huntsDir = import_node_path22.default.join(configDir, "hunts");
8925
+ const huntsDir = import_node_path23.default.join(configDir, "hunts");
7825
8926
  import_node_fs20.default.mkdirSync(huntsDir, { recursive: true });
7826
8927
  const fileName = options.output.endsWith(".yml") ? options.output : `${options.output}.yml`;
7827
- const filePath = import_node_path22.default.join(huntsDir, fileName);
8928
+ const filePath = import_node_path23.default.join(huntsDir, fileName);
7828
8929
  import_node_fs20.default.writeFileSync(filePath, yamlStr + "\n", "utf-8");
7829
8930
  console.log(import_chalk12.default.green(` Saved to ${filePath}`));
7830
8931
  } else {
@@ -7849,14 +8950,19 @@ function buildGenerateCommand() {
7849
8950
  var import_commander10 = require("commander");
7850
8951
  var import_chalk13 = __toESM(require("chalk"), 1);
7851
8952
  init_loader();
8953
+ init_hunt_name();
7852
8954
  function buildHistoryCommand() {
7853
- const command = new import_commander10.Command("history").argument("<hunt-name>", "Hunt name or path (e.g. homepage or admin/users-crud)").description("Show run history for a hunt").option("--config <path>", "Custom config path").option("--limit <n>", "Show the last N runs (default: 20)", (value) => {
8955
+ const command = new import_commander10.Command("history").argument(
8956
+ "<hunt-name>",
8957
+ "Hunt name or path (e.g. homepage, admin/users-crud, or .prowl/hunts/homepage.yml)"
8958
+ ).description("Show run history for a hunt").option("--config <path>", "Custom config path").option("--limit <n>", "Show the last N runs (default: 20)", (value) => {
7854
8959
  const n = Number(value);
7855
8960
  if (!Number.isInteger(n) || n < 1) {
7856
8961
  throw new Error("--limit must be a positive integer");
7857
8962
  }
7858
8963
  return n;
7859
- }).option("--json", "Output as JSON").action((huntName, options) => {
8964
+ }).option("--json", "Output as JSON").action((huntArg, options) => {
8965
+ const huntName = normalizeHuntName(huntArg);
7860
8966
  try {
7861
8967
  const { configDir } = loadConfig(options.config);
7862
8968
  const entries = readHuntHistory(configDir, huntName);
@@ -8022,7 +9128,7 @@ var import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
8022
9128
  var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
8023
9129
 
8024
9130
  // src/mcp/tools.ts
8025
- var import_node_path23 = __toESM(require("path"), 1);
9131
+ var import_node_path24 = __toESM(require("path"), 1);
8026
9132
  init_loader();
8027
9133
  function listHuntsTool(configPath) {
8028
9134
  const { configDir } = loadConfig(configPath);
@@ -8030,7 +9136,7 @@ function listHuntsTool(configPath) {
8030
9136
  }
8031
9137
  async function runSuiteTool(args = {}, options = {}) {
8032
9138
  const { configPath: resolvedConfigPath, configDir, config } = loadConfig(options.configPath);
8033
- const projectRoot = options.projectRoot ?? import_node_path23.default.dirname(configDir);
9139
+ const projectRoot = options.projectRoot ?? import_node_path24.default.dirname(configDir);
8034
9140
  const suite = await runSuite({
8035
9141
  configPath: resolvedConfigPath,
8036
9142
  includeTags: args.includeTags,
@@ -8039,8 +9145,8 @@ async function runSuiteTool(args = {}, options = {}) {
8039
9145
  });
8040
9146
  const bugLogCfg = config.bugLog ?? {};
8041
9147
  const logBugs = args.logBugs ?? bugLogCfg.enabled ?? true;
8042
- const backlogPath = bugLogCfg.backlogPath ? import_node_path23.default.resolve(projectRoot, bugLogCfg.backlogPath) : void 0;
8043
- const resolvedPath = bugLogCfg.resolvedPath ? import_node_path23.default.resolve(projectRoot, bugLogCfg.resolvedPath) : backlogPath ? import_node_path23.default.join(import_node_path23.default.dirname(backlogPath), "resolved.md") : void 0;
9148
+ const backlogPath = bugLogCfg.backlogPath ? import_node_path24.default.resolve(projectRoot, bugLogCfg.backlogPath) : void 0;
9149
+ const resolvedPath = bugLogCfg.resolvedPath ? import_node_path24.default.resolve(projectRoot, bugLogCfg.resolvedPath) : backlogPath ? import_node_path24.default.join(import_node_path24.default.dirname(backlogPath), "resolved.md") : void 0;
8044
9150
  const bugs = logBugs ? updateBacklogFromSuite(suite, { projectRoot, backlogPath, resolvedPath }) : { created: [], regressions: [], skipped: [], backlogPath: null };
8045
9151
  const { status, totalHunts, passed, failed, skipped } = suite.result;
8046
9152
  return {
@@ -8068,8 +9174,8 @@ async function runHuntTool(args, configPath) {
8068
9174
 
8069
9175
  // src/mcp/projects.ts
8070
9176
  var import_node_fs21 = __toESM(require("fs"), 1);
8071
- var import_node_os3 = __toESM(require("os"), 1);
8072
- var import_node_path24 = __toESM(require("path"), 1);
9177
+ var import_node_os5 = __toESM(require("os"), 1);
9178
+ var import_node_path25 = __toESM(require("path"), 1);
8073
9179
  var import_yaml3 = __toESM(require("yaml"), 1);
8074
9180
  var import_zod2 = require("zod");
8075
9181
  var projectEntrySchema = import_zod2.z.object({
@@ -8081,25 +9187,25 @@ var projectRegistrySchema = import_zod2.z.object({
8081
9187
  projects: import_zod2.z.record(import_zod2.z.string().min(1), projectEntrySchema)
8082
9188
  }).strict();
8083
9189
  function defaultRegistryPath() {
8084
- return import_node_path24.default.join(import_node_os3.default.homedir(), ".prowl", "projects.yml");
9190
+ return import_node_path25.default.join(import_node_os5.default.homedir(), ".prowl", "projects.yml");
8085
9191
  }
8086
9192
  function legacyRegistryPath() {
8087
- return import_node_path24.default.join(import_node_os3.default.homedir(), ".prowlqa", "projects.yml");
9193
+ return import_node_path25.default.join(import_node_os5.default.homedir(), ".prowlqa", "projects.yml");
8088
9194
  }
8089
9195
  function resolveProjectConfigPath(root) {
8090
- const preferred = import_node_path24.default.join(root, ".prowl", "config.yml");
9196
+ const preferred = import_node_path25.default.join(root, ".prowl", "config.yml");
8091
9197
  if (import_node_fs21.default.existsSync(preferred)) return preferred;
8092
- const legacy = import_node_path24.default.join(root, ".prowlqa", "config.yml");
9198
+ const legacy = import_node_path25.default.join(root, ".prowlqa", "config.yml");
8093
9199
  if (import_node_fs21.default.existsSync(legacy)) return legacy;
8094
9200
  return preferred;
8095
9201
  }
8096
9202
  function resolveRegistryRelativePath(registry, inputPath) {
8097
- return import_node_path24.default.isAbsolute(inputPath) ? inputPath : import_node_path24.default.resolve(import_node_path24.default.dirname(registry.registryPath), inputPath);
9203
+ return import_node_path25.default.isAbsolute(inputPath) ? inputPath : import_node_path25.default.resolve(import_node_path25.default.dirname(registry.registryPath), inputPath);
8098
9204
  }
8099
9205
  function resolveRegistryPath(explicitPath) {
8100
- if (explicitPath) return import_node_path24.default.resolve(explicitPath);
9206
+ if (explicitPath) return import_node_path25.default.resolve(explicitPath);
8101
9207
  const envPath = process.env.PROWL_PROJECTS ?? process.env.PROWLQA_PROJECTS;
8102
- if (envPath) return import_node_path24.default.resolve(envPath);
9208
+ if (envPath) return import_node_path25.default.resolve(envPath);
8103
9209
  const fallback = defaultRegistryPath();
8104
9210
  if (import_node_fs21.default.existsSync(fallback)) return fallback;
8105
9211
  const legacy = legacyRegistryPath();
@@ -8246,10 +9352,377 @@ function buildMcpCommand() {
8246
9352
  });
8247
9353
  }
8248
9354
 
9355
+ // src/cli/commands/macdriver.ts
9356
+ var import_chalk15 = __toESM(require("chalk"), 1);
9357
+ var import_commander13 = require("commander");
9358
+
9359
+ // src/browser/macdriver-install.ts
9360
+ var import_node_child_process7 = require("child_process");
9361
+ var import_node_crypto3 = require("crypto");
9362
+ var import_node_fs22 = __toESM(require("fs"), 1);
9363
+ var import_node_os6 = __toESM(require("os"), 1);
9364
+ var import_node_path26 = __toESM(require("path"), 1);
9365
+ var import_node_util = require("util");
9366
+ var execFileAsync = (0, import_node_util.promisify)(import_node_child_process7.execFile);
9367
+ function parseChecksumFile(text) {
9368
+ const token = text.trim().split(/\s+/, 1)[0] ?? "";
9369
+ const digest = token.toLowerCase();
9370
+ if (!/^[0-9a-f]{64}$/.test(digest)) {
9371
+ throw new Error(`Malformed .sha256 checksum file (expected a 64-char hex digest, got: ${text.trim().slice(0, 80)})`);
9372
+ }
9373
+ return digest;
9374
+ }
9375
+ function sha256Hex(bytes) {
9376
+ return (0, import_node_crypto3.createHash)("sha256").update(bytes).digest("hex");
9377
+ }
9378
+ var commandRunner = async (file, args) => execFileAsync(file, args);
9379
+ function commandOutput(result) {
9380
+ return [result.stdout, result.stderr].filter((value) => value !== void 0).map((value) => value.toString()).join("\n").trim();
9381
+ }
9382
+ function commandErrorDetail(error) {
9383
+ const err = error;
9384
+ return [err.stderr, err.stdout, err.message].filter((value) => value !== void 0 && value !== "").map((value) => value.toString()).join("\n").trim();
9385
+ }
9386
+ async function runRequiredCommand(run, binaryPath, command, args, label) {
9387
+ try {
9388
+ return await run(command, args);
9389
+ } catch (error) {
9390
+ const detail = commandErrorDetail(error);
9391
+ throw new Error(`${label} failed for ${binaryPath}${detail ? `: ${detail}` : ""}`);
9392
+ }
9393
+ }
9394
+ var zipinfoArchiveLister = async (zipPath) => {
9395
+ try {
9396
+ const { stdout } = await execFileAsync("zipinfo", ["-1", zipPath]);
9397
+ return stdout.toString().split(/\r?\n/).filter((entry) => entry.length > 0);
9398
+ } catch (error) {
9399
+ const detail = commandErrorDetail(error);
9400
+ throw new Error(`Failed to inspect release archive ${zipPath} with zipinfo${detail ? `: ${detail}` : ""}`);
9401
+ }
9402
+ };
9403
+ function normalizeArchiveEntryName(entry) {
9404
+ if (entry.length === 0 || entry !== entry.trim() || entry.includes("\0") || entry.includes("\\")) {
9405
+ throw new Error(`Unsafe path in prowl-macdriver release archive: ${JSON.stringify(entry)}`);
9406
+ }
9407
+ if (entry.endsWith("/")) {
9408
+ throw new Error(`Unexpected directory in prowl-macdriver release archive: ${entry}`);
9409
+ }
9410
+ if (import_node_path26.default.posix.isAbsolute(entry)) {
9411
+ throw new Error(`Unsafe absolute path in prowl-macdriver release archive: ${entry}`);
9412
+ }
9413
+ const parts = entry.split("/");
9414
+ if (parts.some((part) => part === "" || part === "." || part === "..")) {
9415
+ throw new Error(`Unsafe path in prowl-macdriver release archive: ${entry}`);
9416
+ }
9417
+ return entry;
9418
+ }
9419
+ function validateMacdriverArchiveEntries(entries) {
9420
+ const normalized = entries.map(normalizeArchiveEntryName);
9421
+ if (normalized.length !== 1 || normalized[0] !== HELPER_BINARY) {
9422
+ const shown = normalized.length > 0 ? normalized.join(", ") : "(empty archive)";
9423
+ throw new Error(
9424
+ `Unexpected prowl-macdriver release archive contents: ${shown}. Expected exactly "${HELPER_BINARY}" at the archive root.`
9425
+ );
9426
+ }
9427
+ }
9428
+ var dittoExtractor = async (zipPath, destDir) => {
9429
+ await execFileAsync("ditto", ["-x", "-k", zipPath, destDir]);
9430
+ };
9431
+ function parseCodesignDetails(text) {
9432
+ const details = { identifier: null, authorities: [], teamIdentifier: null };
9433
+ for (const line of text.split(/\r?\n/)) {
9434
+ const trimmed = line.trim();
9435
+ if (trimmed.startsWith("Identifier=")) {
9436
+ details.identifier = trimmed.slice("Identifier=".length);
9437
+ } else if (trimmed.startsWith("Authority=")) {
9438
+ details.authorities.push(trimmed.slice("Authority=".length));
9439
+ } else if (trimmed.startsWith("TeamIdentifier=")) {
9440
+ details.teamIdentifier = trimmed.slice("TeamIdentifier=".length);
9441
+ }
9442
+ }
9443
+ return details;
9444
+ }
9445
+ function validateCodesignDetails(details, binaryPath) {
9446
+ if (details.identifier !== MACDRIVER_SIGNING_IDENTIFIER) {
9447
+ throw new Error(
9448
+ `codesign verification failed for ${binaryPath}: expected identifier "${MACDRIVER_SIGNING_IDENTIFIER}", got "${details.identifier ?? "missing"}"`
9449
+ );
9450
+ }
9451
+ const developerIdAuthority = details.authorities.find(
9452
+ (authority) => authority.startsWith(`${MACDRIVER_SIGNING_AUTHORITY_PREFIX} (`)
9453
+ );
9454
+ const authorityTeamId = developerIdAuthority?.match(/\(([A-Z0-9]{10})\)$/)?.[1] ?? null;
9455
+ if (!developerIdAuthority || !authorityTeamId) {
9456
+ const shown = details.authorities.length > 0 ? details.authorities.join(" / ") : "missing";
9457
+ throw new Error(
9458
+ `codesign verification failed for ${binaryPath}: expected ${MACDRIVER_SIGNING_AUTHORITY_PREFIX} signer, got ${shown}`
9459
+ );
9460
+ }
9461
+ if (!details.teamIdentifier) {
9462
+ throw new Error(`codesign verification failed for ${binaryPath}: missing TeamIdentifier`);
9463
+ }
9464
+ if (details.teamIdentifier !== authorityTeamId) {
9465
+ throw new Error(
9466
+ `codesign verification failed for ${binaryPath}: TeamIdentifier ${details.teamIdentifier} does not match Developer ID authority team ${authorityTeamId}`
9467
+ );
9468
+ }
9469
+ }
9470
+ async function verifyMacdriverSignature(binaryPath, run = commandRunner) {
9471
+ await runRequiredCommand(run, binaryPath, "codesign", ["--verify", "--strict", binaryPath], "codesign verification");
9472
+ const display = await runRequiredCommand(
9473
+ run,
9474
+ binaryPath,
9475
+ "codesign",
9476
+ ["--display", "--verbose=4", binaryPath],
9477
+ "codesign detail inspection"
9478
+ );
9479
+ validateCodesignDetails(parseCodesignDetails(commandOutput(display)), binaryPath);
9480
+ await runRequiredCommand(
9481
+ run,
9482
+ binaryPath,
9483
+ "spctl",
9484
+ ["--assess", "--type", "execute", "--verbose=4", binaryPath],
9485
+ "spctl assessment"
9486
+ );
9487
+ }
9488
+ var codesignVerifier = async (binaryPath) => {
9489
+ await verifyMacdriverSignature(binaryPath);
9490
+ };
9491
+ async function downloadAndVerify(options = {}) {
9492
+ const version = options.version ?? MACDRIVER_VERSION;
9493
+ const fetchImpl = options.fetchImpl ?? fetch;
9494
+ const assetName = macdriverAssetName(version);
9495
+ const zipUrl = macdriverAssetUrl(assetName, version);
9496
+ const sumUrl = macdriverAssetUrl(macdriverChecksumName(version), version);
9497
+ const [zipRes, sumRes] = await Promise.all([
9498
+ fetchImpl(zipUrl, { redirect: "follow" }),
9499
+ fetchImpl(sumUrl, { redirect: "follow" })
9500
+ ]);
9501
+ if (zipRes.status === 404 || sumRes.status === 404) {
9502
+ throw new Error(
9503
+ `No published prowl-macdriver release for ${macdriverReleaseTag(version)} yet.
9504
+ The signed binary is cut by the maintainer; until then build from source:
9505
+ cd macdriver && swift build -c release`
9506
+ );
9507
+ }
9508
+ if (!zipRes.ok) {
9509
+ throw new Error(`Failed to download ${assetName} (HTTP ${zipRes.status}) from ${zipUrl}`);
9510
+ }
9511
+ if (!sumRes.ok) {
9512
+ throw new Error(`Failed to download the checksum (HTTP ${sumRes.status}) from ${sumUrl}`);
9513
+ }
9514
+ const zipBytes = Buffer.from(await zipRes.arrayBuffer());
9515
+ const expected = parseChecksumFile(await sumRes.text());
9516
+ const actual = sha256Hex(zipBytes);
9517
+ if (actual !== expected) {
9518
+ throw new Error(
9519
+ `Checksum mismatch for ${assetName}.
9520
+ expected: ${expected}
9521
+ actual: ${actual}
9522
+ The download was rejected and discarded; re-run the install, and report it if it repeats.`
9523
+ );
9524
+ }
9525
+ return zipBytes;
9526
+ }
9527
+ async function installMacdriver(options = {}) {
9528
+ const version = validateMacdriverVersion(options.version ?? MACDRIVER_VERSION);
9529
+ const homedir = options.homedir ?? import_node_os6.default.homedir();
9530
+ const listArchiveEntries = options.listArchiveEntries ?? zipinfoArchiveLister;
9531
+ const extract = options.extract ?? dittoExtractor;
9532
+ const verifySignature = options.verifySignature ?? codesignVerifier;
9533
+ const installRoot = macdriverInstallRoot(homedir);
9534
+ const versionDir = macdriverVersionDir(version, homedir);
9535
+ const binaryPath = macdriverInstalledBinary(version, homedir);
9536
+ if (!options.force && import_node_fs22.default.existsSync(binaryPath)) {
9537
+ return { version, binaryPath, alreadyInstalled: true };
9538
+ }
9539
+ const zipBytes = await downloadAndVerify({ version, fetchImpl: options.fetchImpl });
9540
+ import_node_fs22.default.mkdirSync(installRoot, { recursive: true });
9541
+ const stagingDir = import_node_fs22.default.mkdtempSync(import_node_path26.default.join(installRoot, `.tmp-${version}-`));
9542
+ const extractDir = import_node_path26.default.join(stagingDir, "extract");
9543
+ const zipPath = import_node_path26.default.join(stagingDir, macdriverAssetName(version));
9544
+ try {
9545
+ import_node_fs22.default.mkdirSync(extractDir);
9546
+ import_node_fs22.default.writeFileSync(zipPath, zipBytes);
9547
+ validateMacdriverArchiveEntries(await listArchiveEntries(zipPath));
9548
+ await extract(zipPath, extractDir);
9549
+ const stagedBinaryPath = import_node_path26.default.join(extractDir, HELPER_BINARY);
9550
+ assertExtractedHelper(extractDir, stagedBinaryPath);
9551
+ import_node_fs22.default.chmodSync(stagedBinaryPath, 493);
9552
+ await verifySignature(stagedBinaryPath);
9553
+ replaceVersionDir(versionDir, extractDir, installRoot);
9554
+ } finally {
9555
+ import_node_fs22.default.rmSync(stagingDir, { recursive: true, force: true });
9556
+ }
9557
+ return { version, binaryPath, alreadyInstalled: false };
9558
+ }
9559
+ function assertExtractedHelper(extractDir, binaryPath) {
9560
+ const entries = import_node_fs22.default.readdirSync(extractDir);
9561
+ if (!entries.includes(HELPER_BINARY)) {
9562
+ throw new Error(`The release archive did not contain a "${HELPER_BINARY}" binary.`);
9563
+ }
9564
+ if (entries.length !== 1 || entries[0] !== HELPER_BINARY) {
9565
+ const shown = entries.length > 0 ? entries.join(", ") : "(empty directory)";
9566
+ throw new Error(
9567
+ `Unexpected extracted prowl-macdriver archive contents: ${shown}. Expected exactly "${HELPER_BINARY}".`
9568
+ );
9569
+ }
9570
+ let stat = null;
9571
+ try {
9572
+ stat = import_node_fs22.default.lstatSync(binaryPath);
9573
+ } catch {
9574
+ }
9575
+ if (!stat?.isFile()) {
9576
+ throw new Error(`The release archive "${HELPER_BINARY}" entry is not a regular file.`);
9577
+ }
9578
+ }
9579
+ function replaceVersionDir(versionDir, stagedVersionDir, installRoot) {
9580
+ const backupDir = import_node_path26.default.join(installRoot, `.previous-${import_node_path26.default.basename(versionDir)}-${process.pid}-${Date.now()}`);
9581
+ let backedUp = false;
9582
+ try {
9583
+ if (import_node_fs22.default.existsSync(versionDir)) {
9584
+ import_node_fs22.default.renameSync(versionDir, backupDir);
9585
+ backedUp = true;
9586
+ }
9587
+ import_node_fs22.default.renameSync(stagedVersionDir, versionDir);
9588
+ if (backedUp) {
9589
+ import_node_fs22.default.rmSync(backupDir, { recursive: true, force: true });
9590
+ }
9591
+ } catch (error) {
9592
+ if (backedUp && !import_node_fs22.default.existsSync(versionDir) && import_node_fs22.default.existsSync(backupDir)) {
9593
+ import_node_fs22.default.renameSync(backupDir, versionDir);
9594
+ }
9595
+ throw error;
9596
+ }
9597
+ }
9598
+ var runVersionProbe = async (binaryPath) => {
9599
+ try {
9600
+ const { stdout } = await execFileAsync(binaryPath, ["version"], { timeout: 5e3 });
9601
+ const match = stdout.match(/prowl-macdriver\s+(\S+)/);
9602
+ return match ? match[1] : stdout.trim() || null;
9603
+ } catch {
9604
+ return null;
9605
+ }
9606
+ };
9607
+ async function collectMacdriverStatus(options = {}) {
9608
+ const env = options.env ?? process.env;
9609
+ const homedir = options.homedir ?? import_node_os6.default.homedir();
9610
+ const probe = options.probe ?? runVersionProbe;
9611
+ let resolved = null;
9612
+ try {
9613
+ const resolvedPath = resolveHelperBinary(env, { homedir });
9614
+ resolved = { path: resolvedPath, source: classifyResolvedSource(resolvedPath, env, homedir) };
9615
+ } catch {
9616
+ resolved = null;
9617
+ }
9618
+ const installed = [];
9619
+ const root = macdriverInstallRoot(homedir);
9620
+ if (import_node_fs22.default.existsSync(root)) {
9621
+ for (const entry of import_node_fs22.default.readdirSync(root, { withFileTypes: true })) {
9622
+ if (!entry.isDirectory()) continue;
9623
+ let binaryPath;
9624
+ try {
9625
+ binaryPath = macdriverInstalledBinary(entry.name, homedir);
9626
+ } catch {
9627
+ continue;
9628
+ }
9629
+ if (import_node_fs22.default.existsSync(binaryPath)) {
9630
+ installed.push({ version: entry.name, binaryPath });
9631
+ }
9632
+ }
9633
+ installed.sort((a, b) => a.version.localeCompare(b.version));
9634
+ }
9635
+ const probedVersion = resolved ? await probe(resolved.path) : null;
9636
+ return { resolved, pinnedVersion: MACDRIVER_VERSION, installed, probedVersion };
9637
+ }
9638
+ function classifyResolvedSource(resolvedPath, env, homedir) {
9639
+ if (env.PROWL_MACDRIVER_BIN && resolvedPath === env.PROWL_MACDRIVER_BIN) {
9640
+ return "env";
9641
+ }
9642
+ if (resolvedPath.startsWith(macdriverInstallRoot(homedir) + import_node_path26.default.sep)) {
9643
+ return "user-install";
9644
+ }
9645
+ return "source-build";
9646
+ }
9647
+ function tccGuidance() {
9648
+ return "macOS permissions: the app that hosts Prowl (your terminal \u2014 Terminal, iTerm, VS Code, \u2026)\nmust be granted, in System Settings \u2192 Privacy & Security:\n \u2022 Accessibility \u2014 required to drive the target app\n \u2022 Screen Recording \u2014 required for screenshots / visual baselines\nGrant both to the terminal app, not to prowl-macdriver itself, then re-run your hunt.";
9649
+ }
9650
+
9651
+ // src/cli/commands/macdriver.ts
9652
+ function buildInstallCommand() {
9653
+ return new import_commander13.Command("install").description("Download and install the prebuilt, signed prowl-macdriver helper").option("--force", "Reinstall even if the pinned version is already present").action(async (options) => {
9654
+ try {
9655
+ console.log(
9656
+ import_chalk15.default.cyan(
9657
+ `Installing prowl-macdriver ${MACDRIVER_VERSION} (${macdriverReleaseTag(MACDRIVER_VERSION)})\u2026`
9658
+ )
9659
+ );
9660
+ const result = await installMacdriver({ force: options.force });
9661
+ if (result.alreadyInstalled) {
9662
+ console.log(
9663
+ import_chalk15.default.green(`Already installed at ${result.binaryPath}`) + import_chalk15.default.dim(" (use --force to reinstall)")
9664
+ );
9665
+ } else {
9666
+ console.log(import_chalk15.default.green(`Installed prowl-macdriver ${result.version} \u2192 ${result.binaryPath}`));
9667
+ }
9668
+ console.log("");
9669
+ console.log(tccGuidance());
9670
+ } catch (error) {
9671
+ const message = error instanceof Error ? error.message : "Install failed";
9672
+ console.error(import_chalk15.default.red(`Error: ${message}`));
9673
+ process.exitCode = 1;
9674
+ }
9675
+ });
9676
+ }
9677
+ function buildStatusCommand() {
9678
+ return new import_commander13.Command("status").description("Report the resolved prowl-macdriver binary, installed versions, and permissions guidance").action(async () => {
9679
+ try {
9680
+ const status = await collectMacdriverStatus();
9681
+ console.log(import_chalk15.default.bold("prowl-macdriver status"));
9682
+ console.log(` pinned version: ${status.pinnedVersion}`);
9683
+ if (status.resolved) {
9684
+ const label = status.resolved.source === "env" ? "PROWL_MACDRIVER_BIN override" : status.resolved.source === "user-install" ? "user install (~/.prowl/macdriver)" : "repo source build (macdriver/.build)";
9685
+ console.log(` resolved binary: ${status.resolved.path}`);
9686
+ console.log(` resolved via: ${label}`);
9687
+ console.log(
9688
+ ` runs: ${status.probedVersion ? import_chalk15.default.green(`yes (reports ${status.probedVersion})`) : import_chalk15.default.yellow("no (binary did not respond to `version`)")}`
9689
+ );
9690
+ } else {
9691
+ console.log(` resolved binary: ${import_chalk15.default.yellow("none")}`);
9692
+ console.log(import_chalk15.default.dim(" search order: PROWL_MACDRIVER_BIN \u2192 ~/.prowl/macdriver \u2192 macdriver/.build"));
9693
+ console.log(import_chalk15.default.cyan(" Run `prowl macdriver install` to install the prebuilt helper."));
9694
+ }
9695
+ if (status.installed.length > 0) {
9696
+ console.log(" installed versions:");
9697
+ for (const entry of status.installed) {
9698
+ const marker = entry.version === status.pinnedVersion ? import_chalk15.default.green(" (pinned)") : "";
9699
+ console.log(` - ${entry.version}${marker} ${import_chalk15.default.dim(entry.binaryPath)}`);
9700
+ }
9701
+ } else {
9702
+ console.log(" installed versions: none");
9703
+ }
9704
+ console.log("");
9705
+ console.log(tccGuidance());
9706
+ } catch (error) {
9707
+ const message = error instanceof Error ? error.message : "Status failed";
9708
+ console.error(import_chalk15.default.red(`Error: ${message}`));
9709
+ process.exitCode = 1;
9710
+ }
9711
+ });
9712
+ }
9713
+ function buildMacdriverCommand() {
9714
+ const command = new import_commander13.Command("macdriver").description(
9715
+ "Manage the macOS helper binary (prowl-macdriver) for the macOS target"
9716
+ );
9717
+ command.addCommand(buildInstallCommand());
9718
+ command.addCommand(buildStatusCommand());
9719
+ return command;
9720
+ }
9721
+
8249
9722
  // src/cli/program.ts
8250
9723
  var CLI_VERSION = package_default.version;
8251
9724
  function buildProgram() {
8252
- const program2 = new import_commander13.Command();
9725
+ const program2 = new import_commander14.Command();
8253
9726
  program2.name("prowl").description("CLI-first QA testing tool for deterministic Playwright flows").version(CLI_VERSION);
8254
9727
  program2.addCommand(buildRunCommand());
8255
9728
  program2.addCommand(buildCiCommand());
@@ -8263,6 +9736,7 @@ function buildProgram() {
8263
9736
  program2.addCommand(buildHistoryCommand());
8264
9737
  program2.addCommand(buildFlakyCommand());
8265
9738
  program2.addCommand(buildMcpCommand());
9739
+ program2.addCommand(buildMacdriverCommand());
8266
9740
  return program2;
8267
9741
  }
8268
9742