prowl-tools 0.1.3 → 0.1.5

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,23 +1,32 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  DEFAULT_FLAKY_THRESHOLD,
4
+ analyzeMacApp,
4
5
  analyzePage,
6
+ assertTargetAppAllowed,
7
+ closeBrowser,
8
+ createPlaywrightDriver,
5
9
  generateHunt,
10
+ launchBrowser,
11
+ launchMacSession,
12
+ parseBrowserEngine,
6
13
  printCiSummary,
7
14
  rankFlaky,
8
15
  readHuntHistory,
9
16
  runHunt,
10
17
  runSuite,
18
+ saveStorageState,
11
19
  updateBacklogFromSuite
12
- } from "./chunk-T7YLXF6X.js";
20
+ } from "./chunk-2KD2XCTH.js";
13
21
  import {
14
22
  CONFIG_DIR,
23
+ findConfigPath,
15
24
  listHunts,
16
25
  loadConfig,
17
26
  loadHuntMeta,
18
27
  loadHuntTags,
19
28
  resolveViewport
20
- } from "./chunk-NXXGJOBG.js";
29
+ } from "./chunk-ITOSUJCN.js";
21
30
 
22
31
  // src/cli/program.ts
23
32
  import { Command as Command13 } from "commander";
@@ -25,11 +34,11 @@ import { Command as Command13 } from "commander";
25
34
  // package.json
