prowl-tools 0.1.4 → 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,11 +1,20 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  DEFAULT_FLAKY_THRESHOLD,
4
+ analyzeAndroidApp,
5
+ analyzeIosApp,
6
+ analyzeMacApp,
4
7
  analyzePage,
8
+ assertAndroidAppAllowed,
9
+ assertIosAppAllowed,
10
+ assertTargetAppAllowed,
5
11
  closeBrowser,
6
12
  createPlaywrightDriver,
7
13
  generateHunt,
14
+ launchAndroidSession,
8
15
  launchBrowser,
16
+ launchIosSession,
17
+ launchMacSession,
9
18
  parseBrowserEngine,
10
19
  printCiSummary,
11
20
  rankFlaky,
@@ -14,15 +23,16 @@ import {
14
23
  runSuite,
15
24
  saveStorageState,
16
25
  updateBacklogFromSuite
17
- } from "./chunk-ZEFVTKQT.js";
26
+ } from "./chunk-5KQR3IR3.js";
18
27
  import {
19
28
  CONFIG_DIR,
29
+ findConfigPath,
20
30
  listHunts,
21
31
  loadConfig,
22
32
  loadHuntMeta,
23
33
  loadHuntTags,
24
34
  resolveViewport
25
- } from "./chunk-MBAIGNVO.js";
35
+ } from "./chunk-WHAMB4TY.js";
26
36
 
27
37
  // src/cli/program.ts
28
38
  import { Command as Command13 } from "commander";
@@ -30,7 +40,7 @@ import { Command as Command13 } from "commander";
30
40
  // package.json
