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.js CHANGED
@@ -1,13 +1,19 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  DEFAULT_FLAKY_THRESHOLD,
4
+ analyzeAndroidApp,
5
+ analyzeIosApp,
4
6
  analyzeMacApp,
5
7
  analyzePage,
8
+ assertAndroidAppAllowed,
9
+ assertIosAppAllowed,
6
10
  assertTargetAppAllowed,
7
11
  closeBrowser,
8
12
  createPlaywrightDriver,
9
13
  generateHunt,
14
+ launchAndroidSession,
10
15
  launchBrowser,
16
+ launchIosSession,
11
17
  launchMacSession,
12
18
  parseBrowserEngine,
13
19
  printCiSummary,
@@ -17,7 +23,7 @@ import {
17
23
  runSuite,
18
24
  saveStorageState,
19
25
  updateBacklogFromSuite
20
- } from "./chunk-2KD2XCTH.js";
26
+ } from "./chunk-5KQR3IR3.js";
21
27
  import {
22
28
  CONFIG_DIR,
23
29
  findConfigPath,
@@ -26,7 +32,7 @@ import {
26
32
  loadHuntMeta,
27
33
  loadHuntTags,
28
34
  resolveViewport
29
- } from "./chunk-ITOSUJCN.js";
35
+ } from "./chunk-WHAMB4TY.js";
30
36
 
31
37
  // src/cli/program.ts
32
38
  import { Command as Command13 } from "commander";
@@ -34,7 +40,7 @@ import { Command as Command13 } from "commander";
34
40
  // package.json
35
41
  var package_default = {
36
42
  name: "prowl-tools",
37
- version: "0.1.5",
43
+ version: "0.1.6",
38
44
  description: "CLI-first QA testing tool for deterministic Playwright flows.",
39
45
  type: "module",
40
46
  license: "Apache-2.0",
@@ -198,6 +204,7 @@ function describeStep(step) {
198
204
  const th = step.assertScreenshot.threshold !== void 0 ? ` (threshold: ${step.assertScreenshot.threshold})` : "";
199
205
  return `assertScreenshot "${step.assertScreenshot.name}"${th}`;
200
206
  }
207
+ if ("assertWithAI" in step) return `assertWithAI "${truncate(step.assertWithAI, 60)}"`;
201
208
  return "unknown step";
202
209
  }
