prowl-tools 0.1.6 → 0.1.8

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.
@@ -2,383 +2,13 @@ import {
2
2
  SUPPORTED_BROWSER_ENGINES,
3
3
  ensureAllowedDomain,
4
4
  huntSchema,
5
+ interpolateHunt,
5
6
  listHunts,
6
7
  loadConfig,
7
8
  loadHunt,
8
9
  loadHuntTags,
9
10
  resolveViewport
10
- } from "./chunk-WHAMB4TY.js";
11
-
12
- // src/config/interpolate.ts
13
- import crypto from "crypto";
14
- var VAR_PATTERN = /\{\{([A-Z0-9_]+)\}\}/g;
15
- function collectInterpolatedValues(input, vars, values) {
16
- if (typeof input === "string") {
17
- for (const match of input.matchAll(VAR_PATTERN)) {
18
- const varValue = vars[match[1]];
19
- if (varValue) values.add(varValue);
20
- }
21
- return;
22
- }
23
- if (Array.isArray(input)) {
24
- for (const item of input) {
25
- collectInterpolatedValues(item, vars, values);
26
- }
27
- return;
28
- }
29
- if (input && typeof input === "object") {
30
- for (const [key, value] of Object.entries(input)) {
31
- collectInterpolatedValues(key, vars, values);
32
- collectInterpolatedValues(value, vars, values);
33
- }
34
- }
35
- }
36
- function interpolateString(input, vars) {
37
- const usedVars = [];
38
- const value = input.replace(VAR_PATTERN, (_, name) => {
39
- const varValue = vars[name];
40
- if (varValue === void 0) {
41
- throw new Error(`Missing variable: ${name}`);
42
- }
43
- usedVars.push(name);
44
- return varValue;
45
- });
46
- return { value, usedVars };
47
- }
48
- var RANDOM_FIRST_NAMES = ["Alex", "Jordan", "Morgan", "Taylor", "Casey", "Riley", "Quinn", "Avery"];
49
- var RANDOM_LAST_NAMES = ["Smith", "Johnson", "Brown", "Davis", "Wilson", "Clark", "Hall", "Young"];
50
- function generateRandomVars(randomSource) {
51
- const random = randomSource?.random ?? Math.random;
52
- const randomBytes = randomSource?.randomBytes ?? crypto.randomBytes;
53
- const randomUUID = randomSource?.randomUUID ?? crypto.randomUUID;
54
- const hex = randomBytes(4).toString("hex");
55
- const firstIndex = Math.floor(random() * RANDOM_FIRST_NAMES.length);
56
- const lastIndex = Math.floor(random() * RANDOM_LAST_NAMES.length);
57
- const num2 = Math.floor(random() * 9e3) + 1e3;
58
- const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
59
- let text = "";
60
- for (let i = 0; i < 8; i++) {
61
- text += chars[Math.floor(random() * chars.length)];
62
- }
63
- return {
64
- RANDOM_EMAIL: `prowl_${hex}@test.com`,
65
- RANDOM_NAME: `${RANDOM_FIRST_NAMES[firstIndex]} ${RANDOM_LAST_NAMES[lastIndex]}`,
66
- RANDOM_NUMBER: String(num2),
67
- RANDOM_UUID: randomUUID(),
68
- RANDOM_TEXT: text
69
- };
70
- }
71
- function interpolateStep(step, vars, stepPath2, redacted) {
72
- const isExplicitFill = (value) => typeof value.selector === "string" && typeof value.value === "string";
73
- const interpolateSinglePair = (record) => {
74
- const entries = Object.entries(record);
75
- if (entries.length !== 1) {
76
- throw new Error("Shorthand step expects exactly one key-value pair");
77
- }
78
- const [key, value] = entries[0];
79
- return {
80
- [interpolateString(key, vars).value]: interpolateString(value, vars).value
81
- };
82
- };
83
- if ("navigate" in step) {
84
- const result = interpolateString(step.navigate, vars);
85
- return { navigate: result.value };
86
- }
87
- if ("click" in step) {
88
- if (typeof step.click === "string") {
89
- return { click: interpolateString(step.click, vars).value };
90
- }
91
- const result = interpolateString(step.click.selector, vars);
92
- return { click: { selector: result.value } };
93
- }
94
- if ("fill" in step) {
95
- if (isExplicitFill(step.fill)) {
96
- const selectorResult = interpolateString(step.fill.selector, vars);
97
- const valueResult2 = interpolateString(step.fill.value, vars);
98
- if (valueResult2.usedVars.length > 0) {
99
- redacted.add(stepPath2);
100
- }
101
- return { fill: { selector: selectorResult.value, value: valueResult2.value } };
102
- }
103
- const [rawLabel, rawValue] = Object.entries(step.fill)[0] ?? [];
104
- if (rawLabel === void 0 || rawValue === void 0) {
105
- throw new Error("Shorthand fill expects exactly one key-value pair");
106
- }
107
- const labelResult = interpolateString(rawLabel, vars);
108
- const valueResult = interpolateString(rawValue, vars);
109
- if (valueResult.usedVars.length > 0) {
110
- redacted.add(stepPath2);
111
- }
112
- return {
113
- fill: {
114
- [labelResult.value]: valueResult.value
115
- }
116
- };
117
- }
118
- if ("type" in step) {
119
- const valueResult = interpolateString(step.type, vars);
120
- if (valueResult.usedVars.length > 0) {
121
- redacted.add(stepPath2);
122
- }
123
- return { type: valueResult.value };
124
- }
125
- if ("selectOption" in step) {
126
- const selectorResult = interpolateString(step.selectOption.selector, vars);
127
- const valueResult = interpolateString(step.selectOption.value, vars);
128
- return { selectOption: { selector: selectorResult.value, value: valueResult.value } };
129
- }
130
- if ("select" in step) {
131
- return { select: interpolateSinglePair(step.select) };
132
- }
133
- if ("press" in step) {
134
- const selectorResult = interpolateString(step.press.selector, vars);
135
- const keyResult = interpolateString(step.press.key, vars);
136
- return { press: { selector: selectorResult.value, key: keyResult.value } };
137
- }
138
- if ("onDialog" in step) {
139
- return { onDialog: { action: step.onDialog.action } };
140
- }
141
- if ("setInputFiles" in step) {
142
- const selectorResult = interpolateString(step.setInputFiles.selector, vars);
143
- const rawFiles = step.setInputFiles.files;
144
- const files = Array.isArray(rawFiles) ? rawFiles.map((f) => interpolateString(f, vars).value) : interpolateString(rawFiles, vars).value;
145
- return { setInputFiles: { selector: selectorResult.value, files } };
146
- }
147
- if ("runHunt" in step) {
148
- if (typeof step.runHunt === "string") {
149
- return { runHunt: interpolateString(step.runHunt, vars).value };
150
- }
151
- const nameResult = interpolateString(step.runHunt.name, vars);
152
- const interpolatedVars = {};
153
- for (const [key, value] of Object.entries(step.runHunt.vars ?? {})) {
154
- interpolatedVars[key] = interpolateString(value, vars).value;
155
- }
156
- return {
157
- runHunt: {
158
- name: nameResult.value,
159
- ...Object.keys(interpolatedVars).length > 0 ? { vars: interpolatedVars } : {}
160
- }
161
- };
162
- }
163
- if ("assert" in step) {
164
- if (step.assert.visible !== void 0) {
165
- return { assert: { visible: interpolateString(step.assert.visible, vars).value } };
166
- }
167
- if (step.assert.notVisible !== void 0) {
168
- return { assert: { notVisible: interpolateString(step.assert.notVisible, vars).value } };
169
- }
170
- if (step.assert.urlIncludes !== void 0) {
171
- return { assert: { urlIncludes: interpolateString(step.assert.urlIncludes, vars).value } };
172
- }
173
- if (step.assert.urlEquals !== void 0) {
174
- return { assert: { urlEquals: interpolateString(step.assert.urlEquals, vars).value } };
175
- }
176
- return step;
177
- }
178
- if ("wait" in step) {
179
- if (typeof step.wait === "string") {
180
- return { wait: interpolateString(step.wait, vars).value };
181
- }
182
- return {
183
- wait: {
184
- for: interpolateString(step.wait.for, vars).value,
185
- timeout: step.wait.timeout
186
- }
187
- };
188
- }
189
- if ("waitForSelector" in step) {
190
- const selectorResult = interpolateString(step.waitForSelector.selector, vars);
191
- return {
192
- waitForSelector: {
193
- selector: selectorResult.value,
194
- timeout: step.waitForSelector.timeout
195
- }
196
- };
197
- }
198
- if ("waitForUrl" in step) {
199
- const valueResult = interpolateString(step.waitForUrl.value, vars);
200
- return {
201
- waitForUrl: {
202
- value: valueResult.value,
203
- timeout: step.waitForUrl.timeout
204
- }
205
- };
206
- }
207
- if ("waitForNetworkIdle" in step) {
208
- return { waitForNetworkIdle: { timeout: step.waitForNetworkIdle.timeout } };
209
- }
210
- if ("hover" in step) {
211
- const selectorResult = interpolateString(step.hover.selector, vars);
212
- return { hover: { selector: selectorResult.value } };
213
- }
214
- if ("scroll" in step) {
215
- return { scroll: { direction: step.scroll.direction, amount: step.scroll.amount } };
216
- }
217
- if ("scrollTo" in step) {
218
- const selectorResult = interpolateString(step.scrollTo.selector, vars);
219
- return { scrollTo: { selector: selectorResult.value } };
220
- }
221
- if ("screenshot" in step) {
222
- return { screenshot: { name: step.screenshot.name } };
223
- }
224
- if ("if" in step) {
225
- const condition = step.if;
226
- const thenSteps = condition.then.map(
227
- (s, i) => interpolateStep(s, vars, `${stepPath2}.if.then.${i}`, redacted)
228
- );
229
- const elseSteps = condition.else?.map(
230
- (s, i) => interpolateStep(s, vars, `${stepPath2}.if.else.${i}`, redacted)
231
- );
232
- return {
233
- if: {
234
- ...condition.visible !== void 0 ? { visible: interpolateString(condition.visible, vars).value } : {},
235
- ...condition.notVisible !== void 0 ? { notVisible: interpolateString(condition.notVisible, vars).value } : {},
236
- then: thenSteps,
237
- ...elseSteps !== void 0 ? { else: elseSteps } : {}
238
- }
239
- };
240
- }
241
- if ("repeat" in step) {
242
- const repeat = step.repeat;
243
- const subSteps = repeat.steps.map(
244
- (s, i) => interpolateStep(s, vars, `${stepPath2}.repeat.steps.${i}`, redacted)
245
- );
246
- return {
247
- repeat: {
248
- ...repeat.times !== void 0 ? { times: repeat.times } : {},
249
- ...repeat.while !== void 0 ? {
250
- while: {
251
- ...repeat.while.visible !== void 0 ? { visible: interpolateString(repeat.while.visible, vars).value } : {},
252
- ...repeat.while.notVisible !== void 0 ? { notVisible: interpolateString(repeat.while.notVisible, vars).value } : {}
253
- }
254
- } : {},
255
- ...repeat.maxIterations !== void 0 ? { maxIterations: repeat.maxIterations } : {},
256
- steps: subSteps
257
- }
258
- };
259
- }
260
- if ("mockRoute" in step) {
261
- const mock = step.mockRoute;
262
- return {
263
- mockRoute: {
264
- url: interpolateString(mock.url, vars).value,
265
- response: {
266
- status: mock.response.status,
267
- ...mock.response.contentType !== void 0 ? { contentType: interpolateString(mock.response.contentType, vars).value } : {},
268
- ...mock.response.body !== void 0 ? { body: interpolateString(mock.response.body, vars).value } : {},
269
- ...mock.response.file !== void 0 ? { file: interpolateString(mock.response.file, vars).value } : {}
270
- }
271
- }
272
- };
273
- }
274
- if ("unmockRoute" in step) {
275
- if (typeof step.unmockRoute === "string") {
276
- return { unmockRoute: interpolateString(step.unmockRoute, vars).value };
277
- }
278
- return {
279
- unmockRoute: { url: interpolateString(step.unmockRoute.url, vars).value }
280
- };
281
- }
282
- if ("evalScript" in step) {
283
- if (typeof step.evalScript === "string") {
284
- return { evalScript: interpolateString(step.evalScript, vars).value };
285
- }
286
- return {
287
- evalScript: {
288
- expression: interpolateString(step.evalScript.expression, vars).value,
289
- ...step.evalScript.as !== void 0 ? { as: step.evalScript.as } : {}
290
- }
291
- };
292
- }
293
- if ("runScript" in step) {
294
- return {
295
- runScript: { file: interpolateString(step.runScript.file, vars).value }
296
- };
297
- }
298
- if ("assertScreenshot" in step) {
299
- return {
300
- assertScreenshot: {
301
- name: interpolateString(step.assertScreenshot.name, vars).value,
302
- ...step.assertScreenshot.threshold !== void 0 ? { threshold: step.assertScreenshot.threshold } : {}
303
- }
304
- };
305
- }
306
- if ("assertWithAI" in step) {
307
- return {
308
- assertWithAI: interpolateString(step.assertWithAI, vars).value
309
- };
310
- }
311
- if ("copyText" in step) {
312
- return {
313
- copyText: {
314
- selector: interpolateString(step.copyText.selector, vars).value,
315
- as: step.copyText.as
316
- }
317
- };
318
- }
319
- if ("waitForDownload" in step) {
320
- if (step.waitForDownload === null) {
321
- return { waitForDownload: null };
322
- }
323
- return {
324
- waitForDownload: {
325
- ...step.waitForDownload.filename !== void 0 ? { filename: interpolateString(step.waitForDownload.filename, vars).value } : {},
326
- ...step.waitForDownload.timeout !== void 0 ? { timeout: step.waitForDownload.timeout } : {}
327
- }
328
- };
329
- }
330
- return step;
331
- }
332
- function interpolateAssertion(assertion, vars) {
333
- if ("selectorExists" in assertion) {
334
- return { selectorExists: interpolateString(assertion.selectorExists, vars).value };
335
- }
336
- if ("selectorNotExists" in assertion) {
337
- return { selectorNotExists: interpolateString(assertion.selectorNotExists, vars).value };
338
- }
339
- if ("urlIncludes" in assertion) {
340
- return { urlIncludes: interpolateString(assertion.urlIncludes, vars).value };
341
- }
342
- if ("urlEquals" in assertion) {
343
- return { urlEquals: interpolateString(assertion.urlEquals, vars).value };
344
- }
345
- if ("noConsoleErrors" in assertion) {
346
- return { noConsoleErrors: assertion.noConsoleErrors };
347
- }
348
- if ("noNetworkErrors" in assertion) {
349
- return { noNetworkErrors: assertion.noNetworkErrors };
350
- }
351
- return assertion;
352
- }
353
- function interpolateHunt(hunt, env, randomVars = generateRandomVars()) {
354
- const redactedFillSteps = /* @__PURE__ */ new Set();
355
- const envVars = Object.fromEntries(
356
- Object.entries(env).filter(([, value]) => value !== void 0)
357
- );
358
- const baseVars = { ...randomVars, ...envVars };
359
- const resolvedHuntVars = {};
360
- for (const [key, value] of Object.entries(hunt.vars ?? {})) {
361
- resolvedHuntVars[key] = interpolateString(value, baseVars).value;
362
- }
363
- const vars = { ...baseVars, ...resolvedHuntVars };
364
- const redactionValues = /* @__PURE__ */ new Set();
365
- collectInterpolatedValues(hunt.steps, vars, redactionValues);
366
- collectInterpolatedValues(hunt.assertions, vars, redactionValues);
367
- const steps = hunt.steps.map(
368
- (step, index) => interpolateStep(step, vars, `${index}`, redactedFillSteps)
369
- );
370
- const assertions = hunt.assertions?.map((assertion) => interpolateAssertion(assertion, vars));
371
- return {
372
- hunt: {
373
- ...hunt,
374
- steps,
375
- assertions
376
- },
377
- redactedFillSteps,
378
- randomVars,
379
- redactionValues: [...redactionValues]
380
- };
381
- }
11
+ } from "./chunk-JFJQNJSJ.js";
382
12
 