26
35
  var package_default = {
27
36
  name: "prowl-tools",
28
- version: "0.1.3",
37
+ version: "0.1.5",
29
38
  description: "CLI-first QA testing tool for deterministic Playwright flows.",
30
39
  type: "module",
31
40
  license: "Apache-2.0",
32
- author: "Michael Tookes",
41
+ author: "Prowl Tools",
33
42
  repository: {
34
43
  type: "git",
35
44
  url: "https://github.com/prowl-tools/prowl.git"
@@ -95,6 +104,10 @@ var package_default = {
95
104
  yaml: "^2.6.1",
96
105
  zod: "^3.23.8"
97
106
  },
107
+ optionalDependencies: {
108
+ "appium-uiautomator2-server": "10.6.2",
109
+ "appium-webdriveragent": "16.4.0"
110
+ },
98
111
  devDependencies: {
99
112
  "@types/node": "^22.13.1",
100
113
  "@types/pngjs": "^6.0.5",
@@ -396,7 +409,6 @@ import path2 from "path";
396
409
  import readline from "readline";
397
410
  import chalk5 from "chalk";
398
411
  import { Command as Command3 } from "commander";
399
- import { chromium } from "playwright";
400
412
  function resolvePath(configDir, inputPath) {
401
413
  if (path2.isAbsolute(inputPath)) {
402
414
  return inputPath;
@@ -415,30 +427,35 @@ function waitForEnter(prompt) {
415
427
  }
416
428
  function buildLoginCommand() {
417
429
  const command = new Command3("login").option("--url <target>", "Override target URL").option("--config <path>", "Custom config path").action(async (options) => {
418
- let browser = null;
419
- let context = null;
430
+ let session = null;
420
431
  try {
421
432
  const { config, configDir } = loadConfig(options.config);
433
+ if (config.target.type !== "web") {
434
+ throw new Error("`prowl login` captures browser auth state and only applies to web targets.");
435
+ }
422
436
  const targetUrl = options.url ?? config.target.url;
423
437
  const storageStatePath = config.auth.storageStatePath ? resolvePath(configDir, config.auth.storageStatePath) : resolvePath(configDir, ".prowl/auth-state.json");
424
- browser = await chromium.launch({ headless: false });
425
- context = await browser.newContext();
426
- const page = await context.newPage();
427
- await page.goto(targetUrl);
438
+ session = await launchBrowser({
439
+ headless: false,
440
+ slowMo: 0,
441
+ timeout: config.browser.timeout,
442
+ trace: false,
443
+ recordHar: false,
444
+ runDir: configDir
445
+ });
446
+ const driver = createPlaywrightDriver(session.page);
447
+ await driver.goto(targetUrl);
428
448
  console.log(chalk5.green("Browser opened. Log in manually."));
429
449
  await waitForEnter("Press Enter to save auth state and close the browser... ");
430
- await context.storageState({ path: storageStatePath });
450
+ await saveStorageState(session, storageStatePath);
431
451
  console.log(chalk5.green(`Saved auth state to ${storageStatePath}`));
432
452
  } catch (error) {
433
453
  const message = error instanceof Error ? error.message : "Login failed";
434
454
  console.error(chalk5.red(`Error: ${message}`));
435
455
  process.exitCode = 1;
436
456
  } finally {
437
- if (context) {
438
- await context.close();
439
- }
440
- if (browser) {
441
- await browser.close();
457
+ if (session) {
458
+ await closeBrowser(session);
442
459
  }
443
460
  }
444
461
  });
@@ -763,8 +780,6 @@ function buildUpdateBaselinesCommand() {
763
780
  // src/cli/commands/analyze.ts
764
781
  import { Command as Command8 } from "commander";
765
782
  import chalk10 from "chalk";
766
- import { chromium as chromium2, firefox, webkit } from "playwright";
767
- var ENGINES = { chromium: chromium2, firefox, webkit };
768
783
  function parseViewportFlag(value) {
769
784
  const match = /^(\d+)x(\d+)$/i.exec(value);
770
785
  if (match) {
@@ -772,69 +787,160 @@ function parseViewportFlag(value) {
772
787
  }
773
788
  return value;
774
789
  }
775
- function buildAnalyzeCommand() {
776
- 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) => {
777
- try {
778
- const engine = options.browser ?? "chromium";
779
- const channel = options.channel;
780
- const viewport = options.viewport ? resolveViewport(parseViewportFlag(options.viewport)) : { width: 1280, height: 720 };
781
- const browserEngine = ENGINES[engine];
782
- const browser = await browserEngine.launch({
783
- headless: !options.headed,
784
- channel
785
- });
786
- const context = await browser.newContext({ viewport });
787
- const page = await context.newPage();
788
- try {
789
- await page.goto(url, { waitUntil: "networkidle" });
790
- const result = await analyzePage(page);
791
- if (options.json) {
792
- console.log(JSON.stringify(result, null, 2));
793
- } else {
794
- console.log(`
790
+ function tryLoadConfig(configPath) {
791
+ const configLocation = configPath !== void 0 ? configPath : findConfigPath(process.cwd());
792
+ if (configLocation === null) {
793
+ return null;
794
+ }
795
+ try {
796
+ return loadConfig(configLocation).config;
797
+ } catch (error) {
798
+ const message = error instanceof Error ? error.message : "Unknown configuration error";
799
+ throw new Error(`Failed to load config at ${configLocation}: ${message}`);
800
+ }
801
+ }
802
+ async function runWebAnalyze(url, options) {
803
+ const engine = parseBrowserEngine(options.browser);
804
+ const channel = options.channel;
805
+ const viewport = options.viewport ? resolveViewport(parseViewportFlag(options.viewport)) : { width: 1280, height: 720 };
806
+ const session = await launchBrowser({
807
+ headless: !options.headed,
808
+ slowMo: 0,
809
+ timeout: 3e4,
810
+ trace: false,
811
+ recordHar: false,
812
+ runDir: process.cwd(),
813
+ engine,
814
+ channel,
815
+ viewport
816
+ });
817
+ const driver = createPlaywrightDriver(session.page);
818
+ try {
819
+ await driver.goto(url, { waitUntil: "networkidle" });
820
+ const result = await analyzePage(driver);
821
+ if (options.json) {
822
+ console.log(JSON.stringify(result, null, 2));
823
+ } else {
824
+ console.log(`
795
825
  ${chalk10.bold("Page Analysis:")} ${result.title}`);
796
- console.log(` ${chalk10.gray("URL:")} ${result.url}
826
+ console.log(` ${chalk10.gray("URL:")} ${result.url}
797
827
  `);
798
- if (result.forms.length > 0) {
799
- console.log(chalk10.bold(" Forms:"));
800
- for (const form of result.forms) {
801
- const method = form.method ? chalk10.cyan(form.method) : "";
802
- const action = form.action ? chalk10.gray(form.action) : "";
803
- console.log(` [${form.index}] ${method} ${action} (${form.fieldCount} fields)`);
804
- }
805
- console.log();
806
- }
807
- if (result.elements.length > 0) {
808
- console.log(chalk10.bold(" Interactive Elements:"));
809
- for (const el of result.elements) {
810
- const tag = chalk10.cyan(el.tag);
811
- const type = el.type ? chalk10.gray(`[${el.type}]`) : "";
812
- const bestSelector = el.selectors.testId ?? el.selectors.label ?? el.selectors.ariaLabel ?? el.selectors.css ?? el.selectors.name ?? "";
813
- const selectorStr = bestSelector ? chalk10.yellow(bestSelector) : chalk10.gray("(no selector)");
814
- const req = el.required ? chalk10.red(" *") : "";
815
- const form = el.formGroup !== void 0 ? chalk10.gray(` form[${el.formGroup}]`) : "";
816
- console.log(` ${tag}${type} ${selectorStr}${req}${form}`);
817
- }
818
- console.log();
819
- }
820
- if (result.links.length > 0) {
821
- console.log(chalk10.bold(" Links:"));
822
- for (const link of result.links.slice(0, 20)) {
823
- const text = link.text || chalk10.gray("(no text)");
824
- console.log(` ${text} ${chalk10.gray("\u2192")} ${chalk10.blue(link.href)}`);
825
- }
826
- if (result.links.length > 20) {
827
- console.log(chalk10.gray(` ... and ${result.links.length - 20} more`));
828
- }
829
- console.log();
830
- }
831
- console.log(chalk10.gray(` ${result.elements.length} elements, ${result.forms.length} forms, ${result.links.length} links
828
+ if (result.forms.length > 0) {
829
+ console.log(chalk10.bold(" Forms:"));
830
+ for (const form of result.forms) {
831
+ const method = form.method ? chalk10.cyan(form.method) : "";
832
+ const action = form.action ? chalk10.gray(form.action) : "";
833
+ console.log(` [${form.index}] ${method} ${action} (${form.fieldCount} fields)`);
834
+ }
835
+ console.log();
836
+ }
837
+ if (result.elements.length > 0) {
838
+ console.log(chalk10.bold(" Interactive Elements:"));
839
+ for (const el of result.elements) {
840
+ const tag = chalk10.cyan(el.tag);
841
+ const type = el.type ? chalk10.gray(`[${el.type}]`) : "";
842
+ const bestSelector = el.selectors.testId ?? el.selectors.label ?? el.selectors.ariaLabel ?? el.selectors.css ?? el.selectors.name ?? "";
843
+ const selectorStr = bestSelector ? chalk10.yellow(bestSelector) : chalk10.gray("(no selector)");
844
+ const req = el.required ? chalk10.red(" *") : "";
845
+ const form = el.formGroup !== void 0 ? chalk10.gray(` form[${el.formGroup}]`) : "";
846
+ console.log(` ${tag}${type} ${selectorStr}${req}${form}`);
847
+ }
848
+ console.log();
849
+ }
850
+ if (result.links.length > 0) {
851
+ console.log(chalk10.bold(" Links:"));
852
+ for (const link of result.links.slice(0, 20)) {
853
+ const text = link.text || chalk10.gray("(no text)");
854
+ console.log(` ${text} ${chalk10.gray("\u2192")} ${chalk10.blue(link.href)}`);
855
+ }
856
+ if (result.links.length > 20) {
857
+ console.log(chalk10.gray(` ... and ${result.links.length - 20} more`));
858
+ }
859
+ console.log();
860
+ }
861
+ console.log(chalk10.gray(` ${result.elements.length} elements, ${result.forms.length} forms, ${result.links.length} links
832
862
  `));
863
+ }
864
+ } finally {
865
+ await closeBrowser(session);
866
+ }
867
+ }
868
+ function printMacElement(el) {
869
+ const role = chalk10.cyan(el.role);
870
+ const [best] = el.selectors;
871
+ const selectorStr = best ? chalk10.yellow(best) : chalk10.gray("(no selector)");
872
+ const label = el.title ?? el.description ?? el.value;
873
+ const labelStr = label ? ` ${chalk10.gray(`"${label}"`)}` : "";
874
+ const disabled = el.enabled === false ? chalk10.gray(" (disabled)") : "";
875
+ console.log(` ${role} ${selectorStr}${labelStr}${disabled}`);
876
+ }
877
+ async function runMacAnalyze(app, config, options) {
878
+ const allowedApps = config?.guardrails.allowedApps ?? [];
879
+ assertTargetAppAllowed(allowedApps, app);
880
+ const session = await launchMacSession({ app, timeoutMs: config?.browser.timeout });
881
+ try {
882
+ const result = await analyzeMacApp(session.client, { app: session.bundleId });
883
+ if (options.json) {
884
+ console.log(JSON.stringify(result, null, 2));
885
+ } else {
886
+ console.log(`
887
+ ${chalk10.bold("App Analysis:")} ${result.app}
888
+ `);
889
+ if (result.windows.length > 0) {
890
+ console.log(chalk10.bold(" Windows:"));
891
+ for (const win of result.windows) {
892
+ const title = win.title ? chalk10.gray(`"${win.title}"`) : chalk10.gray("(untitled)");
893
+ console.log(` ${title} ${chalk10.yellow(win.selector)}`);
894
+ }
895
+ console.log();
896
+ }
897
+ if (result.elements.length > 0) {
898
+ console.log(chalk10.bold(" Interactive Elements:"));
899
+ for (const el of result.elements) {
900
+ printMacElement(el);
833
901
  }
834
- } finally {
835
- await context.close();
836
- await browser.close();
902
+ console.log();
837
903
  }
904
+ if (result.menuItems.length > 0) {
905
+ console.log(chalk10.bold(" Menu Bar:"));
906
+ for (const el of result.menuItems) {
907
+ printMacElement(el);
908
+ }
909
+ console.log();
910
+ }
911
+ console.log(
912
+ chalk10.gray(
913
+ ` ${result.elements.length} elements, ${result.windows.length} windows, ${result.menuItems.length} menu items
914
+ `
915
+ )
916
+ );
917
+ }
918
+ } finally {
919
+ await session.client.close();
920
+ }
921
+ }
922
+ 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) => {
924
+ try {
925
+ if (options.app && url) {
926
+ throw new Error("Pass either a URL argument (web) or --app (macOS), not both.");
927
+ }
928
+ if (options.app) {
929
+ await runMacAnalyze(options.app, tryLoadConfig(options.config), options);
930
+ return;
931
+ }
932
+ if (url) {
933
+ await runWebAnalyze(url, options);
934
+ return;
935
+ }
936
+ const config = tryLoadConfig(options.config);
937
+ if (config?.target.type === "macos") {
938
+ await runMacAnalyze(config.target.app, config, options);
939
+ return;
940
+ }
941
+ 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."
943
+ );
838
944
  } catch (error) {
839
945
  const message = error instanceof Error ? error.message : "Analysis failed";
840
946
  if (options.json) {
@@ -908,7 +1014,7 @@ function buildGenerateCommand() {
908
1014
  } else if (options.output) {
909
1015
  let configDir;
910
1016
  try {
911
- const { loadConfig: loadConfig2 } = await import("./loader-5RDNTJHH.js");
1017
+ const { loadConfig: loadConfig2 } = await import("./loader-FCXPARP7.js");
912
1018
  const result = loadConfig2(options.config);
913
1019
  configDir = result.configDir;
914
1020
  } catch {