203
210
  function truncate(text, max) {
@@ -213,6 +220,9 @@ function printStepResult(result, step, _index) {
213
220
  const duration = chalk.gray(`(${result.durationMs}ms)`);
214
221
  if (result.status === "pass") {
215
222
  console.log(` ${chalk.green("\u2713")} ${label} ${duration}`);
223
+ } else if (result.status === "warn") {
224
+ const note = result.value ? chalk.gray(` \u2014 ${result.value}`) : "";
225
+ console.log(` ${chalk.yellow("\u25CB")} ${label} ${duration}${note}`);
216
226
  } else {
217
227
  const error = result.error ? chalk.gray(` \u2014 ${result.error}`) : "";
218
228
  console.log(` ${chalk.red("\u2717")} ${label} ${duration}${error}`);
@@ -799,6 +809,23 @@ function tryLoadConfig(configPath) {
799
809
  throw new Error(`Failed to load config at ${configLocation}: ${message}`);
800
810
  }
801
811
  }
812
+ function looksLikeApk(app) {
813
+ return /\.apk$/i.test(app.trim());
814
+ }
815
+ function pickNativePlatform(platformFlag, config, app) {
816
+ if (platformFlag !== void 0) {
817
+ const normalized = platformFlag.trim().toLowerCase();
818
+ if (normalized === "macos" || normalized === "android" || normalized === "ios") {
819
+ return normalized;
820
+ }
821
+ throw new Error(`Unknown --platform "${platformFlag}". Use one of: macos, android, ios.`);
822
+ }
823
+ const configType = config?.target.type;
824
+ if (configType === "macos" || configType === "android" || configType === "ios") {
825
+ return configType;
826
+ }
827
+ return looksLikeApk(app) ? "android" : "macos";
828
+ }
802
829
  async function runWebAnalyze(url, options) {
803
830
  const engine = parseBrowserEngine(options.browser);
804
831
  const channel = options.channel;
@@ -919,14 +946,141 @@ async function runMacAnalyze(app, config, options) {
919
946
  await session.client.close();
920
947
  }
921
948
  }
949
+ function printAndroidElement(el) {
950
+ const role = chalk10.cyan(el.className);
951
+ const [best] = el.selectors;
952
+ const selectorStr = best ? chalk10.yellow(best) : chalk10.gray("(no selector)");
953
+ const label = el.contentDesc ?? el.text;
954
+ const labelStr = label ? ` ${chalk10.gray(`"${label}"`)}` : "";
955
+ const disabled = el.enabled === false ? chalk10.gray(" (disabled)") : "";
956
+ console.log(` ${role} ${selectorStr}${labelStr}${disabled}`);
957
+ }
958
+ async function runAndroidAnalyze(app, config, options, deviceSerial) {
959
+ const allowedApps = config?.guardrails.allowedApps ?? [];
960
+ if (!looksLikeApk(app)) {
961
+ assertAndroidAppAllowed(allowedApps, app);
962
+ }
963
+ const session = await launchAndroidSession({
964
+ app,
965
+ ...deviceSerial ? { deviceSerial } : {},
966
+ timeoutMs: config?.browser.timeout,
967
+ allowedApps
968
+ });
969
+ try {
970
+ const sourceFn = session.client.source;
971
+ if (typeof sourceFn !== "function") {
972
+ throw new Error(
973
+ "The Android agent client does not expose source(); a live uiautomator2 session is required to analyze."
974
+ );
975
+ }
976
+ const result = await analyzeAndroidApp(
977
+ { source: () => sourceFn.call(session.client) },
978
+ { app: session.package }
979
+ );
980
+ if (options.json) {
981
+ console.log(JSON.stringify(result, null, 2));
982
+ } else {
983
+ console.log(`
984
+ ${chalk10.bold("App Analysis:")} ${result.app}
985
+ `);
986
+ if (result.elements.length > 0) {
987
+ console.log(chalk10.bold(" Interactive Elements:"));
988
+ for (const el of result.elements) {
989
+ printAndroidElement(el);
990
+ }
991
+ console.log();
992
+ }
993
+ console.log(chalk10.gray(` ${result.elements.length} elements
994
+ `));
995
+ }
996
+ } finally {
997
+ await session.teardown();
998
+ }
999
+ }
1000
+ function printIosElement(el) {
1001
+ const role = chalk10.cyan(el.type);
1002
+ const [best] = el.selectors;
1003
+ const selectorStr = best ? chalk10.yellow(best) : chalk10.gray("(no selector)");
1004
+ const label = el.label ?? el.value ?? el.name;
1005
+ const labelStr = label ? ` ${chalk10.gray(`"${label}"`)}` : "";
1006
+ const disabled = el.enabled === false ? chalk10.gray(" (disabled)") : "";
1007
+ console.log(` ${role} ${selectorStr}${labelStr}${disabled}`);
1008
+ }
1009
+ async function runIosAnalyze(app, config, options, udid) {
1010
+ const allowedApps = config?.guardrails.allowedApps ?? [];
1011
+ if (!/\//.test(app) && !/\.app$/i.test(app.trim())) {
1012
+ assertIosAppAllowed(allowedApps, app);
1013
+ }
1014
+ const session = await launchIosSession({
1015
+ app,
1016
+ ...udid ? { udid } : {},
1017
+ timeoutMs: config?.browser.timeout,
1018
+ allowedApps
1019
+ });
1020
+ try {
1021
+ const sourceFn = session.client.source;
1022
+ if (typeof sourceFn !== "function") {
1023
+ throw new Error(
1024
+ "The iOS agent client does not expose source(); a live WebDriverAgent session is required to analyze."
1025
+ );
1026
+ }
1027
+ const result = await analyzeIosApp(
1028
+ { source: () => sourceFn.call(session.client) },
1029
+ { app: session.bundleId }
1030
+ );
1031
+ if (options.json) {
1032
+ console.log(JSON.stringify(result, null, 2));
1033
+ } else {
1034
+ console.log(`
1035
+ ${chalk10.bold("App Analysis:")} ${result.app}
1036
+ `);
1037
+ if (result.windows.length > 0) {
1038
+ console.log(chalk10.bold(" Windows:"));
1039
+ for (const win of result.windows) {
1040
+ const title = win.label ?? win.name;
1041
+ const titleStr = title ? chalk10.gray(`"${title}"`) : chalk10.gray("(untitled)");
1042
+ console.log(` ${titleStr} ${chalk10.yellow(win.selector)}`);
1043
+ }
1044
+ console.log();
1045
+ }
1046
+ if (result.elements.length > 0) {
1047
+ console.log(chalk10.bold(" Interactive Elements:"));
1048
+ for (const el of result.elements) {
1049
+ printIosElement(el);
1050
+ }
1051
+ console.log();
1052
+ }
1053
+ console.log(chalk10.gray(` ${result.elements.length} elements, ${result.windows.length} windows
1054
+ `));
1055
+ }
1056
+ } finally {
1057
+ await session.teardown();
1058
+ }
1059
+ }
1060
+ async function runNativeAnalyze(platform, app, config, options, device) {
1061
+ if (platform === "android") {
1062
+ await runAndroidAnalyze(app, config, options, device.deviceSerial);
1063
+ return;
1064
+ }
1065
+ if (platform === "ios") {
1066
+ await runIosAnalyze(app, config, options, device.udid);
1067
+ return;
1068
+ }
1069
+ await runMacAnalyze(app, config, options);
1070
+ }
922
1071
  function buildAnalyzeCommand() {
923
- const command = new Command8("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) => {
1072
+ const command = new Command8("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) => {
924
1073
  try {
925
1074
  if (options.app && url) {
926
- throw new Error("Pass either a URL argument (web) or --app (macOS), not both.");
1075
+ throw new Error("Pass either a URL argument (web) or --app (native), not both.");
927
1076
  }
928
1077
  if (options.app) {
929
- await runMacAnalyze(options.app, tryLoadConfig(options.config), options);
1078
+ const config2 = tryLoadConfig(options.config);
1079
+ const platform = pickNativePlatform(options.platform, config2, options.app);
1080
+ await runNativeAnalyze(platform, options.app, config2, options, {
1081
+ deviceSerial: options.device,
1082
+ udid: options.udid
1083
+ });
930
1084
  return;
931
1085
  }
932
1086
  if (url) {
@@ -934,12 +1088,21 @@ function buildAnalyzeCommand() {
934
1088
  return;
935
1089
  }
936
1090
  const config = tryLoadConfig(options.config);
937
- if (config?.target.type === "macos") {
938
- await runMacAnalyze(config.target.app, config, options);
1091
+ const target = config?.target;
1092
+ if (target?.type === "macos") {
1093
+ await runMacAnalyze(target.app, config, options);
1094
+ return;
1095
+ }
1096
+ if (target?.type === "android") {
1097
+ await runAndroidAnalyze(target.app, config, options, options.device ?? target.deviceSerial);
1098
+ return;
1099
+ }
1100
+ if (target?.type === "ios") {
1101
+ await runIosAnalyze(target.app, config, options, options.udid ?? target.udid);
939
1102
  return;
940
1103
  }
941
1104
  throw new Error(
942
- "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."
1105
+ "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."
943
1106
  );
944
1107
  } catch (error) {
945
1108
  const message = error instanceof Error ? error.message : "Analysis failed";
@@ -1014,7 +1177,7 @@ function buildGenerateCommand() {
1014
1177
  } else if (options.output) {
1015
1178
  let configDir;
1016
1179
  try {
1017
- const { loadConfig: loadConfig2 } = await import("./loader-FCXPARP7.js");
1180
+ const { loadConfig: loadConfig2 } = await import("./loader-PBBYV3U7.js");
1018
1181
  const result = loadConfig2(options.config);
1019
1182
  configDir = result.configDir;
1020
1183
  } catch {