383
13
  // src/config/target.ts
384
14
  import { execFileSync } from "child_process";
@@ -396,10 +26,17 @@ var WEB_ONLY_STEP_TYPES = /* @__PURE__ */ new Set([
396
26
  "select",
397
27
  "selectOption",
398
28
  "setInputFiles",
399
- "waitForDownload",
400
- "scroll"
401
- // directional scroll runs window.scrollBy (evaluate) — use scrollTo instead
29
+ "waitForDownload"
402
30
  ]);
31
+ var MACOS_UNSUPPORTED_STEP_TYPES = /* @__PURE__ */ new Set(["scroll"]);
32
+ function macosUnsupportedReason(step) {
33
+ for (const type of MACOS_UNSUPPORTED_STEP_TYPES) {
34
+ if (type in step) {
35
+ return type;
36
+ }
37
+ }
38
+ return null;
39
+ }
403
40
  function webOnlyReason(step) {
404
41
  for (const type of WEB_ONLY_STEP_TYPES) {
405
42
  if (type in step) {
@@ -435,6 +72,14 @@ function assertStepsSupportedByTarget(steps, target) {
435
72
  `Step "${reason}" is not supported by the ${label} target. It is web-only; use a portable step (click, fill, type, press, wait, assert visible, screenshot, etc.).`
436
73
  );
437
74
  }
75
+ if (target === "macos") {
76
+ const macReason = macosUnsupportedReason(step);
77
+ if (macReason) {
78
+ throw new Error(
79
+ `Step "${macReason}" is not supported by the macOS target. Directional scroll is a touch swipe available on the iOS and Android targets; there is no macOS accessibility equivalent (use scrollTo to bring a specific element into view instead).`
80
+ );
81
+ }
82
+ }
438
83
  if ("if" in step) {
439
84
  assertStepsSupportedByTarget(step.if.then, target);
440
85
  if (step.if.else) {
@@ -689,6 +334,9 @@ function createMacDriver(client, options = {}) {
689
334
  async hover(selector) {
690
335
  await query("hover", selector);
691
336
  },
337
+ scroll() {
338
+ return rejectUnsupported("scroll");
339
+ },
692
340
  async scrollIntoView(selector) {
693
341
  await query("scrollTo", selector);
694
342
  },
@@ -757,27 +405,71 @@ function createMacDriver(client, options = {}) {
757
405
  };
758
406
  }
759
407
 
408
+ // src/browser/macdriver-release.ts
409
+ import os from "os";
410
+ import path2 from "path";
411
+ var HELPER_BINARY = "prowl-macdriver";
412
+ var MACDRIVER_VERSION = "0.1.0";
413
+ var MACDRIVER_REPO = "prowl-tools/prowl";
414
+ var MACDRIVER_SIGNING_IDENTIFIER = "tools.prowl.macdriver";
415
+ var MACDRIVER_SIGNING_AUTHORITY_PREFIX = "Developer ID Application: Genkei Labs";
416
+ var MACDRIVER_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z][0-9A-Za-z.-]*)?(?:\+[0-9A-Za-z][0-9A-Za-z.-]*)?$/;
417
+ function validateMacdriverVersion(version) {
418
+ if (!MACDRIVER_VERSION_PATTERN.test(version)) {
419
+ throw new Error(
420
+ `Invalid prowl-macdriver version "${version}". Expected a release version like 0.1.0.`
421
+ );
422
+ }
423
+ return version;
424
+ }
425
+ function macdriverReleaseTag(version = MACDRIVER_VERSION) {
426
+ return `macdriver-v${validateMacdriverVersion(version)}`;
427
+ }
428
+ function macdriverAssetName(version = MACDRIVER_VERSION) {
429
+ return `prowl-macdriver-v${validateMacdriverVersion(version)}-universal.zip`;
430
+ }
431
+ function macdriverChecksumName(version = MACDRIVER_VERSION) {
432
+ return `${macdriverAssetName(version)}.sha256`;
433
+ }
434
+ function macdriverAssetUrl(assetName, version = MACDRIVER_VERSION) {
435
+ return `https://github.com/${MACDRIVER_REPO}/releases/download/${macdriverReleaseTag(version)}/${assetName}`;
436
+ }
437
+ function macdriverInstallRoot(homedir = os.homedir()) {
438
+ return path2.join(homedir, ".prowl", "macdriver");
439
+ }
440
+ function macdriverVersionDir(version = MACDRIVER_VERSION, homedir = os.homedir()) {
441
+ const root = path2.resolve(macdriverInstallRoot(homedir));
442
+ const versionDir = path2.resolve(root, validateMacdriverVersion(version));
443
+ if (!versionDir.startsWith(root + path2.sep)) {
444
+ throw new Error(`Resolved prowl-macdriver version directory escaped install root: ${versionDir}`);
445
+ }
446
+ return versionDir;
447
+ }
448
+ function macdriverInstalledBinary(version = MACDRIVER_VERSION, homedir = os.homedir()) {
449
+ return path2.join(macdriverVersionDir(version, homedir), HELPER_BINARY);
450
+ }
451
+
760
452
  // src/browser/mac-helper.ts
761
453
  import { spawn } from "child_process";
762
454
  import fs2 from "fs";
763
- import path2 from "path";
455
+ import os2 from "os";
456
+ import path3 from "path";
764
457
  import { fileURLToPath } from "url";
765
- var HELPER_BINARY = "prowl-macdriver";
766
458
  function macdriverBuildInstructions() {
767
- return "The macOS target requires the experimental `prowl-macdriver` helper, which is not shipped in the npm package. Build it locally:\n cd macdriver && swift build -c release\nor point Prowl at a prebuilt binary via the PROWL_MACDRIVER_BIN environment variable.";
459
+ 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.";
768
460
  }
769
461
  function getPackageRoot() {
770
- let dir = path2.dirname(fileURLToPath(import.meta.url));
771
- const root = path2.parse(dir).root;
462
+ let dir = path3.dirname(fileURLToPath(import.meta.url));
463
+ const root = path3.parse(dir).root;
772
464
  while (dir !== root) {
773
- if (fs2.existsSync(path2.join(dir, "package.json"))) {
465
+ if (fs2.existsSync(path3.join(dir, "package.json"))) {
774
466
  return dir;
775
467
  }
776
- dir = path2.dirname(dir);
468
+ dir = path3.dirname(dir);
777
469
  }
778
470
  return root;
779
471
  }
780
- function resolveHelperBinary(env = process.env) {
472
+ function resolveHelperBinary(env = process.env, options = {}) {
781
473
  const override = env.PROWL_MACDRIVER_BIN;
782
474
  if (override) {
783
475
  if (!fs2.existsSync(override)) {
@@ -788,10 +480,15 @@ ${macdriverBuildInstructions()}`
788
480
  }
789
481
  return override;
790
482
  }
483
+ const homedir = options.homedir ?? os2.homedir();
484
+ const userBinary = macdriverInstalledBinary(MACDRIVER_VERSION, homedir);
485
+ if (fs2.existsSync(userBinary)) {
486
+ return userBinary;
487
+ }
791
488
  const root = getPackageRoot();
792
489
  const candidates = [
793
- path2.join(root, "macdriver", ".build", "release", HELPER_BINARY),
794
- path2.join(root, "macdriver", ".build", "debug", HELPER_BINARY)
490
+ path3.join(root, "macdriver", ".build", "release", HELPER_BINARY),
491
+ path3.join(root, "macdriver", ".build", "debug", HELPER_BINARY)
795
492
  ];
796
493
  for (const candidate of candidates) {
797
494
  if (fs2.existsSync(candidate)) {
@@ -1246,6 +943,103 @@ function parseSnapshot(xml) {
1246
943
 
1247
944
  // src/browser/android-driver.ts
1248
945
  import fs3 from "fs";
946
+
947
+ // src/browser/touch-gestures.ts
948
+ var DEFAULT_SWIPE_FRACTION = 0.75;
949
+ var SCROLL_TO_PROBE_SWIPE_FRACTION = 0.6;
950
+ var MAX_SWIPE_FRACTION = 0.9;
951
+ var SWIPE_HOLD_MS = 100;
952
+ var SWIPE_MOVE_DURATION_MS = 300;
953
+ var SCROLL_TO_SWEEP_DEPTH = 10;
954
+ var SCROLL_TO_PROBE_DIRECTIONS = [
955
+ ...Array.from({ length: SCROLL_TO_SWEEP_DEPTH }, () => "down"),
956
+ ...Array.from({ length: SCROLL_TO_SWEEP_DEPTH * 2 }, () => "up")
957
+ ];
958
+ var MAX_SCROLL_TO_SWIPES = SCROLL_TO_PROBE_DIRECTIONS.length;
959
+ var OPPOSITE_SWIPE_DIRECTIONS = {
960
+ up: "down",
961
+ down: "up",
962
+ left: "right",
963
+ right: "left"
964
+ };
965
+ async function probeScrollIntoView({
966
+ isVisible,
967
+ swipe,
968
+ directions = SCROLL_TO_PROBE_DIRECTIONS
969
+ }) {
970
+ if (await isVisible()) {
971
+ return true;
972
+ }
973
+ for (const direction of directions) {
974
+ await swipe(direction);
975
+ if (await isVisible()) {
976
+ return true;
977
+ }
978
+ }
979
+ return false;
980
+ }
981
+ function toScreenSize(value, source) {
982
+ const record = value ?? {};
983
+ const width = Number(record.width);
984
+ const height = Number(record.height);
985
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
986
+ throw new Error(`${source} did not return a usable screen size`);
987
+ }
988
+ return { width, height };
989
+ }
990
+ function isVertical(direction) {
991
+ return direction === "up" || direction === "down";
992
+ }
993
+ function swipeDistanceFor(direction, size, amount) {
994
+ if (amount !== void 0 && !Number.isFinite(amount)) {
995
+ throw new Error("scroll amount must be a finite number");
996
+ }
997
+ const axis = isVertical(direction) ? size.height : size.width;
998
+ const requested = amount === void 0 ? axis * DEFAULT_SWIPE_FRACTION : Math.abs(amount);
999
+ const max = axis * MAX_SWIPE_FRACTION;
1000
+ return Math.max(1, Math.round(Math.min(requested, max)));
1001
+ }
1002
+ function scrollToProbeDistanceFor(direction, size) {
1003
+ const axis = isVertical(direction) ? size.height : size.width;
1004
+ return Math.max(1, Math.round(axis * SCROLL_TO_PROBE_SWIPE_FRACTION));
1005
+ }
1006
+ function swipeEndpoints(direction, size, distance) {
1007
+ const cx = Math.round(size.width / 2);
1008
+ const cy = Math.round(size.height / 2);
1009
+ const half = Math.round(distance / 2);
1010
+ switch (direction) {
1011
+ case "down":
1012
+ return { start: { x: cx, y: cy + half }, end: { x: cx, y: cy - half } };
1013
+ case "up":
1014
+ return { start: { x: cx, y: cy - half }, end: { x: cx, y: cy + half } };
1015
+ case "right":
1016
+ return { start: { x: cx + half, y: cy }, end: { x: cx - half, y: cy } };
1017
+ case "left":
1018
+ return { start: { x: cx - half, y: cy }, end: { x: cx + half, y: cy } };
1019
+ }
1020
+ }
1021
+ function buildSwipeActions(start, end) {
1022
+ return {
1023
+ type: "pointer",
1024
+ id: "finger1",
1025
+ parameters: { pointerType: "touch" },
1026
+ actions: [
1027
+ { type: "pointerMove", duration: 0, x: start.x, y: start.y, origin: "viewport" },
1028
+ { type: "pointerDown", button: 0 },
1029
+ { type: "pause", duration: SWIPE_HOLD_MS },
1030
+ { type: "pointerMove", duration: SWIPE_MOVE_DURATION_MS, x: end.x, y: end.y, origin: "viewport" },
1031
+ { type: "pointerUp", button: 0 }
1032
+ ]
1033
+ };
1034
+ }
1035
+ function buildDirectionalSwipe(direction, size, amount) {
1036
+ const normalizedDirection = amount !== void 0 && amount < 0 ? OPPOSITE_SWIPE_DIRECTIONS[direction] : direction;
1037
+ const distance = swipeDistanceFor(normalizedDirection, size, amount);
1038
+ const { start, end } = swipeEndpoints(normalizedDirection, size, distance);
1039
+ return { actions: buildSwipeActions(start, end), distance, start, end };
1040
+ }
1041
+
1042
+ // src/browser/android-driver.ts
1249
1043
  var ANDROID_CAPABILITIES = /* @__PURE__ */ new Set([
1250
1044
  "query",
1251
1045
  "interact",
@@ -1360,6 +1154,11 @@ function createAndroidDriver(client, options = {}) {
1360
1154
  async function fillSelector(selector, value) {
1361
1155
  await client.setValue(await resolveOne(selector), value);
1362
1156
  }
1157
+ async function swipe(direction, amount, size) {
1158
+ const actualSize = size ?? await client.windowSize();
1159
+ const { actions } = buildDirectionalSwipe(direction, actualSize, amount);
1160
+ await client.performActions(actions);
1161
+ }
1363
1162
  return {
1364
1163
  capabilities: ANDROID_CAPABILITIES,
1365
1164
  // navigation -----------------------------------------------------------
@@ -1397,8 +1196,31 @@ function createAndroidDriver(client, options = {}) {
1397
1196
  hover() {
1398
1197
  return rejectUnsupported("hover");
1399
1198
  },
1400
- scrollIntoView() {
1401
- return rejectUnsupported("scrollTo");
1199
+ // Screen-centred swipe via the W3C actions endpoint (PROWL-080). Direction
1200
+ // semantics match the web step: scrolling "down" reveals lower content, so
1201
+ // the finger drags up. See ./touch-gestures.ts.
1202
+ async scroll(direction, amount) {
1203
+ await swipe(direction, amount);
1204
+ },
1205
+ // Resolve the element, short-circuiting if it is already present in the
1206
+ // hierarchy; otherwise use the shared bounded down/up mobile probe before
1207
+ // failing with a message naming the selector and attempts.
1208
+ async scrollIntoView(selector) {
1209
+ const query = parseAndroidSelector(selector);
1210
+ let probeSize;
1211
+ const found = await probeScrollIntoView({
1212
+ isVisible: async () => (await client.findElements(query)).length > 0,
1213
+ swipe: async (direction) => {
1214
+ probeSize ??= await client.windowSize();
1215
+ await swipe(direction, scrollToProbeDistanceFor(direction, probeSize), probeSize);
1216
+ }
1217
+ });
1218
+ if (found) {
1219
+ return;
1220
+ }
1221
+ throw new Error(
1222
+ `scrollTo: element "${selector}" not visible after ${MAX_SCROLL_TO_SWIPES} scroll attempts on the Android target`
1223
+ );
1402
1224
  },
1403
1225
  setInputFiles() {
1404
1226
  return rejectUnsupported("setInputFiles");
@@ -1721,12 +1543,12 @@ var Uia2Transport = class {
1721
1543
  * {@link Uia2HttpError} on a non-2xx response, or a timeout error when the
1722
1544
  * per-request deadline elapses.
1723
1545
  */
1724
- async request(method, path16, body, timeoutMs) {
1546
+ async request(method, path18, body, timeoutMs) {
1725
1547
  const requestTimeoutMs = timeoutMs ?? this.requestTimeoutMs;
1726
1548
  const controller = new AbortController();
1727
1549
  const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
1728
1550
  timer.unref?.();
1729
- const url = `${this.baseUrl}${path16}`;
1551
+ const url = `${this.baseUrl}${path18}`;
1730
1552
  let response;
1731
1553
  try {
1732
1554
  response = await this.fetchImpl(url, {
@@ -1738,7 +1560,7 @@ var Uia2Transport = class {
1738
1560
  } catch (error) {
1739
1561
  if (controller.signal.aborted) {
1740
1562
  const shown = requestTimeoutMs >= 1e3 ? `${Math.round(requestTimeoutMs / 1e3)}s` : `${requestTimeoutMs}ms`;
1741
- throw new Error(`uiautomator2 request ${method} ${path16} timed out after ${shown}`);
1563
+ throw new Error(`uiautomator2 request ${method} ${path18} timed out after ${shown}`);
1742
1564
  }
1743
1565
  throw error instanceof Error ? error : new Error(String(error));
1744
1566
  } finally {
@@ -1749,7 +1571,7 @@ var Uia2Transport = class {
1749
1571
  if (!response.ok) {
1750
1572
  const wdError = extractWebdriverError(parsed);
1751
1573
  throw new Uia2HttpError(
1752
- `uiautomator2 ${method} ${path16} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
1574
+ `uiautomator2 ${method} ${path18} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
1753
1575
  response.status,
1754
1576
  wdError
1755
1577
  );
@@ -1832,10 +1654,10 @@ function sleep(ms) {
1832
1654
  }
1833
1655
  function createUia2AgentClient(transport, sessionId, options = {}) {
1834
1656
  const base = `/session/${sessionId}`;
1835
- async function locate(query, path16) {
1657
+ async function locate(query, path18) {
1836
1658
  return transport.request(
1837
1659
  "POST",
1838
- `${base}${path16}`,
1660
+ `${base}${path18}`,
1839
1661
  androidQueryToLocator(query, { appPackage: options.appPackage })
1840
1662
  );
1841
1663
  }
@@ -1870,6 +1692,13 @@ function createUia2AgentClient(transport, sessionId, options = {}) {
1870
1692
  async pressKeyCode(keyCode) {
1871
1693
  await transport.request("POST", `${base}/appium/device/press_keycode`, { keycode: keyCode });
1872
1694
  },
1695
+ async windowSize() {
1696
+ const value = await transport.request("GET", `${base}/window/current/size`);
1697
+ return toScreenSize(value, "uiautomator2 /window/current/size");
1698
+ },
1699
+ async performActions(actions) {
1700
+ await transport.request("POST", `${base}/actions`, { actions: [actions] });
1701
+ },
1873
1702
  async screenshotPng() {
1874
1703
  const value = await transport.request("GET", `${base}/screenshot`);
1875
1704
  if (typeof value !== "string") {
@@ -1894,7 +1723,7 @@ function createUia2AgentClient(transport, sessionId, options = {}) {
1894
1723
  import { createRequire } from "module";
1895
1724
  import { execFile as execFile2 } from "child_process";
1896
1725
  import fs4 from "fs";
1897
- import path3 from "path";
1726
+ import path4 from "path";
1898
1727
  var UIA2_REMOTE_PORT = 6790;
1899
1728
  function resolveAgentApks(requireFn = createRequire(import.meta.url)) {
1900
1729
  let pkgJsonPath;
@@ -1905,10 +1734,10 @@ function resolveAgentApks(requireFn = createRequire(import.meta.url)) {
1905
1734
  "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"
1906
1735
  );
1907
1736
  }
1908
- const pkgDir = path3.dirname(pkgJsonPath);
1737
+ const pkgDir = path4.dirname(pkgJsonPath);
1909
1738
  const version = requireFn(pkgJsonPath).version;
1910
- const serverApk = path3.join(pkgDir, "apks", `appium-uiautomator2-server-v${version}.apk`);
1911
- const testApk = path3.join(pkgDir, "apks", "appium-uiautomator2-server-debug-androidTest.apk");
1739
+ const serverApk = path4.join(pkgDir, "apks", `appium-uiautomator2-server-v${version}.apk`);
1740
+ const testApk = path4.join(pkgDir, "apks", "appium-uiautomator2-server-debug-androidTest.apk");
1912
1741
  for (const apk of [serverApk, testApk]) {
1913
1742
  if (!fs4.existsSync(apk)) {
1914
1743
  throw new Error(`Expected uiautomator2 agent APK is missing: ${apk}. Reinstall dependencies.`);
@@ -1956,7 +1785,7 @@ async function resolvePackage(app, runner, serial, aaptResolver, allowedApps) {
1956
1785
  assertAndroidAppAllowed(allowedApps, app);
1957
1786
  return app;
1958
1787
  }
1959
- const apkPath = path3.resolve(app);
1788
+ const apkPath = path4.resolve(app);
1960
1789
  if (!fs4.existsSync(apkPath)) {
1961
1790
  throw new Error(`APK not found: ${apkPath}`);
1962
1791
  }
@@ -2017,6 +1846,7 @@ async function launchAndroidSession(options) {
2017
1846
  readyDeadlineMs,
2018
1847
  appPackage: pkg
2019
1848
  });
1849
+ await launchPackage(runner, serial, pkg);
2020
1850
  const driver = createAndroidDriver(client, { appLabel: pkg });
2021
1851
  return { client, driver, package: pkg, serial, teardown };
2022
1852
  } catch (error) {
@@ -2037,6 +1867,7 @@ var IOS_CAPABILITIES = /* @__PURE__ */ new Set([
2037
1867
  ]);
2038
1868
  var WAIT_POLL_INTERVAL_MS2 = 250;
2039
1869
  var DEFAULT_WAIT_TIMEOUT_MS2 = 5e3;
1870
+ var DISPLAYED_CHECK_CONCURRENCY = 4;
2040
1871
  var IOS_PRESS_KEYS = ["backspace", "del", "delete", "enter", "home", "return"];
2041
1872
  function escapePredicateArg(value) {
2042
1873
  return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
@@ -2111,6 +1942,64 @@ function createIosDriver(client, options) {
2111
1942
  async function fillSelector(selector, value) {
2112
1943
  await client.setValue(await resolveOne(selector), value);
2113
1944
  }
1945
+ async function swipe(direction, amount, size) {
1946
+ const actualSize = size ?? await client.windowSize();
1947
+ const { actions } = buildDirectionalSwipe(direction, actualSize, amount);
1948
+ await client.performActions(actions);
1949
+ }
1950
+ async function visibleElementIds(query) {
1951
+ const ids = await client.findElements(query);
1952
+ const visible = Array.from({ length: ids.length }, () => null);
1953
+ let nextIndex = 0;
1954
+ const workerCount = Math.min(DISPLAYED_CHECK_CONCURRENCY, ids.length);
1955
+ await Promise.all(
1956
+ Array.from({ length: workerCount }, async () => {
1957
+ for (; ; ) {
1958
+ const index = nextIndex;
1959
+ nextIndex += 1;
1960
+ if (index >= ids.length) {
1961
+ return;
1962
+ }
1963
+ const id = ids[index];
1964
+ if (await client.isDisplayed(id)) {
1965
+ visible[index] = id;
1966
+ }
1967
+ }
1968
+ })
1969
+ );
1970
+ return visible.filter((id) => id !== null);
1971
+ }
1972
+ async function hasVisibleElement(query) {
1973
+ const ids = await client.findElements(query);
1974
+ if (ids.length === 0) {
1975
+ return false;
1976
+ }
1977
+ if (await client.isDisplayed(ids[0])) {
1978
+ return true;
1979
+ }
1980
+ let found = false;
1981
+ let nextIndex = 1;
1982
+ const workerCount = Math.min(DISPLAYED_CHECK_CONCURRENCY, ids.length - 1);
1983
+ await Promise.all(
1984
+ Array.from({ length: workerCount }, async () => {
1985
+ for (; ; ) {
1986
+ if (found) {
1987
+ return;
1988
+ }
1989
+ const index = nextIndex;
1990
+ nextIndex += 1;
1991
+ if (index >= ids.length) {
1992
+ return;
1993
+ }
1994
+ if (await client.isDisplayed(ids[index])) {
1995
+ found = true;
1996
+ return;
1997
+ }
1998
+ }
1999
+ })
2000
+ );
2001
+ return found;
2002
+ }
2114
2003
  async function pressKey(key) {
2115
2004
  const name = key.trim().toLowerCase();
2116
2005
  if (name === "enter" || name === "return") {
@@ -2142,6 +2031,9 @@ function createIosDriver(client, options) {
2142
2031
  async count(selector) {
2143
2032
  return (await client.findElements(parseIosSelector(selector))).length;
2144
2033
  },
2034
+ async visibleCount(selector) {
2035
+ return (await visibleElementIds(parseIosSelector(selector))).length;
2036
+ },
2145
2037
  async textContent(selector) {
2146
2038
  const id = await client.findElement(parseIosSelector(selector));
2147
2039
  if (id === null) {
@@ -2166,15 +2058,38 @@ function createIosDriver(client, options) {
2166
2058
  hover() {
2167
2059
  return rejectUnsupported("hover");
2168
2060
  },
2169
- scrollIntoView() {
2170
- return rejectUnsupported("scrollTo");
2061
+ // Screen-centred swipe via the W3C actions endpoint (PROWL-080). Direction
2062
+ // semantics match the web step: scrolling "down" reveals lower content, so
2063
+ // the finger drags up. See ./touch-gestures.ts.
2064
+ async scroll(direction, amount) {
2065
+ await swipe(direction, amount);
2066
+ },
2067
+ // Resolve the element, short-circuiting only if WDA reports a matching
2068
+ // element displayed in the viewport; hierarchy-only matches can be offscreen.
2069
+ // Otherwise use the shared bounded down/up mobile probe before failing.
2070
+ async scrollIntoView(selector) {
2071
+ const query = parseIosSelector(selector);
2072
+ let probeSize;
2073
+ const found = await probeScrollIntoView({
2074
+ isVisible: () => hasVisibleElement(query),
2075
+ swipe: async (direction) => {
2076
+ probeSize ??= await client.windowSize();
2077
+ await swipe(direction, scrollToProbeDistanceFor(direction, probeSize), probeSize);
2078
+ }
2079
+ });
2080
+ if (found) {
2081
+ return;
2082
+ }
2083
+ throw new Error(
2084
+ `scrollTo: element "${selector}" not visible after ${MAX_SCROLL_TO_SWIPES} scroll attempts on the iOS target`
2085
+ );
2171
2086
  },
2172
2087
  setInputFiles() {
2173
2088
  return rejectUnsupported("setInputFiles");
2174
2089
  },
2175
2090
  // semantic locators ----------------------------------------------------
2176
2091
  async countByRole(role, name) {
2177
- return (await client.findElements({ by: "role", role, name })).length;
2092
+ return (await visibleElementIds({ by: "role", role, name })).length;
2178
2093
  },
2179
2094
  async clickFirstByRole(role, name) {
2180
2095
  const id = await client.findElement({ by: "role", role, name });
@@ -2184,7 +2099,7 @@ function createIosDriver(client, options) {
2184
2099
  await client.click(id);
2185
2100
  },
2186
2101
  async countByLabel(label) {
2187
- return (await client.findElements({ by: "label", value: label })).length;
2102
+ return (await visibleElementIds({ by: "label", value: label })).length;
2188
2103
  },
2189
2104
  async fillFirstByLabel(label, value) {
2190
2105
  const id = await client.findElement({ by: "label", value: label });
@@ -2202,7 +2117,7 @@ function createIosDriver(client, options) {
2202
2117
  const timeoutMs = waitOptions?.timeout ?? DEFAULT_WAIT_TIMEOUT_MS2;
2203
2118
  const deadline = Date.now() + timeoutMs;
2204
2119
  for (; ; ) {
2205
- if ((await client.findElements(query)).length > 0) {
2120
+ if (await hasVisibleElement(query)) {
2206
2121
  return;
2207
2122
  }
2208
2123
  if (Date.now() >= deadline) {
@@ -2250,8 +2165,8 @@ function createIosDriver(client, options) {
2250
2165
  import { execFile as execFile3, spawn as spawn3 } from "child_process";
2251
2166
  import { mkdir, readFile, rm, writeFile } from "fs/promises";
2252
2167
  import net from "net";
2253
- import os from "os";
2254
- import path4 from "path";
2168
+ import os3 from "os";
2169
+ import path5 from "path";
2255
2170
  var spawnXcrunProcess = (args, options) => {
2256
2171
  const child = spawn3("xcrun", args, {
2257
2172
  stdio: "ignore",
@@ -2265,7 +2180,7 @@ var spawnXcrunProcess = (args, options) => {
2265
2180
  }
2266
2181
  };
2267
2182
  };
2268
- var DEFAULT_SIMULATOR_LOCK_ROOT = path4.join(os.tmpdir(), "prowl-ios-simulator-locks");
2183
+ var DEFAULT_SIMULATOR_LOCK_ROOT = path5.join(os3.tmpdir(), "prowl-ios-simulator-locks");
2269
2184
  var SIMULATOR_LOCK_OWNER_FILE = "owner.json";
2270
2185
  var execFileXcrunRunner = (args, options) => new Promise((resolve) => {
2271
2186
  execFile3(
@@ -2305,7 +2220,7 @@ function isProcessAlive(pid) {
2305
2220
  }
2306
2221
  async function removeStaleSimulatorLock(lockPath) {
2307
2222
  try {
2308
- const ownerText = await readFile(path4.join(lockPath, SIMULATOR_LOCK_OWNER_FILE), "utf8");
2223
+ const ownerText = await readFile(path5.join(lockPath, SIMULATOR_LOCK_OWNER_FILE), "utf8");
2309
2224
  const owner = JSON.parse(ownerText);
2310
2225
  if (typeof owner.pid === "number" && Number.isInteger(owner.pid) && owner.pid > 0 && !isProcessAlive(owner.pid)) {
2311
2226
  await rm(lockPath, { recursive: true, force: true });
@@ -2323,7 +2238,7 @@ function simulatorReservedError(udid) {
2323
2238
  }
2324
2239
  async function reserveSimulatorUdid(udid, options = {}) {
2325
2240
  const lockRoot = options.lockRoot ?? DEFAULT_SIMULATOR_LOCK_ROOT;
2326
- const lockPath = path4.join(lockRoot, simulatorLockName(udid));
2241
+ const lockPath = path5.join(lockRoot, simulatorLockName(udid));
2327
2242
  await mkdir(lockRoot, { recursive: true });
2328
2243
  for (let attempt = 0; attempt < 2; attempt += 1) {
2329
2244
  try {
@@ -2347,7 +2262,7 @@ async function reserveSimulatorUdid(udid, options = {}) {
2347
2262
  };
2348
2263
  try {
2349
2264
  await writeFile(
2350
- path4.join(lockPath, SIMULATOR_LOCK_OWNER_FILE),
2265
+ path5.join(lockPath, SIMULATOR_LOCK_OWNER_FILE),
2351
2266
  `${JSON.stringify({ pid: process.pid, udid, createdAt: (/* @__PURE__ */ new Date()).toISOString() })}
2352
2267
  `,
2353
2268
  { flag: "wx" }
@@ -2539,15 +2454,15 @@ var WdaTransport = class {
2539
2454
  * {@link WdaHttpError} on a non-2xx response, or a timeout error when the
2540
2455
  * per-request deadline elapses.
2541
2456
  */
2542
- async requestFull(method, path16, body, timeoutMs) {
2457
+ async requestFull(method, path18, body, timeoutMs) {
2543
2458
  const requestTimeoutMs = timeoutMs ?? this.requestTimeoutMs;
2544
2459
  const controller = new AbortController();
2545
2460
  const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
2546
2461
  timer.unref?.();
2547
- const url = `${this.baseUrl}${path16}`;
2462
+ const url = `${this.baseUrl}${path18}`;
2548
2463
  const timeoutError = () => {
2549
2464
  const shown = requestTimeoutMs >= 1e3 ? `${Math.round(requestTimeoutMs / 1e3)}s` : `${requestTimeoutMs}ms`;
2550
- return new Error(`WebDriverAgent request ${method} ${path16} timed out after ${shown}`);
2465
+ return new Error(`WebDriverAgent request ${method} ${path18} timed out after ${shown}`);
2551
2466
  };
2552
2467
  let response;
2553
2468
  try {
@@ -2579,7 +2494,7 @@ var WdaTransport = class {
2579
2494
  if (!response.ok) {
2580
2495
  const wdError = extractWebdriverError2(parsed);
2581
2496
  throw new WdaHttpError(
2582
- `WebDriverAgent ${method} ${path16} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
2497
+ `WebDriverAgent ${method} ${path18} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
2583
2498
  response.status,
2584
2499
  wdError
2585
2500
  );
@@ -2587,8 +2502,8 @@ var WdaTransport = class {
2587
2502
  return parsed;
2588
2503
  }
2589
2504
  /** Like {@link requestFull} but returns just the `value` field. */
2590
- async request(method, path16, body, timeoutMs) {
2591
- const parsed = await this.requestFull(method, path16, body, timeoutMs);
2505
+ async request(method, path18, body, timeoutMs) {
2506
+ const parsed = await this.requestFull(method, path18, body, timeoutMs);
2592
2507
  return parsed?.value;
2593
2508
  }
2594
2509
  };
@@ -2664,8 +2579,8 @@ function sleep2(ms) {
2664
2579
  }
2665
2580
  function createWdaAgentClient(transport, sessionId) {
2666
2581
  const base = `/session/${sessionId}`;
2667
- async function locate(query, path16) {
2668
- return transport.request("POST", `${base}${path16}`, iosQueryToLocator(query));
2582
+ async function locate(query, path18) {
2583
+ return transport.request("POST", `${base}${path18}`, iosQueryToLocator(query));
2669
2584
  }
2670
2585
  return {
2671
2586
  async findElement(query) {
@@ -2695,12 +2610,29 @@ function createWdaAgentClient(transport, sessionId) {
2695
2610
  const value = await transport.request("GET", `${base}/element/${elementId}/text`);
2696
2611
  return typeof value === "string" ? value : value == null ? null : String(value);
2697
2612
  },
2613
+ async isDisplayed(elementId) {
2614
+ try {
2615
+ return await transport.request("GET", `${base}/element/${elementId}/displayed`) === true;
2616
+ } catch (error) {
2617
+ if (isNoSuchElement2(error)) {
2618
+ return false;
2619
+ }
2620
+ throw error;
2621
+ }
2622
+ },
2698
2623
  async sendKeys(keys) {
2699
2624
  await transport.request("POST", `${base}/wda/keys`, { value: keys });
2700
2625
  },
2701
2626
  async homescreen() {
2702
2627
  await transport.request("POST", "/wda/homescreen", {});
2703
2628
  },
2629
+ async windowSize() {
2630
+ const value = await transport.request("GET", `${base}/window/size`);
2631
+ return toScreenSize(value, "WebDriverAgent /window/size");
2632
+ },
2633
+ async performActions(actions) {
2634
+ await transport.request("POST", `${base}/actions`, { actions: [actions] });
2635
+ },
2704
2636
  async source() {
2705
2637
  const value = await transport.request("GET", "/source");
2706
2638
  if (typeof value !== "string") {
@@ -2718,8 +2650,8 @@ function createWdaAgentClient(transport, sessionId) {
2718
2650
  import { createRequire as createRequire2 } from "module";
2719
2651
  import { execFile as execFile4 } from "child_process";
2720
2652
  import fs5 from "fs";
2721
- import os2 from "os";
2722
- import path5 from "path";
2653
+ import os4 from "os";
2654
+ import path6 from "path";
2723
2655
  var WDA_RUNNER_BUNDLE_ID = "com.facebook.WebDriverAgentRunner.xctrunner";
2724
2656
  var WDA_USE_PORT_ENV = "USE_PORT";
2725
2657
  var PREPARED_XCTESTRUN_PREFIX = "prowl-wda-xctestrun-";
@@ -2745,19 +2677,19 @@ function resolveWdaProject(requireFn = createRequire2(import.meta.url)) {
2745
2677
  "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"
2746
2678
  );
2747
2679
  }
2748
- const pkgDir = path5.dirname(pkgJsonPath);
2680
+ const pkgDir = path6.dirname(pkgJsonPath);
2749
2681
  const version = requireFn(pkgJsonPath).version;
2750
- const projectPath = path5.join(pkgDir, "WebDriverAgent.xcodeproj");
2682
+ const projectPath = path6.join(pkgDir, "WebDriverAgent.xcodeproj");
2751
2683
  if (!fs5.existsSync(projectPath)) {
2752
2684
  throw new Error(`Expected WebDriverAgent project is missing: ${projectPath}. Reinstall dependencies.`);
2753
2685
  }
2754
2686
  return { projectPath, version };
2755
2687
  }
2756
- function wdaCacheDir(wdaVersion, xcode, homeDir = os2.homedir()) {
2757
- return path5.join(homeDir, ".prowl", "wda", `${wdaVersion}-xcode${xcode}`);
2688
+ function wdaCacheDir(wdaVersion, xcode, homeDir = os4.homedir()) {
2689
+ return path6.join(homeDir, ".prowl", "wda", `${wdaVersion}-xcode${xcode}`);
2758
2690
  }
2759
2691
  function productsDir(derivedDataPath) {
2760
- return path5.join(derivedDataPath, "Build", "Products");
2692
+ return path6.join(derivedDataPath, "Build", "Products");
2761
2693
  }
2762
2694
  function findXctestrunIn(dir) {
2763
2695
  let entries;
@@ -2767,7 +2699,7 @@ function findXctestrunIn(dir) {
2767
2699
  return null;
2768
2700
  }
2769
2701
  const match = entries.filter((name) => name.endsWith(".xctestrun") && !name.startsWith(PREPARED_XCTESTRUN_PREFIX)).sort()[0];
2770
- return match ? path5.join(dir, match) : null;
2702
+ return match ? path6.join(dir, match) : null;
2771
2703
  }
2772
2704
  function resolveOverrideXctestrun(override) {
2773
2705
  if (!fs5.existsSync(override)) {
@@ -2779,7 +2711,7 @@ function resolveOverrideXctestrun(override) {
2779
2711
  const candidates = [];
2780
2712
  const stat = fs5.statSync(override);
2781
2713
  if (override.endsWith(".app")) {
2782
- candidates.push(path5.dirname(path5.dirname(override)));
2714
+ candidates.push(path6.dirname(path6.dirname(override)));
2783
2715
  } else if (stat.isDirectory()) {
2784
2716
  candidates.push(override, productsDir(override));
2785
2717
  }
@@ -2796,7 +2728,7 @@ function resolveOverrideXctestrun(override) {
2796
2728
  async function resolveWdaTestRun(options = {}) {
2797
2729
  const runner = options.runner ?? execFileXcrunRunner;
2798
2730
  const env = options.env ?? process.env;
2799
- const homeDir = options.homeDir ?? os2.homedir();
2731
+ const homeDir = options.homeDir ?? os4.homedir();
2800
2732
  const log = options.logger ?? ((message) => process.stderr.write(`${message}
2801
2733
  `));
2802
2734
  const override = env.PROWL_WDA_RUNNER;
@@ -2912,8 +2844,8 @@ var defaultWdaTestRunPreparer = async ({ xctestrunPath, port }) => {
2912
2844
  }
2913
2845
  const injected = injectUsePortIntoXctestrun(parsed, port);
2914
2846
  const stem = `${PREPARED_XCTESTRUN_PREFIX}${port}-${process.pid}`;
2915
- const outPath = path5.join(path5.dirname(xctestrunPath), `${stem}.xctestrun`);
2916
- const jsonPath = path5.join(os2.tmpdir(), `${stem}.json`);
2847
+ const outPath = path6.join(path6.dirname(xctestrunPath), `${stem}.xctestrun`);
2848
+ const jsonPath = path6.join(os4.tmpdir(), `${stem}.json`);
2917
2849
  fs5.writeFileSync(jsonPath, JSON.stringify(injected));
2918
2850
  try {
2919
2851
  await runPlutil(["-convert", "xml1", jsonPath, "-o", outPath]);
@@ -2926,7 +2858,7 @@ function cleanupPreparedTestRun(preparedPath) {
2926
2858
  if (!preparedPath) {
2927
2859
  return;
2928
2860
  }
2929
- if (!path5.basename(preparedPath).startsWith(PREPARED_XCTESTRUN_PREFIX)) {
2861
+ if (!path6.basename(preparedPath).startsWith(PREPARED_XCTESTRUN_PREFIX)) {
2930
2862
  return;
2931
2863
  }
2932
2864
  fs5.rmSync(preparedPath, { force: true });
@@ -2951,7 +2883,7 @@ async function resolveBundleId(app, runner, udid, coldStart, allowedApps) {
2951
2883
  }
2952
2884
  return app;
2953
2885
  }
2954
- const appPath = path5.resolve(app);
2886
+ const appPath = path6.resolve(app);
2955
2887
  if (!fs5.existsSync(appPath)) {
2956
2888
  throw new Error(`.app bundle not found: ${appPath}`);
2957
2889
  }
@@ -3110,14 +3042,14 @@ async function healSelector(probe, selector, options) {
3110
3042
 
3111
3043
  // src/runner/history.ts
3112
3044
  import fs6 from "fs";
3113
- import path6 from "path";
3045
+ import path7 from "path";
3114
3046
  var HISTORY_FILE = "history.json";
3115
3047
  var LOCK_FILE_SUFFIX = ".lock";
3116
3048
  var LOCK_RETRY_MS = 10;
3117
3049
  var LOCK_TIMEOUT_MS = 5e3;
3118
3050
  var SLEEP_BUFFER = new Int32Array(new SharedArrayBuffer(4));
3119
3051
  function historyPath(configDir) {
3120
- return path6.join(configDir, HISTORY_FILE);
3052
+ return path7.join(configDir, HISTORY_FILE);
3121
3053
  }
3122
3054
  function isHistoryEntry(value) {
3123
3055
  if (!value || typeof value !== "object") {
@@ -3171,7 +3103,7 @@ function sleepSync(ms) {
3171
3103
  function withHistoryLock(configDir, fn) {
3172
3104
  const filePath = historyPath(configDir);
3173
3105
  const lockPath = `${filePath}${LOCK_FILE_SUFFIX}`;
3174
- fs6.mkdirSync(path6.dirname(filePath), { recursive: true });
3106
+ fs6.mkdirSync(path7.dirname(filePath), { recursive: true });
3175
3107
  const startedAt = Date.now();
3176
3108
  while (Date.now() - startedAt < LOCK_TIMEOUT_MS) {
3177
3109
  let fd;
@@ -3212,11 +3144,11 @@ function appendEntry(configDir, entry, maxRuns) {
3212
3144
 
3213
3145
  // src/runner/index.ts
3214
3146
  import fs12 from "fs";
3215
- import path12 from "path";
3147
+ import path13 from "path";
3216
3148
 
3217
3149
  // src/browser/playwright-driver.ts
3218
3150
  import fs7 from "fs";
3219
- import path7 from "path";
3151
+ import path8 from "path";
3220
3152
  import {
3221
3153
  chromium,
3222
3154
  firefox,
@@ -3249,7 +3181,7 @@ async function launchBrowser(options) {
3249
3181
  }
3250
3182
  }
3251
3183
  if (options.recordHar) {
3252
- contextOptions.recordHar = { path: path7.join(options.runDir, "network.har") };
3184
+ contextOptions.recordHar = { path: path8.join(options.runDir, "network.har") };
3253
3185
  }
3254
3186
  const context = await browser.newContext(contextOptions);
3255
3187
  const page = await context.newPage();
@@ -3257,7 +3189,7 @@ async function launchBrowser(options) {
3257
3189
  page.setDefaultNavigationTimeout(options.timeout);
3258
3190
  let tracePath;
3259
3191
  if (options.trace) {
3260
- tracePath = path7.join(options.runDir, "trace.zip");
3192
+ tracePath = path8.join(options.runDir, "trace.zip");
3261
3193
  await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
3262
3194
  }
3263
3195
  return { browser, context, page, tracePath };
@@ -3355,6 +3287,16 @@ function createPlaywrightDriver(page) {
3355
3287
  async hover(selector) {
3356
3288
  await page.locator(selector).hover();
3357
3289
  },
3290
+ async scroll(direction, amount = 500) {
3291
+ const deltas = {
3292
+ up: [0, -amount],
3293
+ down: [0, amount],
3294
+ left: [-amount, 0],
3295
+ right: [amount, 0]
3296
+ };
3297
+ const [x, y] = deltas[direction];
3298
+ await page.evaluate(([sx, sy]) => window.scrollBy(sx, sy), [x, y]);
3299
+ },
3358
3300
  async scrollIntoView(selector) {
3359
3301
  await page.locator(selector).scrollIntoViewIfNeeded();
3360
3302
  },
@@ -3436,7 +3378,7 @@ function createPlaywrightDriver(page) {
3436
3378
 
3437
3379
  // src/runner/steps.ts
3438
3380
  import fs8 from "fs";
3439
- import path8 from "path";
3381
+ import path9 from "path";
3440
3382
 
3441
3383
  // src/runner/policy.ts
3442
3384
  var ALWAYS_ALLOWED_PROTOCOLS = ["about:", "data:"];
@@ -4080,11 +4022,14 @@ function toVisibilitySelector(value) {
4080
4022
  if (looksLikeSelector(value)) return value;
4081
4023
  return textContainsSelector(value);
4082
4024
  }
4025
+ function countVisible(driver, selector) {
4026
+ return driver.visibleCount?.(selector) ?? driver.count(selector);
4027
+ }
4083
4028
  async function runInlineAssert(driver, policy, assertion) {
4084
4029
  if (assertion.visible !== void 0) {
4085
4030
  const selector = toVisibilitySelector(assertion.visible);
4086
4031
  policy.assertAllowedSelector(selector);
4087
- const count = await driver.count(selector);
4032
+ const count = await countVisible(driver, selector);
4088
4033
  if (count === 0) {
4089
4034
  throw new Error(`Expected visible: ${assertion.visible}`);
4090
4035
  }
@@ -4093,7 +4038,7 @@ async function runInlineAssert(driver, policy, assertion) {
4093
4038
  if (assertion.notVisible !== void 0) {
4094
4039
  const selector = toVisibilitySelector(assertion.notVisible);
4095
4040
  policy.assertAllowedSelector(selector);
4096
- const count = await driver.count(selector);
4041
+ const count = await countVisible(driver, selector);
4097
4042
  if (count > 0) {
4098
4043
  throw new Error(`Expected not visible: ${assertion.notVisible}`);
4099
4044
  }
@@ -4116,7 +4061,7 @@ async function runInlineAssert(driver, policy, assertion) {
4116
4061
  throw new Error("assert step is missing an assertion type");
4117
4062
  }
4118
4063
  function screenshotPath(screenshotsDir, fileName) {
4119
- return path8.join(screenshotsDir, fileName);
4064
+ return path9.join(screenshotsDir, fileName);
4120
4065
  }
4121
4066
  function stepPath(prefix, index) {
4122
4067
  return prefix ? `${prefix}.${index}` : `${index}`;
@@ -4133,7 +4078,7 @@ function validateDownloadFilename(suggestedFilename) {
4133
4078
  const safeFilename = suggestedFilename.trim();
4134
4079
  const allowedFilenamePattern = /^[^<>:"/\\|?*]+$/;
4135
4080
  const hasControlCharacter = Array.from(safeFilename).some((char) => char.charCodeAt(0) < 32);
4136
- if (safeFilename.length === 0 || safeFilename !== suggestedFilename || safeFilename !== path8.basename(safeFilename) || safeFilename.includes("..") || /[/\\]/.test(safeFilename) || hasControlCharacter || !allowedFilenamePattern.test(safeFilename)) {
4081
+ if (safeFilename.length === 0 || safeFilename !== suggestedFilename || safeFilename !== path9.basename(safeFilename) || safeFilename.includes("..") || /[/\\]/.test(safeFilename) || hasControlCharacter || !allowedFilenamePattern.test(safeFilename)) {
4137
4082
  throw new Error(`Invalid download filename: "${suggestedFilename}"`);
4138
4083
  }
4139
4084
  return safeFilename;
@@ -4313,7 +4258,7 @@ var STEP_HANDLERS = {
4313
4258
  if (!("setInputFiles" in h.step)) unknownStep();
4314
4259
  const resolvedInput = await h.policy.resolveActionSelector(h.step.setInputFiles.selector);
4315
4260
  const rawFiles = h.step.setInputFiles.files;
4316
- const resolveFile = (f) => path8.isAbsolute(f) ? f : path8.join(h.context.configDir, f);
4261
+ const resolveFile = (f) => path9.isAbsolute(f) ? f : path9.join(h.context.configDir, f);
4317
4262
  const resolvedFiles = Array.isArray(rawFiles) ? rawFiles.map(resolveFile) : resolveFile(rawFiles);
4318
4263
  await h.driver.setInputFiles(resolvedInput.selector, resolvedFiles);
4319
4264
  h.policy.ensureLocationAllowed(h.driver);
@@ -4483,25 +4428,21 @@ var STEP_HANDLERS = {
4483
4428
  }
4484
4429
  },
4485
4430
  scroll: {
4486
- capabilities: ["evaluate"],
4431
+ // Dispatched through the driver's `scroll` verb (interact), so native mobile
4432
+ // targets synthesize a touch swipe while web keeps its `window.scrollBy`
4433
+ // behavior. The per-target step gate rejects `scroll` on macOS before here.
4434
+ capabilities: ["interact"],
4487
4435
  run: async (h) => {
4488
4436
  if (!("scroll" in h.step)) unknownStep();
4489
- const amount = h.step.scroll.amount ?? 500;
4490
- const scrollMap = {
4491
- up: [0, -amount],
4492
- down: [0, amount],
4493
- left: [-amount, 0],
4494
- right: [amount, 0]
4495
- };
4496
- const [x, y] = scrollMap[h.step.scroll.direction];
4497
- await h.driver.evaluate(([sx, sy]) => window.scrollBy(sx, sy), [x, y]);
4437
+ const { direction, amount } = h.step.scroll;
4438
+ await h.driver.scroll(direction, amount);
4498
4439
  return {
4499
4440
  kind: "result",
4500
4441
  result: {
4501
4442
  type: "scroll",
4502
4443
  status: "pass",
4503
4444
  durationMs: Date.now() - h.stepStart,
4504
- value: `${h.step.scroll.direction} ${amount}px`
4445
+ value: amount === void 0 ? direction : `${direction} ${amount}px`
4505
4446
  }
4506
4447
  };
4507
4448
  }
@@ -4547,7 +4488,7 @@ var STEP_HANDLERS = {
4547
4488
  const condition = h.step.if;
4548
4489
  const selector = condition.visible ?? condition.notVisible;
4549
4490
  h.policy.assertAllowedSelector(selector);
4550
- const count = await h.driver.count(selector);
4491
+ const count = await countVisible(h.driver, selector);
4551
4492
  const conditionMet = condition.visible !== void 0 ? count > 0 : count === 0;
4552
4493
  if (conditionMet) {
4553
4494
  const subResult = await h.executeNested({
@@ -4634,7 +4575,7 @@ var STEP_HANDLERS = {
4634
4575
  const whileSelector = repeat.while.visible ?? repeat.while.notVisible;
4635
4576
  h.policy.assertAllowedSelector(whileSelector);
4636
4577
  for (let i = 0; i < maxIter; i++) {
4637
- const whileCount = await h.driver.count(whileSelector);
4578
+ const whileCount = await countVisible(h.driver, whileSelector);
4638
4579
  const shouldContinue = repeat.while.visible !== void 0 ? whileCount > 0 : whileCount === 0;
4639
4580
  if (!shouldContinue) break;
4640
4581
  totalSubSteps += repeat.steps.length;
@@ -4675,11 +4616,11 @@ var STEP_HANDLERS = {
4675
4616
  if (!responseFile) {
4676
4617
  throw new Error("mock.response must include either body or file");
4677
4618
  }
4678
- const candidateFilePath = path8.isAbsolute(responseFile) ? responseFile : path8.join(h.context.configDir, responseFile);
4679
- const resolvedConfigDir = path8.resolve(h.context.configDir);
4680
- const resolvedFilePath = path8.resolve(candidateFilePath);
4681
- const relativePath = path8.relative(resolvedConfigDir, resolvedFilePath);
4682
- const isWithinConfigDir = relativePath === "" || relativePath !== ".." && !relativePath.startsWith(`..${path8.sep}`) && !path8.isAbsolute(relativePath);
4619
+ const candidateFilePath = path9.isAbsolute(responseFile) ? responseFile : path9.join(h.context.configDir, responseFile);
4620
+ const resolvedConfigDir = path9.resolve(h.context.configDir);
4621
+ const resolvedFilePath = path9.resolve(candidateFilePath);
4622
+ const relativePath = path9.relative(resolvedConfigDir, resolvedFilePath);
4623
+ const isWithinConfigDir = relativePath === "" || relativePath !== ".." && !relativePath.startsWith(`..${path9.sep}`) && !path9.isAbsolute(relativePath);
4683
4624
  if (!isWithinConfigDir) {
4684
4625
  throw new Error("mock.response.file must resolve within config directory");
4685
4626
  }
@@ -4746,7 +4687,7 @@ var STEP_HANDLERS = {
4746
4687
  capabilities: ["evaluate"],
4747
4688
  run: async (h) => {
4748
4689
  if (!("runScript" in h.step)) unknownStep();
4749
- const filePath = path8.isAbsolute(h.step.runScript.file) ? h.step.runScript.file : path8.join(h.context.configDir, h.step.runScript.file);
4690
+ const filePath = path9.isAbsolute(h.step.runScript.file) ? h.step.runScript.file : path9.join(h.context.configDir, h.step.runScript.file);
4750
4691
  const fileContents = fs8.readFileSync(filePath, "utf-8");
4751
4692
  await h.driver.evaluate(fileContents);
4752
4693
  return {
@@ -4768,11 +4709,11 @@ var STEP_HANDLERS = {
4768
4709
  const name = h.step.assertScreenshot.name;
4769
4710
  const threshold = h.step.assertScreenshot.threshold ?? 0.1;
4770
4711
  const baselineDir = ensureBaselineDir(h.context.configDir);
4771
- const baselinePath = path8.join(baselineDir, `${name}.png`);
4772
- const currentScreenshotPath = path8.join(h.context.runDir, "screenshots", `${name}-current.png`);
4773
- fs8.mkdirSync(path8.dirname(currentScreenshotPath), { recursive: true });
4712
+ const baselinePath = path9.join(baselineDir, `${name}.png`);
4713
+ const currentScreenshotPath = path9.join(h.context.runDir, "screenshots", `${name}-current.png`);
4714
+ fs8.mkdirSync(path9.dirname(currentScreenshotPath), { recursive: true });
4774
4715
  await h.driver.screenshot({ path: currentScreenshotPath, fullPage: true });
4775
- h.screenshots.push(path8.join("screenshots", `${name}-current.png`));
4716
+ h.screenshots.push(path9.join("screenshots", `${name}-current.png`));
4776
4717
  if (!fs8.existsSync(baselinePath)) {
4777
4718
  fs8.copyFileSync(currentScreenshotPath, baselinePath);
4778
4719
  return {
@@ -4780,7 +4721,7 @@ var STEP_HANDLERS = {
4780
4721
  result: { type: "assertScreenshot", status: "pass", durationMs: Date.now() - h.stepStart, value: "baseline created" }
4781
4722
  };
4782
4723
  }
4783
- const diffPath = path8.join(h.context.runDir, "screenshots", `${name}-diff.png`);
4724
+ const diffPath = path9.join(h.context.runDir, "screenshots", `${name}-diff.png`);
4784
4725
  const comparison = await compareScreenshots(baselinePath, currentScreenshotPath, diffPath, threshold);
4785
4726
  if (comparison.match) {
4786
4727
  return {
@@ -4793,7 +4734,7 @@ var STEP_HANDLERS = {
4793
4734
  }
4794
4735
  };
4795
4736
  }
4796
- h.screenshots.push(path8.join("screenshots", `${name}-diff.png`));
4737
+ h.screenshots.push(path9.join("screenshots", `${name}-diff.png`));
4797
4738
  throw new Error(
4798
4739
  `Visual regression: ${(comparison.diffPercentage * 100).toFixed(2)}% diff exceeds threshold ${(threshold * 100).toFixed(0)}%`
4799
4740
  );
@@ -4819,7 +4760,7 @@ var STEP_HANDLERS = {
4819
4760
  }
4820
4761
  const fileName = `assertWithAI_step_${h.index + 1}.png`;
4821
4762
  const relative = await h.addScreenshot(fileName);
4822
- const screenshotFullPath = path8.join(h.context.runDir, relative);
4763
+ const screenshotFullPath = path9.join(h.context.runDir, relative);
4823
4764
  const imageBase64 = fs8.readFileSync(screenshotFullPath).toString("base64");
4824
4765
  const assertVision = h.context.assertVision ?? assertWithAiVision;
4825
4766
  const verdict = await assertVision(
@@ -4877,7 +4818,7 @@ var STEP_HANDLERS = {
4877
4818
  `Download filename mismatch: expected "${opts.filename}", got "${suggestedFilename}"`
4878
4819
  );
4879
4820
  }
4880
- const savePath = path8.join(h.context.runDir, suggestedFilename);
4821
+ const savePath = path9.join(h.context.runDir, suggestedFilename);
4881
4822
  await download.saveAs(savePath);
4882
4823
  return {
4883
4824
  kind: "result",
@@ -4906,7 +4847,7 @@ async function executeSteps(context) {
4906
4847
  maxSteps: context.maxSteps,
4907
4848
  selfHealing: context.selfHealing
4908
4849
  });
4909
- const screenshotsDir = path8.join(context.runDir, "screenshots");
4850
+ const screenshotsDir = path9.join(context.runDir, "screenshots");
4910
4851
  fs8.mkdirSync(screenshotsDir, { recursive: true });
4911
4852
  const currentHuntName = context.huntStack?.[context.huntStack.length - 1];
4912
4853
  policy.assertWithinMaxSteps(context.steps.length, currentHuntName);
@@ -4917,7 +4858,7 @@ async function executeSteps(context) {
4917
4858
  const addScreenshot = async (fileName) => {
4918
4859
  const fullPath = screenshotPath(screenshotsDir, fileName);
4919
4860
  await captureScreenshot2(driver, fullPath);
4920
- const relative = path8.join("screenshots", fileName);
4861
+ const relative = path9.join("screenshots", fileName);
4921
4862
  screenshots.push(relative);
4922
4863
  return relative;
4923
4864
  };
@@ -5005,12 +4946,12 @@ async function executeSteps(context) {
5005
4946
  return { results, screenshots, failed: false };
5006
4947
  }
5007
4948
  async function captureFinalScreenshot(page, runDir) {
5008
- const screenshotsDir = path8.join(runDir, "screenshots");
4949
+ const screenshotsDir = path9.join(runDir, "screenshots");
5009
4950
  fs8.mkdirSync(screenshotsDir, { recursive: true });
5010
4951
  const fileName = "final.png";
5011
4952
  const filePath = screenshotPath(screenshotsDir, fileName);
5012
4953
  await captureScreenshot2(page, filePath);
5013
- return path8.join("screenshots", fileName);
4954
+ return path9.join("screenshots", fileName);
5014
4955
  }
5015
4956
 
5016
4957
  // src/runner/assertions.ts
@@ -5132,6 +5073,59 @@ async function evaluateAssertions(options) {
5132
5073
  }
5133
5074
  return results;
5134
5075
  }
5076
+ async function evaluateNativeAssertions(options) {
5077
+ const assertions = mergeAssertions(options.config, options.huntAssertions);
5078
+ const authoredTypes = new Set(
5079
+ (options.huntAssertions ?? []).map((assertion) => Object.keys(assertion)[0])
5080
+ );
5081
+ const results = [];
5082
+ const warnings = [];
5083
+ for (const assertion of assertions) {
5084
+ const type = Object.keys(assertion)[0] ?? "assertion";
5085
+ try {
5086
+ if ("selectorExists" in assertion) {
5087
+ options.assertAllowedSelector?.(assertion.selectorExists);
5088
+ const count = await options.driver.count(assertion.selectorExists);
5089
+ results.push({
5090
+ type: "selectorExists",
5091
+ value: assertion.selectorExists,
5092
+ status: count > 0 ? "pass" : "fail",
5093
+ error: count > 0 ? void 0 : "Selector not found"
5094
+ });
5095
+ continue;
5096
+ }
5097
+ if ("selectorNotExists" in assertion) {
5098
+ options.assertAllowedSelector?.(assertion.selectorNotExists);
5099
+ const count = await options.driver.count(assertion.selectorNotExists);
5100
+ results.push({
5101
+ type: "selectorNotExists",
5102
+ value: assertion.selectorNotExists,
5103
+ status: count === 0 ? "pass" : "fail",
5104
+ error: count === 0 ? void 0 : "Selector exists"
5105
+ });
5106
+ continue;
5107
+ }
5108
+ const rawValue = assertion[type];
5109
+ results.push({
5110
+ type,
5111
+ value: rawValue,
5112
+ status: "skipped",
5113
+ error: `skipped (web-only): not supported on the ${options.targetLabel} target`
5114
+ });
5115
+ if (authoredTypes.has(type)) {
5116
+ warnings.push(`${type} is web-only; skipped on ${options.targetLabel} target`);
5117
+ }
5118
+ } catch (error) {
5119
+ const detail = error instanceof Error ? error.message : "no error details";
5120
+ results.push({
5121
+ type,
5122
+ status: "fail",
5123
+ error: `Native assertion "${type}" failed: ${detail}`
5124
+ });
5125
+ }
5126
+ }
5127
+ return { results, warnings };
5128
+ }
5135
5129
 
5136
5130
  // src/runner/tracing.ts
5137
5131
  var DEFAULT_TRACE_HEADER = "traceparent";
@@ -5171,17 +5165,17 @@ function captureTraceCorrelation(response, headerName, sink, redactionValues = [
5171
5165
 
5172
5166
  // src/reporter/result.ts
5173
5167
  import fs9 from "fs";
5174
- import path9 from "path";
5168
+ import path10 from "path";
5175
5169
  function writeResult(runDir, result) {
5176
5170
  const fileName = "result.json";
5177
- const fullPath = path9.join(runDir, fileName);
5171
+ const fullPath = path10.join(runDir, fileName);
5178
5172
  fs9.writeFileSync(fullPath, JSON.stringify(result, null, 2));
5179
5173
  return fileName;
5180
5174
  }
5181
5175
 
5182
5176
  // src/reporter/summary.ts
5183
5177
  import fs10 from "fs";
5184
- import path10 from "path";
5178
+ import path11 from "path";
5185
5179
  function escapeMd(text) {
5186
5180
  return text.replace(/([|`*_{}[\]()#+\-!\\])/g, "\\$1");
5187
5181
  }
@@ -5259,7 +5253,7 @@ function writeSummary(runDir, result) {
5259
5253
  }
5260
5254
  }
5261
5255
  const fileName = "summary.md";
5262
- const fullPath = path10.join(runDir, fileName);
5256
+ const fullPath = path11.join(runDir, fileName);
5263
5257
  fs10.writeFileSync(fullPath, `${lines.join("\n")}
5264
5258
  `);
5265
5259
  return fileName;
@@ -5267,20 +5261,21 @@ function writeSummary(runDir, result) {
5267
5261
 
5268
5262
  // src/reporter/junit.ts
5269
5263
  import fs11 from "fs";
5270
- import path11 from "path";
5264
+ import path12 from "path";
5271
5265
  function escapeXml(text) {
5272
5266
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
5273
5267
  }
5274
5268
  function writeJunit(runDir, result) {
5275
5269
  const totalTests = result.steps.length + result.assertions.length;
5276
5270
  const failures = result.steps.filter((s) => s.status === "fail").length + result.assertions.filter((a) => a.status === "fail").length;
5271
+ const skipped = result.assertions.filter((a) => a.status === "skipped").length;
5277
5272
  const timeSeconds = (result.durationMs / 1e3).toFixed(3);
5278
5273
  const huntName = escapeXml(result.hunt);
5279
5274
  const lines = [];
5280
5275
  lines.push('<?xml version="1.0" encoding="UTF-8"?>');
5281
5276
  lines.push("<testsuites>");
5282
5277
  lines.push(
5283
- ` <testsuite name="${huntName}" tests="${totalTests}" failures="${failures}" errors="0" time="${timeSeconds}" timestamp="${escapeXml(result.startedAt)}">`
5278
+ ` <testsuite name="${huntName}" tests="${totalTests}" failures="${failures}" errors="0" skipped="${skipped}" time="${timeSeconds}" timestamp="${escapeXml(result.startedAt)}">`
5284
5279
  );
5285
5280
  for (let i = 0; i < result.steps.length; i++) {
5286
5281
  const step = result.steps[i];
@@ -5304,6 +5299,11 @@ function writeJunit(runDir, result) {
5304
5299
  lines.push(` <testcase name="${caseName}" classname="${huntName}" time="0">`);
5305
5300
  lines.push(` <failure message="${escapedFailureText}" type="assertion">${escapedFailureText}</failure>`);
5306
5301
  lines.push(" </testcase>");
5302
+ } else if (assertion.status === "skipped") {
5303
+ const skipText = escapeXml(assertion.error ?? "skipped");
5304
+ lines.push(` <testcase name="${caseName}" classname="${huntName}" time="0">`);
5305
+ lines.push(` <skipped message="${skipText}"/>`);
5306
+ lines.push(" </testcase>");
5307
5307
  } else {
5308
5308
  lines.push(` <testcase name="${caseName}" classname="${huntName}" time="0"/>`);
5309
5309
  }
@@ -5311,7 +5311,7 @@ function writeJunit(runDir, result) {
5311
5311
  lines.push(" </testsuite>");
5312
5312
  lines.push("</testsuites>");
5313
5313
  const fileName = "junit.xml";
5314
- const fullPath = path11.join(runDir, fileName);
5314
+ const fullPath = path12.join(runDir, fileName);
5315
5315
  fs11.writeFileSync(fullPath, `${lines.join("\n")}
5316
5316
  `);
5317
5317
  return fileName;
@@ -5354,11 +5354,11 @@ function parseViewportFlag(value) {
5354
5354
  return value;
5355
5355
  }
5356
5356
  function resolvePath(configDir, inputPath) {
5357
- if (path12.isAbsolute(inputPath)) {
5357
+ if (path13.isAbsolute(inputPath)) {
5358
5358
  return inputPath;
5359
5359
  }
5360
- const projectRoot = path12.dirname(configDir);
5361
- return path12.join(projectRoot, inputPath);
5360
+ const projectRoot = path13.dirname(configDir);
5361
+ return path13.join(projectRoot, inputPath);
5362
5362
  }
5363
5363
  function buildRunResult(options) {
5364
5364
  return {
@@ -5377,7 +5377,7 @@ function buildRunResult(options) {
5377
5377
  }
5378
5378
  function writeConsoleLog(runDir, entries) {
5379
5379
  const fileName = "console.log";
5380
- const filePath = path12.join(runDir, fileName);
5380
+ const filePath = path13.join(runDir, fileName);
5381
5381
  const lines = entries.map((entry) => {
5382
5382
  const location = entry.location ? ` (${entry.location})` : "";
5383
5383
  return `[${entry.type}] ${entry.text}${location}`;
@@ -5390,7 +5390,7 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
5390
5390
  const headless = options.headed ? false : config.browser.headless;
5391
5391
  const slowMo = options.slowMo ?? config.browser.slowMo;
5392
5392
  const maxSteps = config.guardrails.maxSteps;
5393
- const runDir = path12.join(configDir, "runs", timestamp());
5393
+ const runDir = path13.join(configDir, "runs", timestamp());
5394
5394
  fs12.mkdirSync(runDir, { recursive: true });
5395
5395
  const storageStatePath = config.auth.storageStatePath ? resolvePath(configDir, config.auth.storageStatePath) : void 0;
5396
5396
  const engine = options.browser ?? config.browser.engine;
@@ -5574,7 +5574,7 @@ async function runHunt(options) {
5574
5574
  }
5575
5575
  async function executeNativeHuntAttempt(options, config, configDir, interpolatedHunt, redactedFillSteps, randomVars, allowedApps, native) {
5576
5576
  const maxSteps = config.guardrails.maxSteps;
5577
- const runDir = path12.join(configDir, "runs", timestamp());
5577
+ const runDir = path13.join(configDir, "runs", timestamp());
5578
5578
  fs12.mkdirSync(runDir, { recursive: true });
5579
5579
  const session = await native.launchSession();
5580
5580
  let result;
@@ -5583,6 +5583,13 @@ async function executeNativeHuntAttempt(options, config, configDir, interpolated
5583
5583
  const appIdentity = native.sessionAppIdentity(session);
5584
5584
  const targetLabel = `${native.targetType}:${appIdentity}`;
5585
5585
  const effectiveAllowedApps = [.../* @__PURE__ */ new Set([...allowedApps, native.targetApp, appIdentity])];
5586
+ const assertionPolicy = createRunPolicy(driver, {
5587
+ forbiddenSelectors: config.guardrails.forbiddenSelectors,
5588
+ allowedDomains: [],
5589
+ allowedApps: effectiveAllowedApps,
5590
+ maxSteps,
5591
+ selfHealing: config.guardrails.selfHealing
5592
+ });
5586
5593
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
5587
5594
  const startTime = Date.now();
5588
5595
  let stepResults = [];
@@ -5622,8 +5629,19 @@ async function executeNativeHuntAttempt(options, config, configDir, interpolated
5622
5629
  } catch {
5623
5630
  finalScreenshot = void 0;
5624
5631
  }
5632
+ const { results: assertionResults, warnings: assertionWarnings } = await evaluateNativeAssertions({
5633
+ driver,
5634
+ config,
5635
+ huntAssertions: interpolatedHunt.assertions,
5636
+ assertAllowedSelector: assertionPolicy.assertAllowedSelector,
5637
+ targetLabel: nativeTargetLabel(native.targetType)
5638
+ });
5639
+ for (const warning of assertionWarnings) {
5640
+ console.warn(warning);
5641
+ }
5625
5642
  const durationMs = Date.now() - startTime;
5626
- const status = stepFailed ? "fail" : "pass";
5643
+ const assertionsFailed = assertionResults.some((assertion) => assertion.status === "fail");
5644
+ const status = stepFailed || assertionsFailed ? "fail" : "pass";
5627
5645
  const artifacts = {
5628
5646
  screenshots: finalScreenshot ? [...stepScreenshots, finalScreenshot] : stepScreenshots
5629
5647
  };
@@ -5634,7 +5652,7 @@ async function executeNativeHuntAttempt(options, config, configDir, interpolated
5634
5652
  hunt: options.huntName,
5635
5653
  targetUrl: targetLabel,
5636
5654
  steps: stepResults,
5637
- assertions: [],
5655
+ assertions: assertionResults,
5638
5656
  artifacts
5639
5657
  });
5640
5658
  result = writeReports(runDir, runResult, { junit: options.junit ?? config.artifacts.junit });
@@ -5747,7 +5765,6 @@ async function runNativeHunt(options, config, configDir, target, native) {
5747
5765
  const hunt = loadHunt(options.huntName, configDir);
5748
5766
  const { hunt: interpolatedHunt, redactedFillSteps, randomVars } = interpolateHunt(hunt, process.env);
5749
5767
  assertStepsSupportedByTarget(interpolatedHunt.steps, native.targetType);
5750
- assertHuntAssertionsSupportedByTarget(interpolatedHunt.assertions, native.targetType);
5751
5768
  native.assertAppAllowed(config.guardrails.allowedApps, target);
5752
5769
  const maxSteps = config.guardrails.maxSteps;
5753
5770
  if (interpolatedHunt.steps.length > maxSteps) {
@@ -5788,7 +5805,7 @@ async function runNativeHunt(options, config, configDir, target, native) {
5788
5805
  }
5789
5806
  function recordHistory(configDir, outcome, maxRuns) {
5790
5807
  try {
5791
- const relativeRunDir = path12.relative(configDir, outcome.runDir);
5808
+ const relativeRunDir = path13.relative(configDir, outcome.runDir);
5792
5809
  appendEntry(
5793
5810
  configDir,
5794
5811
  {
@@ -5910,7 +5927,7 @@ function clusterFailures(failures) {
5910
5927
 
5911
5928
  // src/backlog/index.ts
5912
5929
  import fs13 from "fs";
5913
- import path13 from "path";
5930
+ import path14 from "path";
5914
5931
 
5915
5932
  // src/backlog/parse.ts
5916
5933
  var MARKER_FP = /<!--\s*prowl:fp=([0-9a-f]+)/;
@@ -6016,7 +6033,7 @@ function buildFailure(hunt) {
6016
6033
  if (!hunt.runDir) return failure;
6017
6034
  let run;
6018
6035
  try {
6019
- const resultJson = readFileOrEmpty(path13.join(hunt.runDir, "result.json"));
6036
+ const resultJson = readFileOrEmpty(path14.join(hunt.runDir, "result.json"));
6020
6037
  if (!resultJson) return failure;
6021
6038
  run = JSON.parse(resultJson);
6022
6039
  } catch (error) {
@@ -6045,8 +6062,8 @@ function extractFailures(suiteResult) {
6045
6062
  }
6046
6063
  function updateBacklogFromSuite(suiteResult, options = {}) {
6047
6064
  const projectRoot = options.projectRoot ?? process.cwd();
6048
- const backlogPath = options.backlogPath ?? path13.join(projectRoot, "docs", "backlog.md");
6049
- const resolvedPath = options.resolvedPath ?? path13.join(projectRoot, "docs", "resolved.md");
6065
+ const backlogPath = options.backlogPath ?? path14.join(projectRoot, "docs", "backlog.md");
6066
+ const resolvedPath = options.resolvedPath ?? path14.join(projectRoot, "docs", "resolved.md");
6050
6067
  const date = options.date ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
6051
6068
  const summary = { created: [], regressions: [], skipped: [], backlogPath };
6052
6069
  const failures = extractFailures(suiteResult);
@@ -6078,18 +6095,18 @@ function updateBacklogFromSuite(suiteResult, options = {}) {
6078
6095
  }
6079
6096
  }
6080
6097
  if (ticketsToAdd.length > 0) {
6081
- fs13.mkdirSync(path13.dirname(backlogPath), { recursive: true });
6098
+ fs13.mkdirSync(path14.dirname(backlogPath), { recursive: true });
6082
6099
  fs13.writeFileSync(backlogPath, insertTickets(backlogContent, ticketsToAdd));
6083
6100
  }
6084
6101
  return summary;
6085
6102
  }
6086
6103
 
6087
6104
  // src/runner/suite.ts
6088
- import path15 from "path";
6105
+ import path16 from "path";
6089
6106
 
6090
6107
  // src/reporter/ci-summary.ts
6091
6108
  import fs14 from "fs";
6092
- import path14 from "path";
6109
+ import path15 from "path";
6093
6110
  import chalk from "chalk";
6094
6111
  function countCiResults(results) {
6095
6112
  return {
@@ -6154,7 +6171,7 @@ function writeCiResult(ciRunDir, results, startedAt, totalDurationMs, flaky = []
6154
6171
  ...clusters.length > 0 ? { clusters } : {}
6155
6172
  };
6156
6173
  fs14.mkdirSync(ciRunDir, { recursive: true });
6157
- const filePath = path14.join(ciRunDir, "ci-result.json");
6174
+ const filePath = path15.join(ciRunDir, "ci-result.json");
6158
6175
  fs14.writeFileSync(filePath, JSON.stringify(ciResult, null, 2) + "\n");
6159
6176
  return filePath;
6160
6177
  }
@@ -6340,7 +6357,7 @@ async function runSuite(options = {}) {
6340
6357
  const clusters = clusterFailures(
6341
6358
  extractFailures({ result: { hunts: results }, resultPath: null })
6342
6359
  ).filter((cluster) => cluster.count > 1);
6343
- const ciRunDir = path15.join(configDir, "runs", timestamp("ci"));
6360
+ const ciRunDir = path16.join(configDir, "runs", timestamp("ci"));
6344
6361
  const resultPath = writeCiResult(ciRunDir, results, startedAt, totalDurationMs, flaky, clusters);
6345
6362
  const { passed, failed, skipped } = countCiResults(results);
6346
6363
  return {
@@ -7015,8 +7032,299 @@ async function generateHunt(options) {
7015
7032
  return yamlStr;
7016
7033
  }
7017
7034
 
7035
+ // src/browser/macdriver-install.ts
7036
+ import { execFile as execFile5 } from "child_process";
7037
+ import { createHash as createHash2 } from "crypto";
7038
+ import fs15 from "fs";
7039
+ import os5 from "os";
7040
+ import path17 from "path";
7041
+ import { promisify } from "util";
7042
+ var execFileAsync = promisify(execFile5);
7043
+ function parseChecksumFile(text) {
7044
+ const token = text.trim().split(/\s+/, 1)[0] ?? "";
7045
+ const digest = token.toLowerCase();
7046
+ if (!/^[0-9a-f]{64}$/.test(digest)) {
7047
+ throw new Error(`Malformed .sha256 checksum file (expected a 64-char hex digest, got: ${text.trim().slice(0, 80)})`);
7048
+ }
7049
+ return digest;
7050
+ }
7051
+ function sha256Hex(bytes) {
7052
+ return createHash2("sha256").update(bytes).digest("hex");
7053
+ }
7054
+ var commandRunner = async (file, args) => execFileAsync(file, args);
7055
+ function commandOutput(result) {
7056
+ return [result.stdout, result.stderr].filter((value) => value !== void 0).map((value) => value.toString()).join("\n").trim();
7057
+ }
7058
+ function commandErrorDetail(error) {
7059
+ const err = error;
7060
+ return [err.stderr, err.stdout, err.message].filter((value) => value !== void 0 && value !== "").map((value) => value.toString()).join("\n").trim();
7061
+ }
7062
+ async function runRequiredCommand(run, binaryPath, command, args, label) {
7063
+ try {
7064
+ return await run(command, args);
7065
+ } catch (error) {
7066
+ const detail = commandErrorDetail(error);
7067
+ throw new Error(`${label} failed for ${binaryPath}${detail ? `: ${detail}` : ""}`);
7068
+ }
7069
+ }
7070
+ var zipinfoArchiveLister = async (zipPath) => {
7071
+ try {
7072
+ const { stdout } = await execFileAsync("zipinfo", ["-1", zipPath]);
7073
+ return stdout.toString().split(/\r?\n/).filter((entry) => entry.length > 0);
7074
+ } catch (error) {
7075
+ const detail = commandErrorDetail(error);
7076
+ throw new Error(`Failed to inspect release archive ${zipPath} with zipinfo${detail ? `: ${detail}` : ""}`);
7077
+ }
7078
+ };
7079
+ function normalizeArchiveEntryName(entry) {
7080
+ if (entry.length === 0 || entry !== entry.trim() || entry.includes("\0") || entry.includes("\\")) {
7081
+ throw new Error(`Unsafe path in prowl-macdriver release archive: ${JSON.stringify(entry)}`);
7082
+ }
7083
+ if (entry.endsWith("/")) {
7084
+ throw new Error(`Unexpected directory in prowl-macdriver release archive: ${entry}`);
7085
+ }
7086
+ if (path17.posix.isAbsolute(entry)) {
7087
+ throw new Error(`Unsafe absolute path in prowl-macdriver release archive: ${entry}`);
7088
+ }
7089
+ const parts = entry.split("/");
7090
+ if (parts.some((part) => part === "" || part === "." || part === "..")) {
7091
+ throw new Error(`Unsafe path in prowl-macdriver release archive: ${entry}`);
7092
+ }
7093
+ return entry;
7094
+ }
7095
+ function validateMacdriverArchiveEntries(entries) {
7096
+ const normalized = entries.map(normalizeArchiveEntryName);
7097
+ if (normalized.length !== 1 || normalized[0] !== HELPER_BINARY) {
7098
+ const shown = normalized.length > 0 ? normalized.join(", ") : "(empty archive)";
7099
+ throw new Error(
7100
+ `Unexpected prowl-macdriver release archive contents: ${shown}. Expected exactly "${HELPER_BINARY}" at the archive root.`
7101
+ );
7102
+ }
7103
+ }
7104
+ var dittoExtractor = async (zipPath, destDir) => {
7105
+ await execFileAsync("ditto", ["-x", "-k", zipPath, destDir]);
7106
+ };
7107
+ function parseCodesignDetails(text) {
7108
+ const details = { identifier: null, authorities: [], teamIdentifier: null };
7109
+ for (const line of text.split(/\r?\n/)) {
7110
+ const trimmed = line.trim();
7111
+ if (trimmed.startsWith("Identifier=")) {
7112
+ details.identifier = trimmed.slice("Identifier=".length);
7113
+ } else if (trimmed.startsWith("Authority=")) {
7114
+ details.authorities.push(trimmed.slice("Authority=".length));
7115
+ } else if (trimmed.startsWith("TeamIdentifier=")) {
7116
+ details.teamIdentifier = trimmed.slice("TeamIdentifier=".length);
7117
+ }
7118
+ }
7119
+ return details;
7120
+ }
7121
+ function validateCodesignDetails(details, binaryPath) {
7122
+ if (details.identifier !== MACDRIVER_SIGNING_IDENTIFIER) {
7123
+ throw new Error(
7124
+ `codesign verification failed for ${binaryPath}: expected identifier "${MACDRIVER_SIGNING_IDENTIFIER}", got "${details.identifier ?? "missing"}"`
7125
+ );
7126
+ }
7127
+ const developerIdAuthority = details.authorities.find(
7128
+ (authority) => authority.startsWith(`${MACDRIVER_SIGNING_AUTHORITY_PREFIX} (`)
7129
+ );
7130
+ const authorityTeamId = developerIdAuthority?.match(/\(([A-Z0-9]{10})\)$/)?.[1] ?? null;
7131
+ if (!developerIdAuthority || !authorityTeamId) {
7132
+ const shown = details.authorities.length > 0 ? details.authorities.join(" / ") : "missing";
7133
+ throw new Error(
7134
+ `codesign verification failed for ${binaryPath}: expected ${MACDRIVER_SIGNING_AUTHORITY_PREFIX} signer, got ${shown}`
7135
+ );
7136
+ }
7137
+ if (!details.teamIdentifier) {
7138
+ throw new Error(`codesign verification failed for ${binaryPath}: missing TeamIdentifier`);
7139
+ }
7140
+ if (details.teamIdentifier !== authorityTeamId) {
7141
+ throw new Error(
7142
+ `codesign verification failed for ${binaryPath}: TeamIdentifier ${details.teamIdentifier} does not match Developer ID authority team ${authorityTeamId}`
7143
+ );
7144
+ }
7145
+ }
7146
+ async function verifyMacdriverSignature(binaryPath, run = commandRunner) {
7147
+ await runRequiredCommand(run, binaryPath, "codesign", ["--verify", "--strict", binaryPath], "codesign verification");
7148
+ const display = await runRequiredCommand(
7149
+ run,
7150
+ binaryPath,
7151
+ "codesign",
7152
+ ["--display", "--verbose=4", binaryPath],
7153
+ "codesign detail inspection"
7154
+ );
7155
+ validateCodesignDetails(parseCodesignDetails(commandOutput(display)), binaryPath);
7156
+ await runRequiredCommand(
7157
+ run,
7158
+ binaryPath,
7159
+ "spctl",
7160
+ ["--assess", "--type", "execute", "--verbose=4", binaryPath],
7161
+ "spctl assessment"
7162
+ );
7163
+ }
7164
+ var codesignVerifier = async (binaryPath) => {
7165
+ await verifyMacdriverSignature(binaryPath);
7166
+ };
7167
+ async function downloadAndVerify(options = {}) {
7168
+ const version = options.version ?? MACDRIVER_VERSION;
7169
+ const fetchImpl = options.fetchImpl ?? fetch;
7170
+ const assetName = macdriverAssetName(version);
7171
+ const zipUrl = macdriverAssetUrl(assetName, version);
7172
+ const sumUrl = macdriverAssetUrl(macdriverChecksumName(version), version);
7173
+ const [zipRes, sumRes] = await Promise.all([
7174
+ fetchImpl(zipUrl, { redirect: "follow" }),
7175
+ fetchImpl(sumUrl, { redirect: "follow" })
7176
+ ]);
7177
+ if (zipRes.status === 404 || sumRes.status === 404) {
7178
+ throw new Error(
7179
+ `No published prowl-macdriver release for ${macdriverReleaseTag(version)} yet.
7180
+ The signed binary is cut by the maintainer; until then build from source:
7181
+ cd macdriver && swift build -c release`
7182
+ );
7183
+ }
7184
+ if (!zipRes.ok) {
7185
+ throw new Error(`Failed to download ${assetName} (HTTP ${zipRes.status}) from ${zipUrl}`);
7186
+ }
7187
+ if (!sumRes.ok) {
7188
+ throw new Error(`Failed to download the checksum (HTTP ${sumRes.status}) from ${sumUrl}`);
7189
+ }
7190
+ const zipBytes = Buffer.from(await zipRes.arrayBuffer());
7191
+ const expected = parseChecksumFile(await sumRes.text());
7192
+ const actual = sha256Hex(zipBytes);
7193
+ if (actual !== expected) {
7194
+ throw new Error(
7195
+ `Checksum mismatch for ${assetName}.
7196
+ expected: ${expected}
7197
+ actual: ${actual}
7198
+ The download was rejected and discarded; re-run the install, and report it if it repeats.`
7199
+ );
7200
+ }
7201
+ return zipBytes;
7202
+ }
7203
+ async function installMacdriver(options = {}) {
7204
+ const version = validateMacdriverVersion(options.version ?? MACDRIVER_VERSION);
7205
+ const homedir = options.homedir ?? os5.homedir();
7206
+ const listArchiveEntries = options.listArchiveEntries ?? zipinfoArchiveLister;
7207
+ const extract = options.extract ?? dittoExtractor;
7208
+ const verifySignature = options.verifySignature ?? codesignVerifier;
7209
+ const installRoot = macdriverInstallRoot(homedir);
7210
+ const versionDir = macdriverVersionDir(version, homedir);
7211
+ const binaryPath = macdriverInstalledBinary(version, homedir);
7212
+ if (!options.force && fs15.existsSync(binaryPath)) {
7213
+ return { version, binaryPath, alreadyInstalled: true };
7214
+ }
7215
+ const zipBytes = await downloadAndVerify({ version, fetchImpl: options.fetchImpl });
7216
+ fs15.mkdirSync(installRoot, { recursive: true });
7217
+ const stagingDir = fs15.mkdtempSync(path17.join(installRoot, `.tmp-${version}-`));
7218
+ const extractDir = path17.join(stagingDir, "extract");
7219
+ const zipPath = path17.join(stagingDir, macdriverAssetName(version));
7220
+ try {
7221
+ fs15.mkdirSync(extractDir);
7222
+ fs15.writeFileSync(zipPath, zipBytes);
7223
+ validateMacdriverArchiveEntries(await listArchiveEntries(zipPath));
7224
+ await extract(zipPath, extractDir);
7225
+ const stagedBinaryPath = path17.join(extractDir, HELPER_BINARY);
7226
+ assertExtractedHelper(extractDir, stagedBinaryPath);
7227
+ fs15.chmodSync(stagedBinaryPath, 493);
7228
+ await verifySignature(stagedBinaryPath);
7229
+ replaceVersionDir(versionDir, extractDir, installRoot);
7230
+ } finally {
7231
+ fs15.rmSync(stagingDir, { recursive: true, force: true });
7232
+ }
7233
+ return { version, binaryPath, alreadyInstalled: false };
7234
+ }
7235
+ function assertExtractedHelper(extractDir, binaryPath) {
7236
+ const entries = fs15.readdirSync(extractDir);
7237
+ if (!entries.includes(HELPER_BINARY)) {
7238
+ throw new Error(`The release archive did not contain a "${HELPER_BINARY}" binary.`);
7239
+ }
7240
+ if (entries.length !== 1 || entries[0] !== HELPER_BINARY) {
7241
+ const shown = entries.length > 0 ? entries.join(", ") : "(empty directory)";
7242
+ throw new Error(
7243
+ `Unexpected extracted prowl-macdriver archive contents: ${shown}. Expected exactly "${HELPER_BINARY}".`
7244
+ );
7245
+ }
7246
+ let stat = null;
7247
+ try {
7248
+ stat = fs15.lstatSync(binaryPath);
7249
+ } catch {
7250
+ }
7251
+ if (!stat?.isFile()) {
7252
+ throw new Error(`The release archive "${HELPER_BINARY}" entry is not a regular file.`);
7253
+ }
7254
+ }
7255
+ function replaceVersionDir(versionDir, stagedVersionDir, installRoot) {
7256
+ const backupDir = path17.join(installRoot, `.previous-${path17.basename(versionDir)}-${process.pid}-${Date.now()}`);
7257
+ let backedUp = false;
7258
+ try {
7259
+ if (fs15.existsSync(versionDir)) {
7260
+ fs15.renameSync(versionDir, backupDir);
7261
+ backedUp = true;
7262
+ }
7263
+ fs15.renameSync(stagedVersionDir, versionDir);
7264
+ if (backedUp) {
7265
+ fs15.rmSync(backupDir, { recursive: true, force: true });
7266
+ }
7267
+ } catch (error) {
7268
+ if (backedUp && !fs15.existsSync(versionDir) && fs15.existsSync(backupDir)) {
7269
+ fs15.renameSync(backupDir, versionDir);
7270
+ }
7271
+ throw error;
7272
+ }
7273
+ }
7274
+ var runVersionProbe = async (binaryPath) => {
7275
+ try {
7276
+ const { stdout } = await execFileAsync(binaryPath, ["version"], { timeout: 5e3 });
7277
+ const match = stdout.match(/prowl-macdriver\s+(\S+)/);
7278
+ return match ? match[1] : stdout.trim() || null;
7279
+ } catch {
7280
+ return null;
7281
+ }
7282
+ };
7283
+ async function collectMacdriverStatus(options = {}) {
7284
+ const env = options.env ?? process.env;
7285
+ const homedir = options.homedir ?? os5.homedir();
7286
+ const probe = options.probe ?? runVersionProbe;
7287
+ let resolved = null;
7288
+ try {
7289
+ const resolvedPath = resolveHelperBinary(env, { homedir });
7290
+ resolved = { path: resolvedPath, source: classifyResolvedSource(resolvedPath, env, homedir) };
7291
+ } catch {
7292
+ resolved = null;
7293
+ }
7294
+ const installed = [];
7295
+ const root = macdriverInstallRoot(homedir);
7296
+ if (fs15.existsSync(root)) {
7297
+ for (const entry of fs15.readdirSync(root, { withFileTypes: true })) {
7298
+ if (!entry.isDirectory()) continue;
7299
+ let binaryPath;
7300
+ try {
7301
+ binaryPath = macdriverInstalledBinary(entry.name, homedir);
7302
+ } catch {
7303
+ continue;
7304
+ }
7305
+ if (fs15.existsSync(binaryPath)) {
7306
+ installed.push({ version: entry.name, binaryPath });
7307
+ }
7308
+ }
7309
+ installed.sort((a, b) => a.version.localeCompare(b.version));
7310
+ }
7311
+ const probedVersion = resolved ? await probe(resolved.path) : null;
7312
+ return { resolved, pinnedVersion: MACDRIVER_VERSION, installed, probedVersion };
7313
+ }
7314
+ function classifyResolvedSource(resolvedPath, env, homedir) {
7315
+ if (env.PROWL_MACDRIVER_BIN && resolvedPath === env.PROWL_MACDRIVER_BIN) {
7316
+ return "env";
7317
+ }
7318
+ if (resolvedPath.startsWith(macdriverInstallRoot(homedir) + path17.sep)) {
7319
+ return "user-install";
7320
+ }
7321
+ return "source-build";
7322
+ }
7323
+ function tccGuidance() {
7324
+ 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.";
7325
+ }
7326
+
7018
7327
  export {
7019
- interpolateHunt,
7020
7328
  WEB_ONLY_STEP_TYPES,
7021
7329
  webOnlyReason,
7022
7330
  assertStepsSupportedByTarget,
@@ -7032,6 +7340,20 @@ export {
7032
7340
  createPlaywrightDriver,
7033
7341
  parseMacSelector,
7034
7342
  createMacDriver,
7343
+ HELPER_BINARY,
7344
+ MACDRIVER_VERSION,
7345
+ MACDRIVER_REPO,
7346
+ MACDRIVER_SIGNING_IDENTIFIER,
7347
+ MACDRIVER_SIGNING_AUTHORITY_PREFIX,
7348
+ MACDRIVER_VERSION_PATTERN,
7349
+ validateMacdriverVersion,
7350
+ macdriverReleaseTag,
7351
+ macdriverAssetName,
7352
+ macdriverChecksumName,
7353
+ macdriverAssetUrl,
7354
+ macdriverInstallRoot,
7355
+ macdriverVersionDir,
7356
+ macdriverInstalledBinary,
7035
7357
  macdriverBuildInstructions,
7036
7358
  resolveHelperBinary,
7037
7359
  DEFAULT_REQUEST_TIMEOUT_MS,
@@ -7145,6 +7467,19 @@ export {
7145
7467
  matchIosSelector,
7146
7468
  isIosInteractive,
7147
7469
  analyzeIosApp,
7148
- generateHunt
7470
+ generateHunt,
7471
+ parseChecksumFile,
7472
+ sha256Hex,
7473
+ zipinfoArchiveLister,
7474
+ validateMacdriverArchiveEntries,
7475
+ dittoExtractor,
7476
+ parseCodesignDetails,
7477
+ verifyMacdriverSignature,
7478
+ codesignVerifier,
7479
+ downloadAndVerify,
7480
+ installMacdriver,
7481
+ runVersionProbe,
7482
+ collectMacdriverStatus,
7483
+ tccGuidance
7149
7484
  };
7150
- //# sourceMappingURL=chunk-5KQR3IR3.js.map
7485
+ //# sourceMappingURL=chunk-CWLRDV5P.js.map