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