31
41
  var package_default = {
32
42
  name: "prowl-tools",
33
- version: "0.1.4",
43
+ version: "0.1.6",
34
44
  description: "CLI-first QA testing tool for deterministic Playwright flows.",
35
45
  type: "module",
36
46
  license: "Apache-2.0",
@@ -100,6 +110,10 @@ var package_default = {
100
110
  yaml: "^2.6.1",
101
111
  zod: "^3.23.8"
102
112
  },
113
+ optionalDependencies: {
114
+ "appium-uiautomator2-server": "10.6.2",
115
+ "appium-webdriveragent": "16.4.0"
116
+ },
103
117
  devDependencies: {
104
118
  "@types/node": "^22.13.1",
105
119
  "@types/pngjs": "^6.0.5",
@@ -190,6 +204,7 @@ function describeStep(step) {
190
204
  const th = step.assertScreenshot.threshold !== void 0 ? ` (threshold: ${step.assertScreenshot.threshold})` : "";
191
205
  return `assertScreenshot "${step.assertScreenshot.name}"${th}`;
192
206
  }
207
+ if ("assertWithAI" in step) return `assertWithAI "${truncate(step.assertWithAI, 60)}"`;
193
208
  return "unknown step";
194
209
  }
195
210
  function truncate(text, max) {
@@ -205,6 +220,9 @@ function printStepResult(result, step, _index) {
205
220
  const duration = chalk.gray(`(${result.durationMs}ms)`);
206
221
  if (result.status === "pass") {
207
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}`);
208
226
  } else {
209
227
  const error = result.error ? chalk.gray(` \u2014 ${result.error}`) : "";
210
228
  console.log(` ${chalk.red("\u2717")} ${label} ${duration}${error}`);
@@ -422,7 +440,7 @@ function buildLoginCommand() {
422
440
  let session = null;
423
441
  try {
424
442
  const { config, configDir } = loadConfig(options.config);
425
- if (config.target.type === "macos") {
443
+ if (config.target.type !== "web") {
426
444
  throw new Error("`prowl login` captures browser auth state and only applies to web targets.");
427
445
  }
428
446
  const targetUrl = options.url ?? config.target.url;
@@ -779,73 +797,313 @@ function parseViewportFlag(value) {
779
797
  }
780
798
  return value;
781
799
  }
782
- function buildAnalyzeCommand() {
783
- const command = new Command8("analyze").argument("<url>", "URL to analyze").description("Analyze a page to discover interactive elements and selectors").option("--json", "Output as JSON").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) => {
784
- try {
785
- const engine = parseBrowserEngine(options.browser);
786
- const channel = options.channel;
787
- const viewport = options.viewport ? resolveViewport(parseViewportFlag(options.viewport)) : { width: 1280, height: 720 };
788
- const session = await launchBrowser({
789
- headless: !options.headed,
790
- slowMo: 0,
791
- timeout: 3e4,
792
- trace: false,
793
- recordHar: false,
794
- runDir: process.cwd(),
795
- engine,
796
- channel,
797
- viewport
798
- });
799
- const driver = createPlaywrightDriver(session.page);
800
- try {
801
- await driver.goto(url, { waitUntil: "networkidle" });
802
- const result = await analyzePage(driver);
803
- if (options.json) {
804
- console.log(JSON.stringify(result, null, 2));
805
- } else {
806
- console.log(`
800
+ function tryLoadConfig(configPath) {
801
+ const configLocation = configPath !== void 0 ? configPath : findConfigPath(process.cwd());
802
+ if (configLocation === null) {
803
+ return null;
804
+ }
805
+ try {
806
+ return loadConfig(configLocation).config;
807
+ } catch (error) {
808
+ const message = error instanceof Error ? error.message : "Unknown configuration error";
809
+ throw new Error(`Failed to load config at ${configLocation}: ${message}`);
810
+ }
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
+ }
829
+ async function runWebAnalyze(url, options) {
830
+ const engine = parseBrowserEngine(options.browser);
831
+ const channel = options.channel;
832
+ const viewport = options.viewport ? resolveViewport(parseViewportFlag(options.viewport)) : { width: 1280, height: 720 };
833
+ const session = await launchBrowser({
834
+ headless: !options.headed,
835
+ slowMo: 0,
836
+ timeout: 3e4,
837
+ trace: false,
838
+ recordHar: false,
839
+ runDir: process.cwd(),
840
+ engine,
841
+ channel,
842
+ viewport
843
+ });
844
+ const driver = createPlaywrightDriver(session.page);
845
+ try {
846
+ await driver.goto(url, { waitUntil: "networkidle" });
847
+ const result = await analyzePage(driver);
848
+ if (options.json) {
849
+ console.log(JSON.stringify(result, null, 2));
850
+ } else {
851
+ console.log(`
807
852
  ${chalk10.bold("Page Analysis:")} ${result.title}`);
808
- console.log(` ${chalk10.gray("URL:")} ${result.url}
853
+ console.log(` ${chalk10.gray("URL:")} ${result.url}
809
854
  `);
810
- if (result.forms.length > 0) {
811
- console.log(chalk10.bold(" Forms:"));
812
- for (const form of result.forms) {
813
- const method = form.method ? chalk10.cyan(form.method) : "";
814
- const action = form.action ? chalk10.gray(form.action) : "";
815
- console.log(` [${form.index}] ${method} ${action} (${form.fieldCount} fields)`);
816
- }
817
- console.log();
818
- }
819
- if (result.elements.length > 0) {
820
- console.log(chalk10.bold(" Interactive Elements:"));
821
- for (const el of result.elements) {
822
- const tag = chalk10.cyan(el.tag);
823
- const type = el.type ? chalk10.gray(`[${el.type}]`) : "";
824
- const bestSelector = el.selectors.testId ?? el.selectors.label ?? el.selectors.ariaLabel ?? el.selectors.css ?? el.selectors.name ?? "";
825
- const selectorStr = bestSelector ? chalk10.yellow(bestSelector) : chalk10.gray("(no selector)");
826
- const req = el.required ? chalk10.red(" *") : "";
827
- const form = el.formGroup !== void 0 ? chalk10.gray(` form[${el.formGroup}]`) : "";
828
- console.log(` ${tag}${type} ${selectorStr}${req}${form}`);
829
- }
830
- console.log();
831
- }
832
- if (result.links.length > 0) {
833
- console.log(chalk10.bold(" Links:"));
834
- for (const link of result.links.slice(0, 20)) {
835
- const text = link.text || chalk10.gray("(no text)");
836
- console.log(` ${text} ${chalk10.gray("\u2192")} ${chalk10.blue(link.href)}`);
837
- }
838
- if (result.links.length > 20) {
839
- console.log(chalk10.gray(` ... and ${result.links.length - 20} more`));
840
- }
841
- console.log();
842
- }
843
- console.log(chalk10.gray(` ${result.elements.length} elements, ${result.forms.length} forms, ${result.links.length} links
855
+ if (result.forms.length > 0) {
856
+ console.log(chalk10.bold(" Forms:"));
857
+ for (const form of result.forms) {
858
+ const method = form.method ? chalk10.cyan(form.method) : "";
859
+ const action = form.action ? chalk10.gray(form.action) : "";
860
+ console.log(` [${form.index}] ${method} ${action} (${form.fieldCount} fields)`);
861
+ }
862
+ console.log();
863
+ }
864
+ if (result.elements.length > 0) {
865
+ console.log(chalk10.bold(" Interactive Elements:"));
866
+ for (const el of result.elements) {
867
+ const tag = chalk10.cyan(el.tag);
868
+ const type = el.type ? chalk10.gray(`[${el.type}]`) : "";
869
+ const bestSelector = el.selectors.testId ?? el.selectors.label ?? el.selectors.ariaLabel ?? el.selectors.css ?? el.selectors.name ?? "";
870
+ const selectorStr = bestSelector ? chalk10.yellow(bestSelector) : chalk10.gray("(no selector)");
871
+ const req = el.required ? chalk10.red(" *") : "";
872
+ const form = el.formGroup !== void 0 ? chalk10.gray(` form[${el.formGroup}]`) : "";
873
+ console.log(` ${tag}${type} ${selectorStr}${req}${form}`);
874
+ }
875
+ console.log();
876
+ }
877
+ if (result.links.length > 0) {
878
+ console.log(chalk10.bold(" Links:"));
879
+ for (const link of result.links.slice(0, 20)) {
880
+ const text = link.text || chalk10.gray("(no text)");
881
+ console.log(` ${text} ${chalk10.gray("\u2192")} ${chalk10.blue(link.href)}`);
882
+ }
883
+ if (result.links.length > 20) {
884
+ console.log(chalk10.gray(` ... and ${result.links.length - 20} more`));
885
+ }
886
+ console.log();
887
+ }
888
+ console.log(chalk10.gray(` ${result.elements.length} elements, ${result.forms.length} forms, ${result.links.length} links
844
889
  `));
890
+ }
891
+ } finally {
892
+ await closeBrowser(session);
893
+ }
894
+ }
895
+ function printMacElement(el) {
896
+ const role = chalk10.cyan(el.role);
897
+ const [best] = el.selectors;
898
+ const selectorStr = best ? chalk10.yellow(best) : chalk10.gray("(no selector)");
899
+ const label = el.title ?? el.description ?? el.value;
900
+ const labelStr = label ? ` ${chalk10.gray(`"${label}"`)}` : "";
901
+ const disabled = el.enabled === false ? chalk10.gray(" (disabled)") : "";
902
+ console.log(` ${role} ${selectorStr}${labelStr}${disabled}`);
903
+ }
904
+ async function runMacAnalyze(app, config, options) {
905
+ const allowedApps = config?.guardrails.allowedApps ?? [];
906
+ assertTargetAppAllowed(allowedApps, app);
907
+ const session = await launchMacSession({ app, timeoutMs: config?.browser.timeout });
908
+ try {
909
+ const result = await analyzeMacApp(session.client, { app: session.bundleId });
910
+ if (options.json) {
911
+ console.log(JSON.stringify(result, null, 2));
912
+ } else {
913
+ console.log(`
914
+ ${chalk10.bold("App Analysis:")} ${result.app}
915
+ `);
916
+ if (result.windows.length > 0) {
917
+ console.log(chalk10.bold(" Windows:"));
918
+ for (const win of result.windows) {
919
+ const title = win.title ? chalk10.gray(`"${win.title}"`) : chalk10.gray("(untitled)");
920
+ console.log(` ${title} ${chalk10.yellow(win.selector)}`);
845
921
  }
846
- } finally {
847
- await closeBrowser(session);
922
+ console.log();
923
+ }
924
+ if (result.elements.length > 0) {
925
+ console.log(chalk10.bold(" Interactive Elements:"));
926
+ for (const el of result.elements) {
927
+ printMacElement(el);
928
+ }
929
+ console.log();
930
+ }
931
+ if (result.menuItems.length > 0) {
932
+ console.log(chalk10.bold(" Menu Bar:"));
933
+ for (const el of result.menuItems) {
934
+ printMacElement(el);
935
+ }
936
+ console.log();
937
+ }
938
+ console.log(
939
+ chalk10.gray(
940
+ ` ${result.elements.length} elements, ${result.windows.length} windows, ${result.menuItems.length} menu items
941
+ `
942
+ )
943
+ );
944
+ }
945
+ } finally {
946
+ await session.client.close();
947
+ }
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();
848
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
+ }
1071
+ function buildAnalyzeCommand() {
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) => {
1073
+ try {
1074
+ if (options.app && url) {
1075
+ throw new Error("Pass either a URL argument (web) or --app (native), not both.");
1076
+ }
1077
+ if (options.app) {
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
+ });
1084
+ return;
1085
+ }
1086
+ if (url) {
1087
+ await runWebAnalyze(url, options);
1088
+ return;
1089
+ }
1090
+ const config = tryLoadConfig(options.config);
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);
1102
+ return;
1103
+ }
1104
+ throw new Error(
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."
1106
+ );
849
1107
  } catch (error) {
850
1108
  const message = error instanceof Error ? error.message : "Analysis failed";
851
1109
  if (options.json) {
@@ -919,7 +1177,7 @@ function buildGenerateCommand() {
919
1177
  } else if (options.output) {
920
1178
  let configDir;
921
1179
  try {
922
- const { loadConfig: loadConfig2 } = await import("./loader-XMTDB6OL.js");
1180
+ const { loadConfig: loadConfig2 } = await import("./loader-PBBYV3U7.js");
923
1181
  const result = loadConfig2(options.config);
924
1182
  configDir = result.configDir;
925
1183
  } catch {