prowl-tools 0.1.5 → 0.1.6

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
@@ -59,7 +59,7 @@ var init_hunt_name = __esm({
59
59
  });
60
60
 
61
61
  // 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;
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, assertWithAiStepSchema, copyTextStepSchema, waitForDownloadStepSchema, stepSchema, assertionSchema, huntSchema;
63
63
  var init_schema = __esm({
64
64
  "src/config/schema.ts"() {
65
65
  "use strict";
@@ -311,6 +311,9 @@ var init_schema = __esm({
311
311
  threshold: import_zod.z.number().min(0).max(1).optional()
312
312
  }).strict()
313
313
  }).strict();
314
+ assertWithAiStepSchema = import_zod.z.object({
315
+ assertWithAI: import_zod.z.string().min(1)
316
+ }).strict();
314
317
  copyTextStepSchema = import_zod.z.object({
315
318
  copyText: import_zod.z.object({
316
319
  selector: import_zod.z.string().min(1),
@@ -353,6 +356,7 @@ var init_schema = __esm({
353
356
  evalScriptStepSchema,
354
357
  runScriptStepSchema,
355
358
  assertScreenshotStepSchema,
359
+ assertWithAiStepSchema,
356
360
  copyTextStepSchema,
357
361
  waitForDownloadStepSchema
358
362
  ]);
@@ -716,7 +720,7 @@ var import_commander13 = require("commander");
716
720
  // package.json
717
721
  var package_default = {
718
722
  name: "prowl-tools",
719
- version: "0.1.5",
723
+ version: "0.1.6",
720
724
  description: "CLI-first QA testing tool for deterministic Playwright flows.",
721
725
  type: "module",
722
726
  license: "Apache-2.0",
@@ -1105,6 +1109,11 @@ function interpolateStep(step, vars, stepPath2, redacted) {
1105
1109
  }
1106
1110
  };
1107
1111
  }
1112
+ if ("assertWithAI" in step) {
1113
+ return {
1114
+ assertWithAI: interpolateString(step.assertWithAI, vars).value
1115
+ };
1116
+ }
1108
1117
  if ("copyText" in step) {
1109
1118
  return {
1110
1119
  copyText: {
@@ -1987,6 +1996,213 @@ var import_node_path5 = __toESM(require("path"), 1);
1987
1996
 
1988
1997
  // src/browser/android-driver.ts
1989
1998
  var import_node_fs5 = __toESM(require("fs"), 1);
1999
+
2000
+ // src/analyzer/xml.ts
2001
+ var ENTITIES = {
2002
+ amp: "&",
2003
+ lt: "<",
2004
+ gt: ">",
2005
+ quot: '"',
2006
+ apos: "'"
2007
+ };
2008
+ function decodeXmlEntities(value) {
2009
+ if (!value.includes("&")) {
2010
+ return value;
2011
+ }
2012
+ return value.replace(/&(#(?:[xX][0-9a-fA-F]+|[0-9]+)|[a-zA-Z]+);/g, (match, code) => {
2013
+ if (code[0] === "#") {
2014
+ const hex = code[1] === "x" || code[1] === "X";
2015
+ const num2 = Number.parseInt(code.slice(hex ? 2 : 1), hex ? 16 : 10);
2016
+ return Number.isSafeInteger(num2) && num2 <= 1114111 ? String.fromCodePoint(num2) : match;
2017
+ }
2018
+ const named = ENTITIES[code];
2019
+ return named ?? match;
2020
+ });
2021
+ }
2022
+ var ATTR_RE = /([^\s=/]+)\s*=\s*("([^"]*)"|'([^']*)')/g;
2023
+ function parseTagBody(body) {
2024
+ const trimmed = body.trim();
2025
+ const nameMatch = /^([^\s/>]+)/.exec(trimmed);
2026
+ const tag = nameMatch ? nameMatch[1] : "";
2027
+ const attrs = {};
2028
+ const rest = trimmed.slice(tag.length);
2029
+ ATTR_RE.lastIndex = 0;
2030
+ let m;
2031
+ while ((m = ATTR_RE.exec(rest)) !== null) {
2032
+ const rawValue = m[3] !== void 0 ? m[3] : m[4] ?? "";
2033
+ attrs[m[1]] = decodeXmlEntities(rawValue);
2034
+ }
2035
+ return { tag, attrs };
2036
+ }
2037
+ function parseXml(input) {
2038
+ const n = input.length;
2039
+ const stack = [];
2040
+ let root = null;
2041
+ let i = 0;
2042
+ while (i < n) {
2043
+ const lt = input.indexOf("<", i);
2044
+ if (lt < 0) {
2045
+ break;
2046
+ }
2047
+ i = lt + 1;
2048
+ const ch = input[i];
2049
+ if (ch === "?") {
2050
+ const end = input.indexOf("?>", i);
2051
+ i = end < 0 ? n : end + 2;
2052
+ continue;
2053
+ }
2054
+ if (ch === "!") {
2055
+ if (input.startsWith("!--", i)) {
2056
+ const end = input.indexOf("-->", i);
2057
+ i = end < 0 ? n : end + 3;
2058
+ } else {
2059
+ const end = input.indexOf(">", i);
2060
+ i = end < 0 ? n : end + 1;
2061
+ }
2062
+ continue;
2063
+ }
2064
+ if (ch === "/") {
2065
+ const gt = input.indexOf(">", i);
2066
+ i = gt < 0 ? n : gt + 1;
2067
+ stack.pop();
2068
+ continue;
2069
+ }
2070
+ let j = i;
2071
+ let quote2 = null;
2072
+ while (j < n) {
2073
+ const c = input[j];
2074
+ if (quote2 !== null) {
2075
+ if (c === quote2) {
2076
+ quote2 = null;
2077
+ }
2078
+ } else if (c === '"' || c === "'") {
2079
+ quote2 = c;
2080
+ } else if (c === ">") {
2081
+ break;
2082
+ }
2083
+ j += 1;
2084
+ }
2085
+ const inner = input.slice(i, j);
2086
+ i = j + 1;
2087
+ const selfClose = inner.endsWith("/");
2088
+ const body = selfClose ? inner.slice(0, -1) : inner;
2089
+ const { tag, attrs } = parseTagBody(body);
2090
+ if (tag.length === 0) {
2091
+ continue;
2092
+ }
2093
+ const element = { tag, attrs, children: [] };
2094
+ const parent = stack[stack.length - 1];
2095
+ if (parent) {
2096
+ parent.children.push(element);
2097
+ }
2098
+ if (root === null) {
2099
+ root = element;
2100
+ }
2101
+ if (!selfClose) {
2102
+ stack.push(element);
2103
+ }
2104
+ }
2105
+ return root;
2106
+ }
2107
+
2108
+ // src/selector/native.ts
2109
+ function unquoteSelectorValue(value) {
2110
+ const trimmed = value.trim();
2111
+ const first = trimmed[0];
2112
+ if ((first === '"' || first === "'") && trimmed.endsWith(first) && trimmed.length >= 2) {
2113
+ return trimmed.slice(1, -1);
2114
+ }
2115
+ return trimmed;
2116
+ }
2117
+ function quoteSelectorValue(value) {
2118
+ return `"${value}"`;
2119
+ }
2120
+ var ROLE_SELECTOR_RE = /^role=([A-Za-z][\w.$-]*)(?:\[name=(.+)\])?$/s;
2121
+ var NATIVE_SELECTOR_PREFIX_RE = /^(id|label|text|role)=/;
2122
+ function invalidSelectorMessage(selector, reason) {
2123
+ return `Invalid native selector ${JSON.stringify(selector)}: ${reason}.`;
2124
+ }
2125
+ function parseNativeSelector(selector) {
2126
+ const trimmed = selector.trim();
2127
+ if (trimmed.length === 0) {
2128
+ throw new Error(
2129
+ invalidSelectorMessage(
2130
+ selector,
2131
+ "selector is empty; use id=, label=, text=, role=, :focus, or a bare text value"
2132
+ )
2133
+ );
2134
+ }
2135
+ if (trimmed === ":focus") {
2136
+ return { kind: "focused" };
2137
+ }
2138
+ const idMatch = /^id=(.+)$/s.exec(trimmed);
2139
+ if (idMatch) {
2140
+ return { kind: "id", value: unquoteSelectorValue(idMatch[1]) };
2141
+ }
2142
+ const roleMatch = ROLE_SELECTOR_RE.exec(trimmed);
2143
+ if (roleMatch) {
2144
+ const name = roleMatch[2] !== void 0 ? unquoteSelectorValue(roleMatch[2]) : void 0;
2145
+ return name !== void 0 && name.length > 0 ? { kind: "role", role: roleMatch[1], name } : { kind: "role", role: roleMatch[1] };
2146
+ }
2147
+ const labelMatch = /^label=(.+)$/s.exec(trimmed);
2148
+ if (labelMatch) {
2149
+ return { kind: "label", value: unquoteSelectorValue(labelMatch[1]) };
2150
+ }
2151
+ const textMatch = /^text=(.+)$/s.exec(trimmed);
2152
+ if (textMatch) {
2153
+ return { kind: "text", value: unquoteSelectorValue(textMatch[1]) };
2154
+ }
2155
+ const prefix = NATIVE_SELECTOR_PREFIX_RE.exec(trimmed);
2156
+ if (prefix) {
2157
+ throw new Error(
2158
+ invalidSelectorMessage(
2159
+ selector,
2160
+ `malformed ${prefix[1]}= selector; expected id=<value>, label=<value>, text=<value>, or role=<Type>[name=<value>]`
2161
+ )
2162
+ );
2163
+ }
2164
+ return { kind: "text", value: trimmed };
2165
+ }
2166
+ function unwrapNativeTextSelector(selector) {
2167
+ const trimmed = selector.trim();
2168
+ if (!trimmed.startsWith("text=")) {
2169
+ return null;
2170
+ }
2171
+ return unquoteSelectorValue(trimmed.slice("text=".length));
2172
+ }
2173
+ function qualifyResourceId(value, appPackage) {
2174
+ if (value.includes(":") || !appPackage) {
2175
+ return value;
2176
+ }
2177
+ return `${appPackage}:id/${value}`;
2178
+ }
2179
+ function normalizeXcuiClassName(role) {
2180
+ return role.startsWith("XCUIElementType") ? role : `XCUIElementType${role}`;
2181
+ }
2182
+ function shortIosType(type) {
2183
+ return type.startsWith("XCUIElementType") ? type.slice("XCUIElementType".length) : type;
2184
+ }
2185
+ function rankNativeSelectors(fields) {
2186
+ const selectors = [];
2187
+ if (fields.id) {
2188
+ selectors.push(`id=${fields.id}`);
2189
+ }
2190
+ if (fields.label) {
2191
+ selectors.push(`label=${quoteSelectorValue(fields.label)}`);
2192
+ }
2193
+ if (fields.role && fields.name) {
2194
+ selectors.push(`role=${fields.role}[name=${quoteSelectorValue(fields.name)}]`);
2195
+ }
2196
+ if (fields.name) {
2197
+ selectors.push(`text=${quoteSelectorValue(fields.name)}`);
2198
+ }
2199
+ if (selectors.length === 0 && fields.role) {
2200
+ selectors.push(`role=${fields.role}`);
2201
+ }
2202
+ return selectors;
2203
+ }
2204
+
2205
+ // src/browser/android-driver.ts
1990
2206
  var ANDROID_CAPABILITIES = /* @__PURE__ */ new Set([
1991
2207
  "query",
1992
2208
  "interact",
@@ -2020,50 +2236,29 @@ var ANDROID_KEYCODES = {
2020
2236
  pageup: 92,
2021
2237
  pagedown: 93
2022
2238
  };
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
2239
  function escapeUiSelectorArg(value) {
2032
2240
  return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
2033
2241
  }
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]) };
2242
+ function toAndroidQuery(selector) {
2243
+ switch (selector.kind) {
2244
+ case "focused":
2245
+ return { by: "focused" };
2246
+ case "id":
2247
+ return { by: "id", value: selector.value };
2248
+ case "role":
2249
+ return selector.name !== void 0 ? { by: "role", role: selector.role, name: selector.name } : { by: "role", role: selector.role };
2250
+ case "label":
2251
+ return { by: "accessibilityId", value: selector.value };
2252
+ case "text":
2253
+ return { by: "text", value: selector.value };
2055
2254
  }
2056
- return { by: "text", value: trimmed };
2255
+ }
2256
+ function parseAndroidSelector(selector) {
2257
+ return toAndroidQuery(parseNativeSelector(selector));
2057
2258
  }
2058
2259
  function locator(strategy, selector) {
2059
2260
  return { strategy, selector, context: "" };
2060
2261
  }
2061
- function qualifyResourceId(value, appPackage) {
2062
- if (value.includes(":") || !appPackage) {
2063
- return value;
2064
- }
2065
- return `${appPackage}:id/${value}`;
2066
- }
2067
2262
  function androidQueryToLocator(query, options = {}) {
2068
2263
  switch (query.by) {
2069
2264
  case "id":
@@ -2090,11 +2285,7 @@ function androidQueryToLocator(query, options = {}) {
2090
2285
  }
2091
2286
  }
2092
2287
  function unwrapAndroidTextSelector(selector) {
2093
- const trimmed = selector.trim();
2094
- if (!trimmed.startsWith("text=")) {
2095
- return null;
2096
- }
2097
- return unquote2(trimmed.slice(5));
2288
+ return unwrapNativeTextSelector(selector);
2098
2289
  }
2099
2290
  function keyCodeFor(key) {
2100
2291
  const code = ANDROID_KEYCODES[key.trim().toLowerCase()];
@@ -2369,22 +2560,63 @@ async function installApk(runner, serial, apkPath) {
2369
2560
  );
2370
2561
  }
2371
2562
  }
2563
+ function formatUnknownError(error) {
2564
+ return error instanceof Error ? error.message : String(error);
2565
+ }
2566
+ function runnerPhaseError(message, error) {
2567
+ return new Error(`${message}: ${formatUnknownError(error)}`, { cause: error });
2568
+ }
2569
+ function parseResolvedComponent(stdout, pkg) {
2570
+ const lines = stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
2571
+ for (let i = lines.length - 1; i >= 0; i--) {
2572
+ if (/^[\w.]+\/[\w.$]+$/.test(lines[i]) && lines[i].startsWith(`${pkg}/`)) {
2573
+ return lines[i];
2574
+ }
2575
+ }
2576
+ return null;
2577
+ }
2372
2578
  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)) {
2579
+ let resolved;
2580
+ try {
2581
+ resolved = await runner(
2582
+ withSerial(serial, [
2583
+ "shell",
2584
+ "cmd",
2585
+ "package",
2586
+ "resolve-activity",
2587
+ "--brief",
2588
+ "-c",
2589
+ "android.intent.category.LAUNCHER",
2590
+ pkg
2591
+ ]),
2592
+ { timeoutMs: 3e4 }
2593
+ );
2594
+ } catch (error) {
2595
+ throw runnerPhaseError(
2596
+ `Failed to resolve the launcher activity for Android package "${pkg}" via adb`,
2597
+ error
2598
+ );
2599
+ }
2600
+ const component = parseResolvedComponent(resolved.stdout, pkg);
2601
+ if (resolved.code !== 0 || !component) {
2602
+ throw new Error(
2603
+ `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?`
2604
+ );
2605
+ }
2606
+ let start;
2607
+ try {
2608
+ start = await runner(withSerial(serial, ["shell", "am", "start", "-n", component]), {
2609
+ timeoutMs: 3e4
2610
+ });
2611
+ } catch (error) {
2612
+ throw runnerPhaseError(
2613
+ `Failed to launch Android package "${pkg}" with \`am start\` (${component}) via adb`,
2614
+ error
2615
+ );
2616
+ }
2617
+ if (start.code !== 0 || /error|does not exist|cannot start/i.test(start.stdout + start.stderr)) {
2386
2618
  throw new Error(
2387
- `Failed to launch Android package "${pkg}" (exit ${result.code}): ${(result.stdout + result.stderr).trim().slice(0, 400)}. Is it installed?`
2619
+ `Failed to launch Android package "${pkg}" (${component}, exit ${start.code}): ${(start.stdout + start.stderr).trim().slice(0, 400)}.`
2388
2620
  );
2389
2621
  }
2390
2622
  }
@@ -2602,6 +2834,13 @@ function createUia2AgentClient(transport, sessionId, options = {}) {
2602
2834
  }
2603
2835
  return Buffer.from(value, "base64");
2604
2836
  },
2837
+ async source() {
2838
+ const value = await transport.request("GET", `${base}/source`);
2839
+ if (typeof value !== "string") {
2840
+ throw new Error("uiautomator2 /source did not return XML text; cannot analyze Android UI hierarchy");
2841
+ }
2842
+ return value;
2843
+ },
2605
2844
  async close() {
2606
2845
  await transport.request("DELETE", base).catch(() => void 0);
2607
2846
  }
@@ -2745,6 +2984,7 @@ async function closeAndroidSession(session) {
2745
2984
 
2746
2985
  // src/browser/ios-helper.ts
2747
2986
  var import_node_module2 = require("module");
2987
+ var import_node_child_process6 = require("child_process");
2748
2988
  var import_node_fs7 = __toESM(require("fs"), 1);
2749
2989
  var import_node_os2 = __toESM(require("os"), 1);
2750
2990
  var import_node_path7 = __toESM(require("path"), 1);
@@ -2759,43 +2999,25 @@ var IOS_CAPABILITIES = /* @__PURE__ */ new Set([
2759
2999
  var WAIT_POLL_INTERVAL_MS2 = 250;
2760
3000
  var DEFAULT_WAIT_TIMEOUT_MS2 = 5e3;
2761
3001
  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
3002
  function escapePredicateArg(value) {
2771
3003
  return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
2772
3004
  }
2773
- function normalizeXcuiClassName(role) {
2774
- return role.startsWith("XCUIElementType") ? role : `XCUIElementType${role}`;
3005
+ function toIosQuery(selector) {
3006
+ switch (selector.kind) {
3007
+ case "focused":
3008
+ return { by: "focused" };
3009
+ case "id":
3010
+ return { by: "accessibilityId", value: selector.value };
3011
+ case "role":
3012
+ return selector.name !== void 0 ? { by: "role", role: selector.role, name: selector.name } : { by: "role", role: selector.role };
3013
+ case "label":
3014
+ return { by: "label", value: selector.value };
3015
+ case "text":
3016
+ return { by: "text", value: selector.value };
3017
+ }
2775
3018
  }
2776
3019
  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 };
3020
+ return toIosQuery(parseNativeSelector(selector));
2799
3021
  }
2800
3022
  function iosQueryToLocator(query) {
2801
3023
  switch (query.by) {
@@ -2827,11 +3049,7 @@ function iosQueryToLocator(query) {
2827
3049
  }
2828
3050
  }
2829
3051
  function unwrapIosTextSelector(selector) {
2830
- const trimmed = selector.trim();
2831
- if (!trimmed.startsWith("text=")) {
2832
- return null;
2833
- }
2834
- return unquote3(trimmed.slice(5));
3052
+ return unwrapNativeTextSelector(selector);
2835
3053
  }
2836
3054
  function delay2(ms) {
2837
3055
  return new Promise((resolve) => {
@@ -2995,6 +3213,19 @@ var import_promises = require("fs/promises");
2995
3213
  var import_node_net = __toESM(require("net"), 1);
2996
3214
  var import_node_os = __toESM(require("os"), 1);
2997
3215
  var import_node_path6 = __toESM(require("path"), 1);
3216
+ var spawnXcrunProcess = (args, options) => {
3217
+ const child = (0, import_node_child_process5.spawn)("xcrun", args, {
3218
+ stdio: "ignore",
3219
+ env: options?.env ? { ...process.env, ...options.env } : process.env
3220
+ });
3221
+ child.on("error", () => {
3222
+ });
3223
+ return {
3224
+ kill: () => {
3225
+ child.kill();
3226
+ }
3227
+ };
3228
+ };
2998
3229
  var DEFAULT_SIMULATOR_LOCK_ROOT = import_node_path6.default.join(import_node_os.default.tmpdir(), "prowl-ios-simulator-locks");
2999
3230
  var SIMULATOR_LOCK_OWNER_FILE = "owner.json";
3000
3231
  var execFileXcrunRunner = (args, options) => new Promise((resolve) => {
@@ -3431,6 +3662,13 @@ function createWdaAgentClient(transport, sessionId) {
3431
3662
  async homescreen() {
3432
3663
  await transport.request("POST", "/wda/homescreen", {});
3433
3664
  },
3665
+ async source() {
3666
+ const value = await transport.request("GET", "/source");
3667
+ if (typeof value !== "string") {
3668
+ throw new Error("WebDriverAgent /source did not return XML text; cannot analyze iOS UI hierarchy");
3669
+ }
3670
+ return value;
3671
+ },
3434
3672
  async close() {
3435
3673
  await transport.request("DELETE", base).catch(() => void 0);
3436
3674
  }
@@ -3440,8 +3678,8 @@ function createWdaAgentClient(transport, sessionId) {
3440
3678
  // src/browser/ios-helper.ts
3441
3679
  var import_meta3 = {};
3442
3680
  var WDA_RUNNER_BUNDLE_ID = "com.facebook.WebDriverAgentRunner.xctrunner";
3443
- var WDA_RUNNER_APP_NAME = "WebDriverAgentRunner-Runner.app";
3444
3681
  var WDA_USE_PORT_ENV = "USE_PORT";
3682
+ var PREPARED_XCTESTRUN_PREFIX = "prowl-wda-xctestrun-";
3445
3683
  var WDA_STARTUP_ATTEMPTS = 2;
3446
3684
  var defaultIosAgentConnector = async ({
3447
3685
  host,
@@ -3475,10 +3713,44 @@ function resolveWdaProject(requireFn = (0, import_node_module2.createRequire)(im
3475
3713
  function wdaCacheDir(wdaVersion, xcode, homeDir = import_node_os2.default.homedir()) {
3476
3714
  return import_node_path7.default.join(homeDir, ".prowl", "wda", `${wdaVersion}-xcode${xcode}`);
3477
3715
  }
3478
- function runnerAppPath(derivedDataPath) {
3479
- return import_node_path7.default.join(derivedDataPath, "Build", "Products", "Debug-iphonesimulator", WDA_RUNNER_APP_NAME);
3716
+ function productsDir(derivedDataPath) {
3717
+ return import_node_path7.default.join(derivedDataPath, "Build", "Products");
3480
3718
  }
3481
- async function resolveWdaRunner(options = {}) {
3719
+ function findXctestrunIn(dir) {
3720
+ let entries;
3721
+ try {
3722
+ entries = import_node_fs7.default.readdirSync(dir);
3723
+ } catch {
3724
+ return null;
3725
+ }
3726
+ const match = entries.filter((name) => name.endsWith(".xctestrun") && !name.startsWith(PREPARED_XCTESTRUN_PREFIX)).sort()[0];
3727
+ return match ? import_node_path7.default.join(dir, match) : null;
3728
+ }
3729
+ function resolveOverrideXctestrun(override) {
3730
+ if (!import_node_fs7.default.existsSync(override)) {
3731
+ throw new Error(`PROWL_WDA_RUNNER points at a missing path: ${override}`);
3732
+ }
3733
+ if (override.endsWith(".xctestrun")) {
3734
+ return override;
3735
+ }
3736
+ const candidates = [];
3737
+ const stat = import_node_fs7.default.statSync(override);
3738
+ if (override.endsWith(".app")) {
3739
+ candidates.push(import_node_path7.default.dirname(import_node_path7.default.dirname(override)));
3740
+ } else if (stat.isDirectory()) {
3741
+ candidates.push(override, productsDir(override));
3742
+ }
3743
+ for (const dir of candidates) {
3744
+ const found = findXctestrunIn(dir);
3745
+ if (found) {
3746
+ return found;
3747
+ }
3748
+ }
3749
+ throw new Error(
3750
+ `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\`.`
3751
+ );
3752
+ }
3753
+ async function resolveWdaTestRun(options = {}) {
3482
3754
  const runner = options.runner ?? execFileXcrunRunner;
3483
3755
  const env = options.env ?? process.env;
3484
3756
  const homeDir = options.homeDir ?? import_node_os2.default.homedir();
@@ -3486,10 +3758,7 @@ async function resolveWdaRunner(options = {}) {
3486
3758
  `));
3487
3759
  const override = env.PROWL_WDA_RUNNER;
3488
3760
  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;
3761
+ return resolveOverrideXctestrun(override);
3493
3762
  }
3494
3763
  const { projectPath, version } = resolveWdaProject(options.requireFn);
3495
3764
  const xcode = await xcodeVersion(runner);
@@ -3499,9 +3768,9 @@ async function resolveWdaRunner(options = {}) {
3499
3768
  );
3500
3769
  }
3501
3770
  const cacheDir = wdaCacheDir(version, xcode, homeDir);
3502
- const cachedRunner = runnerAppPath(cacheDir);
3503
- if (import_node_fs7.default.existsSync(cachedRunner)) {
3504
- return cachedRunner;
3771
+ const cached = findXctestrunIn(productsDir(cacheDir));
3772
+ if (cached) {
3773
+ return cached;
3505
3774
  }
3506
3775
  log(
3507
3776
  `Prowl: building WebDriverAgent for the iOS target (first run only; this can take a few minutes)\u2026`
@@ -3528,12 +3797,106 @@ async function resolveWdaRunner(options = {}) {
3528
3797
  `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
3798
  );
3530
3799
  }
3531
- if (!import_node_fs7.default.existsSync(cachedRunner)) {
3800
+ const built = findXctestrunIn(productsDir(cacheDir));
3801
+ if (!built) {
3532
3802
  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.`
3803
+ `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
3804
  );
3535
3805
  }
3536
- return cachedRunner;
3806
+ return built;
3807
+ }
3808
+ function injectUsePortIntoXctestrun(plist, port) {
3809
+ const clone = structuredClone(plist);
3810
+ let injected = 0;
3811
+ const visit = (node) => {
3812
+ if (Array.isArray(node)) {
3813
+ for (const entry of node) {
3814
+ visit(entry);
3815
+ }
3816
+ return;
3817
+ }
3818
+ if (!node || typeof node !== "object") {
3819
+ return;
3820
+ }
3821
+ const obj = node;
3822
+ const isTarget = typeof obj.TestBundlePath === "string" || typeof obj.TestHostPath === "string";
3823
+ const existingEnv = obj.EnvironmentVariables && typeof obj.EnvironmentVariables === "object" && !Array.isArray(obj.EnvironmentVariables) ? obj.EnvironmentVariables : void 0;
3824
+ if (isTarget || existingEnv) {
3825
+ const env = existingEnv ?? {};
3826
+ env[WDA_USE_PORT_ENV] = String(port);
3827
+ obj.EnvironmentVariables = env;
3828
+ injected += 1;
3829
+ }
3830
+ for (const value of Object.values(obj)) {
3831
+ visit(value);
3832
+ }
3833
+ };
3834
+ visit(clone);
3835
+ if (injected === 0) {
3836
+ throw new Error(
3837
+ "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."
3838
+ );
3839
+ }
3840
+ return clone;
3841
+ }
3842
+ function runPlutil(args) {
3843
+ return new Promise((resolve, reject) => {
3844
+ (0, import_node_child_process6.execFile)(
3845
+ "plutil",
3846
+ args,
3847
+ { encoding: "utf-8", maxBuffer: 32 * 1024 * 1024 },
3848
+ (error, stdout, stderr) => {
3849
+ if (error) {
3850
+ reject(
3851
+ new Error(
3852
+ `\`plutil ${args.join(" ")}\` failed: ${(stderr || error.message).slice(0, 400)}`
3853
+ )
3854
+ );
3855
+ return;
3856
+ }
3857
+ resolve(stdout ?? "");
3858
+ }
3859
+ );
3860
+ });
3861
+ }
3862
+ var defaultWdaTestRunPreparer = async ({ xctestrunPath, port }) => {
3863
+ const json = await runPlutil(["-convert", "json", "-o", "-", xctestrunPath]);
3864
+ let parsed;
3865
+ try {
3866
+ parsed = JSON.parse(json);
3867
+ } catch {
3868
+ throw new Error(`Could not parse the WebDriverAgent .xctestrun as JSON: ${xctestrunPath}`);
3869
+ }
3870
+ const injected = injectUsePortIntoXctestrun(parsed, port);
3871
+ const stem = `${PREPARED_XCTESTRUN_PREFIX}${port}-${process.pid}`;
3872
+ const outPath = import_node_path7.default.join(import_node_path7.default.dirname(xctestrunPath), `${stem}.xctestrun`);
3873
+ const jsonPath = import_node_path7.default.join(import_node_os2.default.tmpdir(), `${stem}.json`);
3874
+ import_node_fs7.default.writeFileSync(jsonPath, JSON.stringify(injected));
3875
+ try {
3876
+ await runPlutil(["-convert", "xml1", jsonPath, "-o", outPath]);
3877
+ } finally {
3878
+ import_node_fs7.default.rmSync(jsonPath, { force: true });
3879
+ }
3880
+ return outPath;
3881
+ };
3882
+ function cleanupPreparedTestRun(preparedPath) {
3883
+ if (!preparedPath) {
3884
+ return;
3885
+ }
3886
+ if (!import_node_path7.default.basename(preparedPath).startsWith(PREPARED_XCTESTRUN_PREFIX)) {
3887
+ return;
3888
+ }
3889
+ import_node_fs7.default.rmSync(preparedPath, { force: true });
3890
+ }
3891
+ function wdaTestRunArgs(xctestrunPath, udid) {
3892
+ return [
3893
+ "xcodebuild",
3894
+ "test-without-building",
3895
+ "-xctestrun",
3896
+ xctestrunPath,
3897
+ "-destination",
3898
+ `id=${udid}`
3899
+ ];
3537
3900
  }
3538
3901
  async function resolveBundleId(app, runner, udid, coldStart, allowedApps) {
3539
3902
  if (!looksLikeIosAppPath(app)) {
@@ -3564,6 +3927,8 @@ async function resolveBundleId(app, runner, udid, coldStart, allowedApps) {
3564
3927
  }
3565
3928
  async function launchIosSession(options) {
3566
3929
  const runner = options.runner ?? execFileXcrunRunner;
3930
+ const spawner = options.spawner ?? spawnXcrunProcess;
3931
+ const preparer = options.testRunPreparer ?? defaultWdaTestRunPreparer;
3567
3932
  const portAllocator = options.portAllocator ?? findFreePort;
3568
3933
  const connector = options.agentConnector ?? defaultIosAgentConnector;
3569
3934
  const requestTimeoutMs = Math.max(options.timeoutMs ?? 1e4, DEFAULT_WDA_REQUEST_TIMEOUT_MS) + 5e3;
@@ -3587,14 +3952,14 @@ async function launchIosSession(options) {
3587
3952
  options.coldStart ?? false,
3588
3953
  options.allowedApps ?? []
3589
3954
  );
3590
- const wdaRunnerApp = options.wdaRunnerApp ?? await resolveWdaRunner({ runner, logger: options.logger });
3591
- await installApp(runner, udid, wdaRunnerApp);
3955
+ const baseTestRun = options.wdaTestRun ?? await resolveWdaTestRun({ runner, logger: options.logger });
3592
3956
  for (let attempt = 1; attempt <= WDA_STARTUP_ATTEMPTS; attempt += 1) {
3593
3957
  let client;
3958
+ let wdaProcess;
3959
+ let preparedTestRun;
3594
3960
  let tornDown = false;
3595
- let terminateWda = false;
3596
3961
  let terminateTarget = false;
3597
- let stage = "port";
3962
+ let stage = "prepare";
3598
3963
  const teardownAttempt = async () => {
3599
3964
  if (tornDown) {
3600
3965
  return;
@@ -3603,18 +3968,18 @@ async function launchIosSession(options) {
3603
3968
  if (client) {
3604
3969
  await client.close().catch(() => void 0);
3605
3970
  }
3606
- if (terminateWda) {
3607
- await terminateApp(runner, udid, WDA_RUNNER_BUNDLE_ID);
3608
- }
3971
+ wdaProcess?.kill();
3972
+ await terminateApp(runner, udid, WDA_RUNNER_BUNDLE_ID);
3609
3973
  if (terminateTarget) {
3610
3974
  await terminateApp(runner, udid, bundleId);
3611
3975
  }
3976
+ cleanupPreparedTestRun(preparedTestRun);
3612
3977
  };
3613
3978
  try {
3614
3979
  const port = await portAllocator();
3980
+ preparedTestRun = await preparer({ xctestrunPath: baseTestRun, port });
3615
3981
  stage = "wda-launch";
3616
- terminateWda = true;
3617
- await launchApp(runner, udid, WDA_RUNNER_BUNDLE_ID, { [WDA_USE_PORT_ENV]: String(port) });
3982
+ wdaProcess = spawner(wdaTestRunArgs(preparedTestRun, udid));
3618
3983
  stage = "target-launch";
3619
3984
  terminateTarget = true;
3620
3985
  await launchApp(runner, udid, bundleId);
@@ -3793,14 +4158,254 @@ function createRunPolicy(driver, options) {
3793
4158
  return { selector: healed.selector, healedFrom: healed.healedFrom };
3794
4159
  }
3795
4160
  return {
3796
- assertWithinMaxSteps,
3797
- ensureUrlAllowed,
3798
- ensureAppAllowed,
3799
- ensureLocationAllowed,
3800
- assertAllowedSelector,
3801
- resolveActionSelector
4161
+ assertWithinMaxSteps,
4162
+ ensureUrlAllowed,
4163
+ ensureAppAllowed,
4164
+ ensureLocationAllowed,
4165
+ assertAllowedSelector,
4166
+ resolveActionSelector
4167
+ };
4168
+ }
4169
+
4170
+ // src/generator/ai.ts
4171
+ var DEFAULT_BASE_URL = {
4172
+ anthropic: "https://api.anthropic.com",
4173
+ openai: "https://api.openai.com"
4174
+ };
4175
+ var AI_REQUEST_TIMEOUT_MS = 3e4;
4176
+ function apiRoot(config) {
4177
+ return config.baseUrl ?? DEFAULT_BASE_URL[config.provider];
4178
+ }
4179
+ function defaultModelFor(provider) {
4180
+ return provider === "anthropic" ? "claude-sonnet-4-5-20250929" : "gpt-4o";
4181
+ }
4182
+ function resolveAiEnv() {
4183
+ const provider = process.env.PROWL_AI_PROVIDER ?? "anthropic";
4184
+ if (provider !== "anthropic" && provider !== "openai") {
4185
+ throw new Error(`Unsupported AI provider: ${provider}. Use "anthropic" or "openai".`);
4186
+ }
4187
+ const model = process.env.PROWL_AI_MODEL ?? defaultModelFor(provider);
4188
+ const baseUrl = normalizeBaseUrl(process.env.PROWL_AI_BASE_URL) ?? DEFAULT_BASE_URL[provider];
4189
+ return { provider, model, baseUrl, apiKey: process.env.PROWL_AI_KEY };
4190
+ }
4191
+ function normalizeBaseUrl(value) {
4192
+ if (value === void 0) return void 0;
4193
+ const trimmed = value.trim().replace(/\/+$/, "");
4194
+ return trimmed.length > 0 ? trimmed : void 0;
4195
+ }
4196
+ function resolveAiConfig() {
4197
+ const env = resolveAiEnv();
4198
+ if (!env.apiKey) {
4199
+ throw new Error(
4200
+ "PROWL_AI_KEY environment variable is required. Set it to your Anthropic or OpenAI API key."
4201
+ );
4202
+ }
4203
+ return { provider: env.provider, model: env.model, apiKey: env.apiKey, baseUrl: env.baseUrl };
4204
+ }
4205
+ function tryResolveAiConfig() {
4206
+ const env = resolveAiEnv();
4207
+ if (!env.apiKey) {
4208
+ return null;
4209
+ }
4210
+ return { provider: env.provider, model: env.model, apiKey: env.apiKey, baseUrl: env.baseUrl };
4211
+ }
4212
+ async function generateWithAi(prompt, config) {
4213
+ if (config.provider === "anthropic") {
4214
+ return generateWithAnthropic(prompt, config);
4215
+ }
4216
+ return generateWithOpenAi(prompt, config);
4217
+ }
4218
+ async function generateWithAnthropic(prompt, config) {
4219
+ const data = await postJson(
4220
+ "Anthropic",
4221
+ `${apiRoot(config)}/v1/messages`,
4222
+ anthropicHeaders(config),
4223
+ {
4224
+ model: config.model,
4225
+ max_tokens: 4096,
4226
+ messages: [
4227
+ { role: "user", content: prompt }
4228
+ ]
4229
+ }
4230
+ );
4231
+ return extractAnthropicText(data);
4232
+ }
4233
+ async function generateWithOpenAi(prompt, config) {
4234
+ const data = await postJson(
4235
+ "OpenAI",
4236
+ `${apiRoot(config)}/v1/chat/completions`,
4237
+ openAiHeaders(config),
4238
+ {
4239
+ model: config.model,
4240
+ messages: [
4241
+ { role: "user", content: prompt }
4242
+ ],
4243
+ max_tokens: 4096
4244
+ }
4245
+ );
4246
+ return extractOpenAiText(data);
4247
+ }
4248
+ function buildVisionPrompt(assertion) {
4249
+ return [
4250
+ "You are a meticulous QA reviewer. You are given a screenshot of an application",
4251
+ "and a single assertion describing what should be true about it.",
4252
+ "",
4253
+ "Assertion:",
4254
+ assertion,
4255
+ "",
4256
+ "Decide whether the assertion holds for the screenshot. Judge ONLY what is",
4257
+ "visible; do not assume behavior you cannot see. Be strict: if the assertion",
4258
+ "is not clearly satisfied, it fails.",
4259
+ "",
4260
+ "Reply with ONLY a single JSON object on one line, no markdown, no code fences:",
4261
+ '{"pass": <true|false>, "reason": "<one concise sentence explaining the verdict>"}'
4262
+ ].join("\n");
4263
+ }
4264
+ function parseVisionVerdict(raw) {
4265
+ const cleaned = stripCodeFences(raw).trim();
4266
+ const candidate = extractJsonObject(cleaned);
4267
+ if (candidate === null) {
4268
+ throw new Error(
4269
+ `Could not parse a JSON verdict from the AI response: ${truncateForError(raw)}`
4270
+ );
4271
+ }
4272
+ let parsed;
4273
+ try {
4274
+ parsed = JSON.parse(candidate);
4275
+ } catch {
4276
+ throw new Error(
4277
+ `AI verdict was not valid JSON: ${truncateForError(raw)}`
4278
+ );
4279
+ }
4280
+ if (typeof parsed !== "object" || parsed === null) {
4281
+ throw new Error(`AI verdict was not a JSON object: ${truncateForError(raw)}`);
4282
+ }
4283
+ const record = parsed;
4284
+ if (typeof record.pass !== "boolean") {
4285
+ throw new Error(
4286
+ `AI verdict is missing a boolean "pass" field: ${truncateForError(raw)}`
4287
+ );
4288
+ }
4289
+ const reason = typeof record.reason === "string" && record.reason.trim().length > 0 ? record.reason.trim() : record.pass ? "Assertion satisfied." : "Assertion not satisfied.";
4290
+ return { pass: record.pass, reason };
4291
+ }
4292
+ function stripCodeFences(text) {
4293
+ const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
4294
+ return fence ? fence[1] : text;
4295
+ }
4296
+ function extractJsonObject(text) {
4297
+ const trimmed = text.trim();
4298
+ if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
4299
+ return trimmed;
4300
+ }
4301
+ const first = trimmed.indexOf("{");
4302
+ const last = trimmed.lastIndexOf("}");
4303
+ if (first === -1 || last === -1 || last <= first) {
4304
+ return null;
4305
+ }
4306
+ return trimmed.slice(first, last + 1);
4307
+ }
4308
+ function truncateForError(text) {
4309
+ const collapsed = text.replace(/\s+/g, " ").trim();
4310
+ return collapsed.length > 200 ? `${collapsed.slice(0, 199)}\u2026` : collapsed;
4311
+ }
4312
+ async function assertWithAiVision(input, config) {
4313
+ const raw = config.provider === "anthropic" ? await visionWithAnthropic(input, config) : await visionWithOpenAi(input, config);
4314
+ return parseVisionVerdict(raw);
4315
+ }
4316
+ async function visionWithAnthropic(input, config) {
4317
+ const data = await postJson(
4318
+ "Anthropic",
4319
+ `${apiRoot(config)}/v1/messages`,
4320
+ anthropicHeaders(config),
4321
+ {
4322
+ model: config.model,
4323
+ max_tokens: 1024,
4324
+ temperature: 0,
4325
+ messages: [
4326
+ {
4327
+ role: "user",
4328
+ content: [
4329
+ {
4330
+ type: "image",
4331
+ source: {
4332
+ type: "base64",
4333
+ media_type: input.mediaType,
4334
+ data: input.imageBase64
4335
+ }
4336
+ },
4337
+ { type: "text", text: buildVisionPrompt(input.assertion) }
4338
+ ]
4339
+ }
4340
+ ]
4341
+ }
4342
+ );
4343
+ return extractAnthropicText(data);
4344
+ }
4345
+ async function visionWithOpenAi(input, config) {
4346
+ const data = await postJson(
4347
+ "OpenAI",
4348
+ `${apiRoot(config)}/v1/chat/completions`,
4349
+ openAiHeaders(config),
4350
+ {
4351
+ model: config.model,
4352
+ max_tokens: 1024,
4353
+ temperature: 0,
4354
+ messages: [
4355
+ {
4356
+ role: "user",
4357
+ content: [
4358
+ { type: "text", text: buildVisionPrompt(input.assertion) },
4359
+ {
4360
+ type: "image_url",
4361
+ image_url: { url: `data:${input.mediaType};base64,${input.imageBase64}` }
4362
+ }
4363
+ ]
4364
+ }
4365
+ ]
4366
+ }
4367
+ );
4368
+ return extractOpenAiText(data);
4369
+ }
4370
+ async function postJson(provider, url, headers, body) {
4371
+ const response = await fetch(url, {
4372
+ method: "POST",
4373
+ headers,
4374
+ body: JSON.stringify(body),
4375
+ signal: AbortSignal.timeout(AI_REQUEST_TIMEOUT_MS)
4376
+ });
4377
+ if (!response.ok) {
4378
+ const responseBody = await response.text();
4379
+ throw new Error(`${provider} API error (${response.status}): ${responseBody}`);
4380
+ }
4381
+ return await response.json();
4382
+ }
4383
+ function anthropicHeaders(config) {
4384
+ return {
4385
+ "Content-Type": "application/json",
4386
+ "x-api-key": config.apiKey,
4387
+ "anthropic-version": "2023-06-01"
4388
+ };
4389
+ }
4390
+ function openAiHeaders(config) {
4391
+ return {
4392
+ "Content-Type": "application/json",
4393
+ "Authorization": `Bearer ${config.apiKey}`
3802
4394
  };
3803
4395
  }
4396
+ function extractAnthropicText(data) {
4397
+ const textBlock = data.content?.find((c) => c.type === "text");
4398
+ if (!textBlock?.text) {
4399
+ throw new Error("Anthropic API returned no text content");
4400
+ }
4401
+ return textBlock.text;
4402
+ }
4403
+ function extractOpenAiText(data) {
4404
+ if (!data.choices?.[0]?.message?.content) {
4405
+ throw new Error("OpenAI API returned no content");
4406
+ }
4407
+ return data.choices[0].message.content;
4408
+ }
3804
4409
 
3805
4410
  // src/runner/steps.ts
3806
4411
  function getStepType(step) {
@@ -3830,6 +4435,7 @@ function getStepType(step) {
3830
4435
  if ("evalScript" in step) return "evalScript";
3831
4436
  if ("runScript" in step) return "runScript";
3832
4437
  if ("assertScreenshot" in step) return "assertScreenshot";
4438
+ if ("assertWithAI" in step) return "assertWithAI";
3833
4439
  if ("copyText" in step) return "copyText";
3834
4440
  if ("waitForDownload" in step) return "waitForDownload";
3835
4441
  return "step";
@@ -3889,6 +4495,9 @@ function applyRuntimeVars(step, vars) {
3889
4495
  }
3890
4496
  };
3891
4497
  }
4498
+ if ("assertWithAI" in step) {
4499
+ return { assertWithAI: sub(step.assertWithAI) };
4500
+ }
3892
4501
  if ("copyText" in step) {
3893
4502
  return { copyText: { selector: sub(step.copyText.selector), as: step.copyText.as } };
3894
4503
  }
@@ -4822,6 +5431,48 @@ var STEP_HANDLERS = {
4822
5431
  );
4823
5432
  }
4824
5433
  },
5434
+ assertWithAI: {
5435
+ capabilities: ["screenshot"],
5436
+ run: async (h) => {
5437
+ if (!("assertWithAI" in h.step)) unknownStep();
5438
+ const assertion = h.step.assertWithAI;
5439
+ const resolve = h.context.resolveAiConfig ?? tryResolveAiConfig;
5440
+ const aiConfig = resolve();
5441
+ if (!aiConfig) {
5442
+ return {
5443
+ kind: "result",
5444
+ result: {
5445
+ type: "assertWithAI",
5446
+ status: "warn",
5447
+ durationMs: Date.now() - h.stepStart,
5448
+ value: `skipped: no AI provider configured (set PROWL_AI_KEY to enable) \u2014 "${assertion}"`
5449
+ }
5450
+ };
5451
+ }
5452
+ const fileName = `assertWithAI_step_${h.index + 1}.png`;
5453
+ const relative = await h.addScreenshot(fileName);
5454
+ const screenshotFullPath = import_node_path9.default.join(h.context.runDir, relative);
5455
+ const imageBase64 = import_node_fs9.default.readFileSync(screenshotFullPath).toString("base64");
5456
+ const assertVision = h.context.assertVision ?? assertWithAiVision;
5457
+ const verdict = await assertVision(
5458
+ { imageBase64, mediaType: "image/png", assertion },
5459
+ aiConfig
5460
+ );
5461
+ if (verdict.pass) {
5462
+ return {
5463
+ kind: "result",
5464
+ result: {
5465
+ type: "assertWithAI",
5466
+ status: "pass",
5467
+ durationMs: Date.now() - h.stepStart,
5468
+ value: verdict.reason,
5469
+ screenshot: relative
5470
+ }
5471
+ };
5472
+ }
5473
+ throw new Error(`AI assertion failed: ${verdict.reason}`);
5474
+ }
5475
+ },
4825
5476
  copyText: {
4826
5477
  capabilities: ["query"],
4827
5478
  run: async (h) => {
@@ -5964,6 +6615,7 @@ function describeStep(step) {
5964
6615
  const th = step.assertScreenshot.threshold !== void 0 ? ` (threshold: ${step.assertScreenshot.threshold})` : "";
5965
6616
  return `assertScreenshot "${step.assertScreenshot.name}"${th}`;
5966
6617
  }
6618
+ if ("assertWithAI" in step) return `assertWithAI "${truncate(step.assertWithAI, 60)}"`;
5967
6619
  return "unknown step";
5968
6620
  }
5969
6621
  function truncate(text, max) {
@@ -5979,6 +6631,9 @@ function printStepResult(result, step, _index) {
5979
6631
  const duration = import_chalk.default.gray(`(${result.durationMs}ms)`);
5980
6632
  if (result.status === "pass") {
5981
6633
  console.log(` ${import_chalk.default.green("\u2713")} ${label} ${duration}`);
6634
+ } else if (result.status === "warn") {
6635
+ const note = result.value ? import_chalk.default.gray(` \u2014 ${result.value}`) : "";
6636
+ console.log(` ${import_chalk.default.yellow("\u25CB")} ${label} ${duration}${note}`);
5982
6637
  } else {
5983
6638
  const error = result.error ? import_chalk.default.gray(` \u2014 ${result.error}`) : "";
5984
6639
  console.log(` ${import_chalk.default.red("\u2717")} ${label} ${duration}${error}`);
@@ -7370,6 +8025,220 @@ async function readStatusMenu(client, menuTimeoutSeconds) {
7370
8025
  }
7371
8026
  }
7372
8027
 
8028
+ // src/analyzer/android.ts
8029
+ var ANDROID_INTERACTIVE_CLASSES = /* @__PURE__ */ new Set([
8030
+ "android.widget.Button",
8031
+ "android.widget.ImageButton",
8032
+ "android.widget.EditText",
8033
+ "android.widget.CheckBox",
8034
+ "android.widget.RadioButton",
8035
+ "android.widget.Switch",
8036
+ "android.widget.ToggleButton",
8037
+ "android.widget.Spinner",
8038
+ "android.widget.SeekBar",
8039
+ "android.widget.RatingBar",
8040
+ "android.widget.CompoundButton",
8041
+ "android.widget.AutoCompleteTextView",
8042
+ "android.widget.MultiAutoCompleteTextView",
8043
+ "android.widget.CheckedTextView",
8044
+ "androidx.appcompat.widget.SwitchCompat",
8045
+ "androidx.appcompat.widget.AppCompatButton",
8046
+ "androidx.appcompat.widget.AppCompatEditText"
8047
+ ]);
8048
+ function str2(value) {
8049
+ if (value === void 0) {
8050
+ return void 0;
8051
+ }
8052
+ const trimmed = value.trim();
8053
+ return trimmed.length > 0 ? trimmed : void 0;
8054
+ }
8055
+ function bool(value) {
8056
+ if (value === void 0) {
8057
+ return void 0;
8058
+ }
8059
+ return value === "true";
8060
+ }
8061
+ function toAndroidNode(element) {
8062
+ const a = element.attrs;
8063
+ return {
8064
+ className: str2(a["class"]),
8065
+ resourceId: str2(a["resource-id"]),
8066
+ contentDesc: str2(a["content-desc"]),
8067
+ text: str2(a.text),
8068
+ package: str2(a.package),
8069
+ clickable: bool(a.clickable),
8070
+ longClickable: bool(a["long-clickable"]),
8071
+ checkable: bool(a.checkable),
8072
+ checked: bool(a.checked),
8073
+ scrollable: bool(a.scrollable),
8074
+ focusable: bool(a.focusable),
8075
+ focused: bool(a.focused),
8076
+ enabled: bool(a.enabled),
8077
+ children: element.children.map(toAndroidNode)
8078
+ };
8079
+ }
8080
+ function parseAndroidHierarchy(xml) {
8081
+ const root = parseXml(xml);
8082
+ return root ? toAndroidNode(root) : null;
8083
+ }
8084
+ function isAndroidInteractive(node) {
8085
+ if (node.clickable || node.longClickable || node.checkable || node.scrollable) {
8086
+ return true;
8087
+ }
8088
+ return node.className !== void 0 && ANDROID_INTERACTIVE_CLASSES.has(node.className);
8089
+ }
8090
+ function rankAndroidSelectors(node) {
8091
+ return rankNativeSelectors({
8092
+ id: node.resourceId,
8093
+ label: node.contentDesc,
8094
+ role: node.className,
8095
+ name: node.text
8096
+ });
8097
+ }
8098
+ function toElement2(node) {
8099
+ return {
8100
+ className: node.className ?? "?",
8101
+ ...node.resourceId ? { resourceId: node.resourceId } : {},
8102
+ ...node.contentDesc ? { contentDesc: node.contentDesc } : {},
8103
+ ...node.text ? { text: node.text } : {},
8104
+ ...node.clickable !== void 0 ? { clickable: node.clickable } : {},
8105
+ ...node.checkable !== void 0 ? { checkable: node.checkable } : {},
8106
+ ...node.scrollable !== void 0 ? { scrollable: node.scrollable } : {},
8107
+ ...node.enabled !== void 0 ? { enabled: node.enabled } : {},
8108
+ selectors: rankAndroidSelectors(node)
8109
+ };
8110
+ }
8111
+ function collectInteractive2(root) {
8112
+ const out = [];
8113
+ const visit = (node) => {
8114
+ if (isAndroidInteractive(node)) {
8115
+ out.push(toElement2(node));
8116
+ }
8117
+ for (const child of node.children) {
8118
+ visit(child);
8119
+ }
8120
+ };
8121
+ visit(root);
8122
+ return out;
8123
+ }
8124
+ async function analyzeAndroidApp(client, options) {
8125
+ const xml = await client.source();
8126
+ const root = parseAndroidHierarchy(xml);
8127
+ const elements = root ? collectInteractive2(root) : [];
8128
+ return { app: options.app, elements };
8129
+ }
8130
+
8131
+ // src/analyzer/ios.ts
8132
+ var IOS_INTERACTIVE_TYPES = /* @__PURE__ */ new Set([
8133
+ "XCUIElementTypeButton",
8134
+ "XCUIElementTypeCell",
8135
+ "XCUIElementTypeTextField",
8136
+ "XCUIElementTypeSecureTextField",
8137
+ "XCUIElementTypeSearchField",
8138
+ "XCUIElementTypeSwitch",
8139
+ "XCUIElementTypeToggle",
8140
+ "XCUIElementTypeLink",
8141
+ "XCUIElementTypeMenuItem",
8142
+ "XCUIElementTypeSlider",
8143
+ "XCUIElementTypeStepper",
8144
+ "XCUIElementTypeTextView",
8145
+ "XCUIElementTypePickerWheel",
8146
+ "XCUIElementTypeTab",
8147
+ "XCUIElementTypeSegmentedControl",
8148
+ "XCUIElementTypeCheckBox",
8149
+ "XCUIElementTypeRadioButton",
8150
+ "XCUIElementTypeKey"
8151
+ ]);
8152
+ var IOS_WINDOW_TYPE = "XCUIElementTypeWindow";
8153
+ function str3(value) {
8154
+ if (value === void 0) {
8155
+ return void 0;
8156
+ }
8157
+ const trimmed = value.trim();
8158
+ return trimmed.length > 0 ? trimmed : void 0;
8159
+ }
8160
+ function bool2(value) {
8161
+ if (value === void 0) {
8162
+ return void 0;
8163
+ }
8164
+ return value === "true" || value === "1";
8165
+ }
8166
+ function toIosNode(element) {
8167
+ const a = element.attrs;
8168
+ return {
8169
+ type: str3(a.type) ?? str3(element.tag),
8170
+ name: str3(a.name),
8171
+ label: str3(a.label),
8172
+ value: str3(a.value),
8173
+ enabled: bool2(a.enabled),
8174
+ visible: bool2(a.visible),
8175
+ children: element.children.map(toIosNode)
8176
+ };
8177
+ }
8178
+ function parseIosHierarchy(xml) {
8179
+ const root = parseXml(xml);
8180
+ return root ? toIosNode(root) : null;
8181
+ }
8182
+ function hasAccessibilityId(node) {
8183
+ return node.name !== void 0 && node.name !== node.label;
8184
+ }
8185
+ function rankIosSelectors(node) {
8186
+ return rankNativeSelectors({
8187
+ id: hasAccessibilityId(node) ? node.name : void 0,
8188
+ label: node.label,
8189
+ role: node.type ? shortIosType(node.type) : void 0,
8190
+ name: node.label ?? node.value
8191
+ });
8192
+ }
8193
+ function toElement3(node) {
8194
+ return {
8195
+ type: node.type ?? "?",
8196
+ ...node.name ? { name: node.name } : {},
8197
+ ...node.label ? { label: node.label } : {},
8198
+ ...node.value ? { value: node.value } : {},
8199
+ ...node.enabled !== void 0 ? { enabled: node.enabled } : {},
8200
+ ...node.visible !== void 0 ? { visible: node.visible } : {},
8201
+ selectors: rankIosSelectors(node)
8202
+ };
8203
+ }
8204
+ function toWindow2(node) {
8205
+ const [best] = rankIosSelectors(node);
8206
+ return {
8207
+ ...node.name ? { name: node.name } : {},
8208
+ ...node.label ? { label: node.label } : {},
8209
+ selector: best ?? `role=${shortIosType(IOS_WINDOW_TYPE)}`
8210
+ };
8211
+ }
8212
+ function isIosInteractive(node) {
8213
+ return node.type !== void 0 && IOS_INTERACTIVE_TYPES.has(node.type);
8214
+ }
8215
+ function collect(root) {
8216
+ const elements = [];
8217
+ const windows = [];
8218
+ const visit = (node) => {
8219
+ if (node.type === IOS_WINDOW_TYPE) {
8220
+ windows.push(toWindow2(node));
8221
+ }
8222
+ if (isIosInteractive(node)) {
8223
+ elements.push(toElement3(node));
8224
+ }
8225
+ for (const child of node.children) {
8226
+ visit(child);
8227
+ }
8228
+ };
8229
+ visit(root);
8230
+ return { elements, windows };
8231
+ }
8232
+ async function analyzeIosApp(client, options) {
8233
+ const xml = await client.source();
8234
+ const root = parseIosHierarchy(xml);
8235
+ if (!root) {
8236
+ return { app: options.app, elements: [], windows: [] };
8237
+ }
8238
+ const { elements, windows } = collect(root);
8239
+ return { app: options.app, elements, windows };
8240
+ }
8241
+
7373
8242
  // src/cli/commands/analyze.ts
7374
8243
  init_loader();
7375
8244
  function parseViewportFlag2(value) {
@@ -7391,6 +8260,23 @@ function tryLoadConfig(configPath) {
7391
8260
  throw new Error(`Failed to load config at ${configLocation}: ${message}`);
7392
8261
  }
7393
8262
  }
8263
+ function looksLikeApk2(app) {
8264
+ return /\.apk$/i.test(app.trim());
8265
+ }
8266
+ function pickNativePlatform(platformFlag, config, app) {
8267
+ if (platformFlag !== void 0) {
8268
+ const normalized = platformFlag.trim().toLowerCase();
8269
+ if (normalized === "macos" || normalized === "android" || normalized === "ios") {
8270
+ return normalized;
8271
+ }
8272
+ throw new Error(`Unknown --platform "${platformFlag}". Use one of: macos, android, ios.`);
8273
+ }
8274
+ const configType = config?.target.type;
8275
+ if (configType === "macos" || configType === "android" || configType === "ios") {
8276
+ return configType;
8277
+ }
8278
+ return looksLikeApk2(app) ? "android" : "macos";
8279
+ }
7394
8280
  async function runWebAnalyze(url, options) {
7395
8281
  const engine = parseBrowserEngine(options.browser);
7396
8282
  const channel = options.channel;
@@ -7511,14 +8397,141 @@ async function runMacAnalyze(app, config, options) {
7511
8397
  await session.client.close();
7512
8398
  }
7513
8399
  }
8400
+ function printAndroidElement(el) {
8401
+ const role = import_chalk11.default.cyan(el.className);
8402
+ const [best] = el.selectors;
8403
+ const selectorStr = best ? import_chalk11.default.yellow(best) : import_chalk11.default.gray("(no selector)");
8404
+ const label = el.contentDesc ?? el.text;
8405
+ const labelStr = label ? ` ${import_chalk11.default.gray(`"${label}"`)}` : "";
8406
+ const disabled = el.enabled === false ? import_chalk11.default.gray(" (disabled)") : "";
8407
+ console.log(` ${role} ${selectorStr}${labelStr}${disabled}`);
8408
+ }
8409
+ async function runAndroidAnalyze(app, config, options, deviceSerial) {
8410
+ const allowedApps = config?.guardrails.allowedApps ?? [];
8411
+ if (!looksLikeApk2(app)) {
8412
+ assertAndroidAppAllowed(allowedApps, app);
8413
+ }
8414
+ const session = await launchAndroidSession({
8415
+ app,
8416
+ ...deviceSerial ? { deviceSerial } : {},
8417
+ timeoutMs: config?.browser.timeout,
8418
+ allowedApps
8419
+ });
8420
+ try {
8421
+ const sourceFn = session.client.source;
8422
+ if (typeof sourceFn !== "function") {
8423
+ throw new Error(
8424
+ "The Android agent client does not expose source(); a live uiautomator2 session is required to analyze."
8425
+ );
8426
+ }
8427
+ const result = await analyzeAndroidApp(
8428
+ { source: () => sourceFn.call(session.client) },
8429
+ { app: session.package }
8430
+ );
8431
+ if (options.json) {
8432
+ console.log(JSON.stringify(result, null, 2));
8433
+ } else {
8434
+ console.log(`
8435
+ ${import_chalk11.default.bold("App Analysis:")} ${result.app}
8436
+ `);
8437
+ if (result.elements.length > 0) {
8438
+ console.log(import_chalk11.default.bold(" Interactive Elements:"));
8439
+ for (const el of result.elements) {
8440
+ printAndroidElement(el);
8441
+ }
8442
+ console.log();
8443
+ }
8444
+ console.log(import_chalk11.default.gray(` ${result.elements.length} elements
8445
+ `));
8446
+ }
8447
+ } finally {
8448
+ await session.teardown();
8449
+ }
8450
+ }
8451
+ function printIosElement(el) {
8452
+ const role = import_chalk11.default.cyan(el.type);
8453
+ const [best] = el.selectors;
8454
+ const selectorStr = best ? import_chalk11.default.yellow(best) : import_chalk11.default.gray("(no selector)");
8455
+ const label = el.label ?? el.value ?? el.name;
8456
+ const labelStr = label ? ` ${import_chalk11.default.gray(`"${label}"`)}` : "";
8457
+ const disabled = el.enabled === false ? import_chalk11.default.gray(" (disabled)") : "";
8458
+ console.log(` ${role} ${selectorStr}${labelStr}${disabled}`);
8459
+ }
8460
+ async function runIosAnalyze(app, config, options, udid) {
8461
+ const allowedApps = config?.guardrails.allowedApps ?? [];
8462
+ if (!/\//.test(app) && !/\.app$/i.test(app.trim())) {
8463
+ assertIosAppAllowed(allowedApps, app);
8464
+ }
8465
+ const session = await launchIosSession({
8466
+ app,
8467
+ ...udid ? { udid } : {},
8468
+ timeoutMs: config?.browser.timeout,
8469
+ allowedApps
8470
+ });
8471
+ try {
8472
+ const sourceFn = session.client.source;
8473
+ if (typeof sourceFn !== "function") {
8474
+ throw new Error(
8475
+ "The iOS agent client does not expose source(); a live WebDriverAgent session is required to analyze."
8476
+ );
8477
+ }
8478
+ const result = await analyzeIosApp(
8479
+ { source: () => sourceFn.call(session.client) },
8480
+ { app: session.bundleId }
8481
+ );
8482
+ if (options.json) {
8483
+ console.log(JSON.stringify(result, null, 2));
8484
+ } else {
8485
+ console.log(`
8486
+ ${import_chalk11.default.bold("App Analysis:")} ${result.app}
8487
+ `);
8488
+ if (result.windows.length > 0) {
8489
+ console.log(import_chalk11.default.bold(" Windows:"));
8490
+ for (const win of result.windows) {
8491
+ const title = win.label ?? win.name;
8492
+ const titleStr = title ? import_chalk11.default.gray(`"${title}"`) : import_chalk11.default.gray("(untitled)");
8493
+ console.log(` ${titleStr} ${import_chalk11.default.yellow(win.selector)}`);
8494
+ }
8495
+ console.log();
8496
+ }
8497
+ if (result.elements.length > 0) {
8498
+ console.log(import_chalk11.default.bold(" Interactive Elements:"));
8499
+ for (const el of result.elements) {
8500
+ printIosElement(el);
8501
+ }
8502
+ console.log();
8503
+ }
8504
+ console.log(import_chalk11.default.gray(` ${result.elements.length} elements, ${result.windows.length} windows
8505
+ `));
8506
+ }
8507
+ } finally {
8508
+ await session.teardown();
8509
+ }
8510
+ }
8511
+ async function runNativeAnalyze(platform, app, config, options, device) {
8512
+ if (platform === "android") {
8513
+ await runAndroidAnalyze(app, config, options, device.deviceSerial);
8514
+ return;
8515
+ }
8516
+ if (platform === "ios") {
8517
+ await runIosAnalyze(app, config, options, device.udid);
8518
+ return;
8519
+ }
8520
+ await runMacAnalyze(app, config, options);
8521
+ }
7514
8522
  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) => {
8523
+ 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
8524
  try {
7517
8525
  if (options.app && url) {
7518
- throw new Error("Pass either a URL argument (web) or --app (macOS), not both.");
8526
+ throw new Error("Pass either a URL argument (web) or --app (native), not both.");
7519
8527
  }
7520
8528
  if (options.app) {
7521
- await runMacAnalyze(options.app, tryLoadConfig(options.config), options);
8529
+ const config2 = tryLoadConfig(options.config);
8530
+ const platform = pickNativePlatform(options.platform, config2, options.app);
8531
+ await runNativeAnalyze(platform, options.app, config2, options, {
8532
+ deviceSerial: options.device,
8533
+ udid: options.udid
8534
+ });
7522
8535
  return;
7523
8536
  }
7524
8537
  if (url) {
@@ -7526,12 +8539,21 @@ function buildAnalyzeCommand() {
7526
8539
  return;
7527
8540
  }
7528
8541
  const config = tryLoadConfig(options.config);
7529
- if (config?.target.type === "macos") {
7530
- await runMacAnalyze(config.target.app, config, options);
8542
+ const target = config?.target;
8543
+ if (target?.type === "macos") {
8544
+ await runMacAnalyze(target.app, config, options);
8545
+ return;
8546
+ }
8547
+ if (target?.type === "android") {
8548
+ await runAndroidAnalyze(target.app, config, options, options.device ?? target.deviceSerial);
8549
+ return;
8550
+ }
8551
+ if (target?.type === "ios") {
8552
+ await runIosAnalyze(target.app, config, options, options.udid ?? target.udid);
7531
8553
  return;
7532
8554
  }
7533
8555
  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."
8556
+ "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
8557
  );
7536
8558
  } catch (error) {
7537
8559
  const message = error instanceof Error ? error.message : "Analysis failed";
@@ -7601,6 +8623,7 @@ var STEP_REFERENCE = `
7601
8623
 
7602
8624
  ### Visual Regression
7603
8625
  - assertScreenshot: { name: "baseline-name", threshold: 0.1 }
8626
+ - assertWithAI: "The login form should show email and password fields" \u2014 AI checks the screenshot against the claim
7604
8627
 
7605
8628
  ### Control Flow
7606
8629
  - if: { visible: ".banner", then: [steps...] }
@@ -7642,81 +8665,6 @@ function extractYamlFromResponse(response) {
7642
8665
  return response.trim();
7643
8666
  }
7644
8667
 
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
8668
  // src/generator/index.ts
7721
8669
  init_loader();
7722
8670
  init_schema();