@houwert/conductor 0.2.0

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.
Files changed (60) hide show
  1. package/.claude-plugin/plugin.json +6 -0
  2. package/README.md +39 -0
  3. package/dist/commands/assert-not-visible.js +47 -0
  4. package/dist/commands/assert-visible.js +58 -0
  5. package/dist/commands/back.js +25 -0
  6. package/dist/commands/cheat-sheet.js +100 -0
  7. package/dist/commands/daemon.js +61 -0
  8. package/dist/commands/device-pool.js +202 -0
  9. package/dist/commands/erase-text.js +26 -0
  10. package/dist/commands/foreground-app.js +50 -0
  11. package/dist/commands/hide-keyboard.js +27 -0
  12. package/dist/commands/inspect.js +37 -0
  13. package/dist/commands/install.js +64 -0
  14. package/dist/commands/launch-app.js +42 -0
  15. package/dist/commands/list-apps.js +60 -0
  16. package/dist/commands/list-devices.js +61 -0
  17. package/dist/commands/open-link.js +22 -0
  18. package/dist/commands/press-key.js +91 -0
  19. package/dist/commands/run-flow-inline.js +25 -0
  20. package/dist/commands/run-flow.js +29 -0
  21. package/dist/commands/run-parallel.js +143 -0
  22. package/dist/commands/screenshot.js +29 -0
  23. package/dist/commands/scroll-until-visible.js +69 -0
  24. package/dist/commands/scroll.js +36 -0
  25. package/dist/commands/session.js +49 -0
  26. package/dist/commands/set-location.js +18 -0
  27. package/dist/commands/set-orientation.js +23 -0
  28. package/dist/commands/start-device.js +178 -0
  29. package/dist/commands/stop-app.js +32 -0
  30. package/dist/commands/swipe.js +72 -0
  31. package/dist/commands/tap.js +69 -0
  32. package/dist/commands/type.js +22 -0
  33. package/dist/daemon/client.js +112 -0
  34. package/dist/daemon/protocol.js +25 -0
  35. package/dist/daemon/server.js +208 -0
  36. package/dist/drivers/android.js +343 -0
  37. package/dist/drivers/bootstrap.js +371 -0
  38. package/dist/drivers/element-resolver.js +371 -0
  39. package/dist/drivers/flow-runner.js +1309 -0
  40. package/dist/drivers/ios.js +328 -0
  41. package/dist/drivers/js-engine.js +150 -0
  42. package/dist/drivers/wait.js +211 -0
  43. package/dist/index.js +426 -0
  44. package/dist/output.js +36 -0
  45. package/dist/pkg-root.js +28 -0
  46. package/dist/postinstall.js +12 -0
  47. package/dist/runner.js +190 -0
  48. package/dist/session.js +66 -0
  49. package/dist/update-check.js +109 -0
  50. package/dist/utils.js +19 -0
  51. package/dist/verbose.js +17 -0
  52. package/drivers/android/conductor-app.apk +0 -0
  53. package/drivers/android/conductor-server.apk +0 -0
  54. package/drivers/ios/conductor-driver-ios-config.xctestrun +126 -0
  55. package/drivers/ios/conductor-driver-ios.zip +0 -0
  56. package/drivers/ios/conductor-driver-iosUITests-Runner.zip +0 -0
  57. package/package.json +52 -0
  58. package/proto/conductor_android.proto +116 -0
  59. package/skills/conductor/SKILL.md +677 -0
  60. package/skills/conductor/references/flow-syntax.md +179 -0
@@ -0,0 +1,1309 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.parseFlowFile = parseFlowFile;
7
+ exports.parseFlowString = parseFlowString;
8
+ exports.executeFlow = executeFlow;
9
+ /**
10
+ * Native Conductor YAML flow parser and executor.
11
+ * Parses flow YAML files and executes commands directly using IOSDriver / AndroidDriver.
12
+ */
13
+ const promises_1 = __importDefault(require("fs/promises"));
14
+ const path_1 = __importDefault(require("path"));
15
+ const node_vm_1 = __importDefault(require("node:vm"));
16
+ const js_yaml_1 = __importDefault(require("js-yaml"));
17
+ const ios_js_1 = require("./ios.js");
18
+ const android_js_1 = require("./android.js");
19
+ const wait_js_1 = require("./wait.js");
20
+ const perf_hooks_1 = require("perf_hooks");
21
+ const js_engine_js_1 = require("./js-engine.js");
22
+ const utils_js_1 = require("../utils.js");
23
+ function fmtMs(ms) {
24
+ return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`;
25
+ }
26
+ function toElementSelector(sel) {
27
+ // A bare string matches text OR id (Maestro's query semantics).
28
+ // Only object form with explicit `text:` or `id:` keys forces one field.
29
+ if (typeof sel === 'string')
30
+ return { query: sel };
31
+ return { text: sel.text, id: sel.id, index: sel.index };
32
+ }
33
+ // ── Parsing ───────────────────────────────────────────────────────────────────
34
+ function isSimpleIdentifier(s) {
35
+ return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(s);
36
+ }
37
+ function evalInlineExpr(expr, env) {
38
+ // output.xxx references are resolved later by resolveDeep — leave them
39
+ if (/^output\./.test(expr))
40
+ return `\${${expr}}`;
41
+ // Simple env var name — handle here too
42
+ if (isSimpleIdentifier(expr) && expr in env)
43
+ return env[expr];
44
+ // Try JS evaluation with env vars in scope
45
+ try {
46
+ const ctx = { ...env, undefined };
47
+ const result = node_vm_1.default.runInNewContext(expr, ctx);
48
+ return result !== undefined ? String(result) : '';
49
+ }
50
+ catch {
51
+ return `\${${expr}}`; // leave as-is if eval fails
52
+ }
53
+ }
54
+ function interpolate(value, env) {
55
+ return value.replace(/\$\{([^}]+)\}/g, (match, key) => {
56
+ // Check env var first (simple identifier lookup)
57
+ if (key in env)
58
+ return env[key];
59
+ // Check process.env
60
+ if (key in process.env)
61
+ return process.env[key];
62
+ // Try inline JS evaluation
63
+ return evalInlineExpr(key.trim(), env);
64
+ });
65
+ }
66
+ function interpolateDeep(obj, env) {
67
+ if (typeof obj === 'string')
68
+ return interpolate(obj, env);
69
+ if (Array.isArray(obj))
70
+ return obj.map((item) => interpolateDeep(item, env));
71
+ if (obj && typeof obj === 'object') {
72
+ const out = {};
73
+ for (const [k, v] of Object.entries(obj)) {
74
+ out[k] = interpolateDeep(v, env);
75
+ }
76
+ return out;
77
+ }
78
+ return obj;
79
+ }
80
+ async function parseFlowFile(filePath, extraEnv) {
81
+ const content = await promises_1.default.readFile(filePath, 'utf-8');
82
+ return parseFlowString(content, extraEnv);
83
+ }
84
+ function parseFlowString(content, extraEnv) {
85
+ const docs = [];
86
+ js_yaml_1.default.loadAll(content, (doc) => docs.push(doc));
87
+ let header = {};
88
+ let rawCommands;
89
+ if (docs.length >= 2) {
90
+ header = docs[0] ?? {};
91
+ rawCommands = docs[1];
92
+ }
93
+ else if (docs.length === 1) {
94
+ const doc = docs[0];
95
+ if (Array.isArray(doc)) {
96
+ rawCommands = doc;
97
+ }
98
+ else if (doc && typeof doc === 'object') {
99
+ // Single-document flow: either a header-only or treat as single command
100
+ const keys = Object.keys(doc);
101
+ const headerKeys = new Set(['appId', 'url', 'env', 'tags', 'onFlowStart', 'onFlowComplete']);
102
+ if (keys.every((k) => headerKeys.has(k))) {
103
+ header = doc;
104
+ rawCommands = [];
105
+ }
106
+ else {
107
+ rawCommands = [doc];
108
+ }
109
+ }
110
+ }
111
+ // CLI-supplied extraEnv overrides the flow's own env block (mirrors real Maestro behaviour)
112
+ const env = {
113
+ ...(header['env'] ?? {}),
114
+ ...(extraEnv ?? {}),
115
+ };
116
+ const commandList = Array.isArray(rawCommands) ? rawCommands : rawCommands ? [rawCommands] : [];
117
+ const onFlowStart = header['onFlowStart'];
118
+ const onFlowComplete = header['onFlowComplete'];
119
+ return {
120
+ appId: typeof header['appId'] === 'string' ? interpolate(header['appId'], env) : undefined,
121
+ env,
122
+ commands: interpolateDeep(commandList, env),
123
+ onFlowStart: Array.isArray(onFlowStart)
124
+ ? interpolateDeep(onFlowStart, env)
125
+ : undefined,
126
+ onFlowComplete: Array.isArray(onFlowComplete)
127
+ ? interpolateDeep(onFlowComplete, env)
128
+ : undefined,
129
+ };
130
+ }
131
+ // ── Execution helpers ─────────────────────────────────────────────────────────
132
+ async function getScreenSize(driver) {
133
+ if (driver instanceof ios_js_1.IOSDriver) {
134
+ const info = await driver.deviceInfo();
135
+ return { w: info.widthPoints, h: info.heightPoints };
136
+ }
137
+ else {
138
+ const info = await driver.deviceInfo();
139
+ return { w: info.widthPixels, h: info.heightPixels };
140
+ }
141
+ }
142
+ // Key used to persist the iOS permission dismissal setting across flow boundaries
143
+ // via the shared output object.
144
+ const OUTPUT_IOS_SHOULD_ALLOW = '__iosShouldAllow';
145
+ async function waitForElement(driver, sel, timeoutMs, appIds, opts) {
146
+ const elSel = toElementSelector(sel);
147
+ if (driver instanceof ios_js_1.IOSDriver) {
148
+ const iosShouldAllow = opts?.output[OUTPUT_IOS_SHOULD_ALLOW];
149
+ return (0, wait_js_1.waitForIOSElement)(() => iosGetHierarchy(driver, appIds ?? [], iosShouldAllow), elSel, timeoutMs);
150
+ }
151
+ else {
152
+ return (0, wait_js_1.waitForAndroidElement)(() => driver.viewHierarchy(), elSel, timeoutMs);
153
+ }
154
+ }
155
+ // ── iOS permission dialog dismissal ───────────────────────────────────────────
156
+ // XCTest element type numbers (XCUIElementType raw values)
157
+ const IOS_ELEMENT_TYPE_ALERT = 7; // XCUIElementType.alert
158
+ const IOS_ELEMENT_TYPE_DIALOG = 8; // XCUIElementType.dialog (some system prompts)
159
+ const IOS_ELEMENT_TYPE_SHEET = 5; // XCUIElementType.sheet (action sheets)
160
+ const IOS_ELEMENT_TYPE_BUTTON = 9; // XCUIElementType.button
161
+ // SpringBoard button labels for granting / denying permissions
162
+ const ALLOW_BUTTON_LABELS = new Set([
163
+ 'Allow',
164
+ 'Allow While Using App',
165
+ 'Allow Once',
166
+ 'Allow Full Access',
167
+ ]);
168
+ const DENY_BUTTON_LABELS = new Set(["Don't Allow", 'Ask App Not to Track']);
169
+ function walkAXElements(root, visit) {
170
+ visit(root);
171
+ for (const child of root.children ?? [])
172
+ walkAXElements(child, visit);
173
+ }
174
+ /**
175
+ * If `root` contains a recognisable SpringBoard permission dialog, tap the
176
+ * appropriate Allow/Deny button and return true. Returns false if no dialog found.
177
+ */
178
+ async function tapPermissionDialog(driver, root, shouldAllow) {
179
+ const targetLabels = shouldAllow ? ALLOW_BUTTON_LABELS : DENY_BUTTON_LABELS;
180
+ const alerts = [];
181
+ walkAXElements(root, (el) => {
182
+ if (el.elementType === IOS_ELEMENT_TYPE_ALERT ||
183
+ el.elementType === IOS_ELEMENT_TYPE_DIALOG ||
184
+ el.elementType === IOS_ELEMENT_TYPE_SHEET)
185
+ alerts.push(el);
186
+ });
187
+ for (const alert of alerts) {
188
+ const buttons = [];
189
+ walkAXElements(alert, (el) => {
190
+ if (el.elementType === IOS_ELEMENT_TYPE_BUTTON)
191
+ buttons.push(el);
192
+ });
193
+ const isPermissionDialog = buttons.some((b) => ALLOW_BUTTON_LABELS.has(b.label) || DENY_BUTTON_LABELS.has(b.label));
194
+ if (!isPermissionDialog)
195
+ continue;
196
+ const button = buttons.find((b) => targetLabels.has(b.label));
197
+ if (button) {
198
+ await driver.tap(button.frame.X + button.frame.Width / 2, button.frame.Y + button.frame.Height / 2);
199
+ await (0, utils_js_1.sleep)(500);
200
+ return true;
201
+ }
202
+ }
203
+ return false;
204
+ }
205
+ /**
206
+ * Fetch the iOS view hierarchy. If shouldAllow is set, dismiss one permission
207
+ * dialog (if present) before returning so the caller sees a clean hierarchy.
208
+ * The waitForIOSElement retry loop handles multiple dialogs across iterations.
209
+ */
210
+ async function iosGetHierarchy(driver, appIds, shouldAllow) {
211
+ const root = (await driver.viewHierarchy(false, appIds)).axElement;
212
+ if (shouldAllow !== undefined) {
213
+ const tapped = await tapPermissionDialog(driver, root, shouldAllow);
214
+ if (tapped)
215
+ return (await driver.viewHierarchy(false, appIds)).axElement;
216
+ }
217
+ return root;
218
+ }
219
+ /**
220
+ * After launchApp on iOS, eagerly dismiss all pending permission dialogs.
221
+ * Repeats up to maxRounds to handle apps that show multiple dialogs in sequence.
222
+ */
223
+ async function dismissIOSPermissionDialogs(driver, shouldAllow, appIds) {
224
+ for (let round = 0; round < 8; round++) {
225
+ let root;
226
+ try {
227
+ root = (await driver.viewHierarchy(false, appIds)).axElement;
228
+ }
229
+ catch {
230
+ return;
231
+ }
232
+ const tapped = await tapPermissionDialog(driver, root, shouldAllow);
233
+ if (!tapped)
234
+ return;
235
+ }
236
+ }
237
+ /**
238
+ * Wait for the screen to settle after any navigation action (tap, swipe, link).
239
+ * iOS: two-phase — waits for the transition to start, then for it to finish.
240
+ * Uses the /isScreenStatic endpoint (two back-to-back screenshots, SHA256 hash compare).
241
+ * Android: falls back to hierarchy-based settle.
242
+ */
243
+ async function waitForSettle(driver) {
244
+ if (driver instanceof ios_js_1.IOSDriver) {
245
+ await (0, wait_js_1.waitForIOSTransitionToSettle)(() => driver.isScreenStatic());
246
+ }
247
+ else {
248
+ await (0, wait_js_1.waitForAndroidHierarchyToSettle)(() => driver.viewHierarchy());
249
+ }
250
+ }
251
+ async function findElementNoThrow(driver, sel, timeoutMs = 1000, appIds, opts) {
252
+ try {
253
+ return await waitForElement(driver, sel, timeoutMs, appIds, opts);
254
+ }
255
+ catch {
256
+ return null;
257
+ }
258
+ }
259
+ function parseCoords(s) {
260
+ const [xs, ys] = s.split(',').map((p) => p.trim());
261
+ return { x: parseFloat(xs), y: parseFloat(ys) };
262
+ }
263
+ async function performSwipe(driver, direction, startXY, endXY, durationMs = 500) {
264
+ const { w, h } = await getScreenSize(driver);
265
+ let startX, startY, endX, endY;
266
+ if (startXY && endXY) {
267
+ const s = parseCoords(startXY);
268
+ const e = parseCoords(endXY);
269
+ // Support normalised (0–1) or absolute pixel coordinates
270
+ startX = s.x <= 1 ? s.x * w : s.x;
271
+ startY = s.y <= 1 ? s.y * h : s.y;
272
+ endX = e.x <= 1 ? e.x * w : e.x;
273
+ endY = e.y <= 1 ? e.y * h : e.y;
274
+ }
275
+ else {
276
+ const cx = w / 2;
277
+ const cy = h / 2;
278
+ switch (direction.toUpperCase()) {
279
+ case 'DOWN':
280
+ startX = cx;
281
+ startY = h * 0.7;
282
+ endX = cx;
283
+ endY = h * 0.3;
284
+ break;
285
+ case 'UP':
286
+ startX = cx;
287
+ startY = h * 0.3;
288
+ endX = cx;
289
+ endY = h * 0.7;
290
+ break;
291
+ case 'LEFT':
292
+ startX = w * 0.8;
293
+ startY = cy;
294
+ endX = w * 0.2;
295
+ endY = cy;
296
+ break;
297
+ case 'RIGHT':
298
+ startX = w * 0.2;
299
+ startY = cy;
300
+ endX = w * 0.8;
301
+ endY = cy;
302
+ break;
303
+ default:
304
+ startX = cx;
305
+ startY = h * 0.7;
306
+ endX = cx;
307
+ endY = h * 0.3;
308
+ break;
309
+ }
310
+ }
311
+ if (driver instanceof ios_js_1.IOSDriver) {
312
+ await driver.swipe(startX, startY, endX, endY, durationMs / 1000);
313
+ }
314
+ else {
315
+ await driver.swipe(startX, startY, endX, endY, durationMs);
316
+ }
317
+ }
318
+ // Map Conductor key names → Android keycodes
319
+ const ANDROID_KEYCODES = {
320
+ BACK: 4,
321
+ HOME: 3,
322
+ ENTER: 66,
323
+ RETURN: 66,
324
+ DELETE: 67,
325
+ BACKSPACE: 67,
326
+ TAB: 61,
327
+ SPACE: 62,
328
+ ESCAPE: 111,
329
+ SEARCH: 84,
330
+ VOLUME_UP: 24,
331
+ VOLUME_DOWN: 25,
332
+ POWER: 26,
333
+ DPAD_UP: 19,
334
+ DPAD_DOWN: 20,
335
+ DPAD_LEFT: 21,
336
+ DPAD_RIGHT: 22,
337
+ DPAD_CENTER: 23,
338
+ };
339
+ async function executeFlow(flow, driver, opts = {}) {
340
+ const cliEnv = opts.env ?? {};
341
+ const execOpts = {
342
+ cwd: opts.cwd,
343
+ appId: opts.appId ?? flow.appId,
344
+ // CLI env overrides flow env at runtime (same priority as parse time)
345
+ env: { ...flow.env, ...cliEnv },
346
+ cliEnv,
347
+ output: opts.output ?? {},
348
+ depth: opts.depth ?? 0,
349
+ benchmark: opts.benchmark,
350
+ };
351
+ const flowStart = opts.benchmark ? perf_hooks_1.performance.now() : 0;
352
+ if (flow.onFlowStart?.length) {
353
+ console.log('[onFlowStart]');
354
+ await executeCommands(flow.onFlowStart, driver, execOpts);
355
+ }
356
+ let mainError;
357
+ try {
358
+ await executeCommands(flow.commands, driver, execOpts);
359
+ }
360
+ catch (err) {
361
+ mainError = err;
362
+ }
363
+ finally {
364
+ if (flow.onFlowComplete?.length) {
365
+ console.log('[onFlowComplete]');
366
+ try {
367
+ await executeCommands(flow.onFlowComplete, driver, execOpts);
368
+ }
369
+ catch (cleanupErr) {
370
+ if (mainError !== undefined) {
371
+ // Main flow already failed; log cleanup error but let original propagate
372
+ console.error(`[onFlowComplete] cleanup error: ${cleanupErr instanceof Error ? cleanupErr.message : cleanupErr}`);
373
+ }
374
+ else {
375
+ throw cleanupErr;
376
+ }
377
+ }
378
+ }
379
+ }
380
+ if (mainError !== undefined)
381
+ throw mainError;
382
+ if (opts.benchmark && (opts.depth ?? 0) === 0) {
383
+ console.log(`\nBenchmark: total ${fmtMs(perf_hooks_1.performance.now() - flowStart)}`);
384
+ }
385
+ }
386
+ async function executeCommands(commands, driver, opts) {
387
+ for (const cmd of commands) {
388
+ await executeCommand(cmd, driver, opts);
389
+ }
390
+ }
391
+ /** Resolve ${output.key} references at execution time (after scripts have run). */
392
+ function resolveDeep(val, output) {
393
+ if (typeof val === 'string') {
394
+ return val.replace(/\$\{output\.([^}]+)\}/g, (match, key) => {
395
+ // Support both flat keys and dotted paths (e.g. output.authProfile.username)
396
+ if (key in output)
397
+ return output[key] !== undefined ? String(output[key]) : '';
398
+ const parts = key.split('.');
399
+ let cur = output;
400
+ for (const part of parts) {
401
+ cur = cur?.[part];
402
+ if (cur === undefined)
403
+ return match;
404
+ }
405
+ return cur !== undefined ? String(cur) : match;
406
+ });
407
+ }
408
+ if (Array.isArray(val))
409
+ return val.map((item) => resolveDeep(item, output));
410
+ if (val && typeof val === 'object') {
411
+ const out = {};
412
+ for (const [k, v] of Object.entries(val)) {
413
+ out[k] = resolveDeep(v, output);
414
+ }
415
+ return out;
416
+ }
417
+ return val;
418
+ }
419
+ function selectorLabel(sel) {
420
+ if (typeof sel === 'string')
421
+ return JSON.stringify(sel);
422
+ const parts = [];
423
+ if (sel.text)
424
+ parts.push(`text=${JSON.stringify(sel.text)}`);
425
+ if (sel.id)
426
+ parts.push(`id=${JSON.stringify(sel.id)}`);
427
+ return parts.join(' ') || JSON.stringify(sel);
428
+ }
429
+ function describeCommand(key, val, customLabel) {
430
+ if (customLabel)
431
+ return customLabel;
432
+ if (val === null || val === undefined || val === '')
433
+ return key;
434
+ if (typeof val === 'string')
435
+ return `${key} ${JSON.stringify(val)}`;
436
+ if (typeof val === 'number')
437
+ return `${key} ${val}`;
438
+ if (typeof val === 'object') {
439
+ const v = val;
440
+ const autoLabel = v['text'] ?? v['id'] ?? v['appId'] ?? v['file'] ?? v['direction'] ?? v['path'];
441
+ return autoLabel ? `${key} ${JSON.stringify(autoLabel)}` : key;
442
+ }
443
+ return key;
444
+ }
445
+ async function executeCommand(cmd, driver, opts) {
446
+ // Bare string: `- launchApp`, `- back`, `- scroll`, etc. — treat as { commandName: null }
447
+ if (typeof cmd === 'string') {
448
+ return executeCommand({ [cmd]: null }, driver, opts);
449
+ }
450
+ const key = Object.keys(cmd)[0];
451
+ if (!key)
452
+ return;
453
+ const val = cmd[key];
454
+ // Resolve ${output.key} references that couldn't be resolved at parse time
455
+ const resolvedVal = resolveDeep(val, opts.output);
456
+ // Extract label and optional from the resolved value (if it's an object)
457
+ const customLabel = typeof resolvedVal === 'object' && resolvedVal !== null
458
+ ? resolvedVal['label']
459
+ : undefined;
460
+ const optional = typeof resolvedVal === 'object' &&
461
+ resolvedVal !== null &&
462
+ resolvedVal['optional'] === true;
463
+ const label = describeCommand(key, val, customLabel);
464
+ const indent = ' '.repeat(opts.depth + 1);
465
+ // Compound commands produce nested output — print header on its own line
466
+ const isCompound = key === 'runFlow' || key === 'repeat' || key === 'retry' || key === 'extendedWaitUntil';
467
+ if (isCompound) {
468
+ console.log(`${indent}→ ${label}`);
469
+ }
470
+ else {
471
+ process.stdout.write(`${indent}→ ${label} ... `);
472
+ }
473
+ const t0 = opts.benchmark ? perf_hooks_1.performance.now() : 0;
474
+ try {
475
+ await executeCommandBody(key, resolvedVal, driver, opts);
476
+ const elapsed = opts.benchmark ? ` (${fmtMs(perf_hooks_1.performance.now() - t0)})` : '';
477
+ if (!isCompound) {
478
+ console.log(`ok${elapsed}`);
479
+ }
480
+ else if (opts.benchmark) {
481
+ console.log(`${indent} ↳ ${fmtMs(perf_hooks_1.performance.now() - t0)}`);
482
+ }
483
+ }
484
+ catch (err) {
485
+ const msg = err instanceof Error ? err.message : String(err);
486
+ const elapsed = opts.benchmark ? ` (${fmtMs(perf_hooks_1.performance.now() - t0)})` : '';
487
+ if (optional) {
488
+ // For compound commands the sub-command already printed its warning/failure
489
+ if (!isCompound)
490
+ console.log(`warning (optional): ${msg}${elapsed}`);
491
+ }
492
+ else {
493
+ if (!isCompound)
494
+ console.log(`FAILED${elapsed}\n${indent} ${msg}`);
495
+ throw err;
496
+ }
497
+ }
498
+ }
499
+ function getConductorObj(driver, output) {
500
+ return {
501
+ platform: driver instanceof ios_js_1.IOSDriver ? 'ios' : 'android',
502
+ copiedText: output['__copiedText'] ?? '',
503
+ };
504
+ }
505
+ async function resolvePoint(point, driver) {
506
+ const [xs, ys] = point.split(',').map((s) => s.trim());
507
+ const xRaw = parseFloat(xs);
508
+ const yRaw = parseFloat(ys);
509
+ const isRelX = xs.endsWith('%');
510
+ const isRelY = ys.endsWith('%');
511
+ if (isRelX || isRelY) {
512
+ const { w, h } = await getScreenSize(driver);
513
+ return {
514
+ x: isRelX ? (xRaw / 100) * w : xRaw,
515
+ y: isRelY ? (yRaw / 100) * h : yRaw,
516
+ };
517
+ }
518
+ // values <= 1.0 are treated as fractional
519
+ if (xRaw <= 1.0 && yRaw <= 1.0) {
520
+ const { w, h } = await getScreenSize(driver);
521
+ return { x: xRaw * w, y: yRaw * h };
522
+ }
523
+ return { x: xRaw, y: yRaw };
524
+ }
525
+ async function evaluateWhen(when, driver, opts) {
526
+ if ('true' in when) {
527
+ let expr = String(when['true']);
528
+ // Strip ${...} wrapper left over from parse-time interpolation
529
+ const match = /^\$\{(.+)\}$/.exec(expr);
530
+ if (match)
531
+ expr = match[1];
532
+ if (expr === 'true')
533
+ return true;
534
+ if (expr === 'false')
535
+ return false;
536
+ await (0, js_engine_js_1.executeScript)(`output.__whenCond = !!(${expr});`, opts.env, opts.output, 'when.true', getConductorObj(driver, opts.output));
537
+ const result = opts.output['__whenCond'];
538
+ delete opts.output['__whenCond'];
539
+ return result;
540
+ }
541
+ const appIds = opts.appId ? [opts.appId] : undefined;
542
+ if ('visible' in when) {
543
+ return ((await findElementNoThrow(driver, when['visible'], 1000, appIds, opts)) !==
544
+ null);
545
+ }
546
+ if ('notVisible' in when) {
547
+ return ((await findElementNoThrow(driver, when['notVisible'], 1000, appIds, opts)) ===
548
+ null);
549
+ }
550
+ return true;
551
+ }
552
+ async function executeCommandBody(key, val, driver, opts) {
553
+ const appIds = opts.appId ? [opts.appId] : undefined;
554
+ switch (key) {
555
+ // ── Element interactions ───────────────────────────────────────────────
556
+ case 'tapOn': {
557
+ const v = val;
558
+ if (typeof v === 'object' && v !== null && v.point) {
559
+ const { x, y } = await resolvePoint(v.point, driver);
560
+ await driver.tap(x, y);
561
+ }
562
+ else {
563
+ const isOptional = typeof v === 'object' && v !== null && v.optional === true;
564
+ const el = await waitForElement(driver, val, isOptional ? wait_js_1.OPTIONAL_TIMEOUT_MS : undefined, appIds, opts);
565
+ const repeatCount = typeof v === 'object' && v?.repeat ? v.repeat : 1;
566
+ const delay = typeof v === 'object' && v?.delay ? v.delay : 100;
567
+ await driver.tap(el.centerX, el.centerY);
568
+ for (let i = 1; i < repeatCount; i++) {
569
+ await (0, utils_js_1.sleep)(delay);
570
+ await driver.tap(el.centerX, el.centerY);
571
+ }
572
+ }
573
+ await waitForSettle(driver);
574
+ break;
575
+ }
576
+ case 'doubleTapOn': {
577
+ const v = val;
578
+ if (typeof v === 'object' && v !== null && v.point) {
579
+ const { x, y } = await resolvePoint(v.point, driver);
580
+ await driver.tap(x, y);
581
+ await (0, utils_js_1.sleep)(100);
582
+ await driver.tap(x, y);
583
+ }
584
+ else {
585
+ const isOptional = typeof v === 'object' && v !== null && v.optional === true;
586
+ const el = await waitForElement(driver, val, isOptional ? wait_js_1.OPTIONAL_TIMEOUT_MS : undefined, appIds, opts);
587
+ await driver.tap(el.centerX, el.centerY);
588
+ await (0, utils_js_1.sleep)(100);
589
+ await driver.tap(el.centerX, el.centerY);
590
+ }
591
+ await waitForSettle(driver);
592
+ break;
593
+ }
594
+ case 'longPressOn': {
595
+ const v = val;
596
+ if (typeof v === 'object' && v !== null && v.point) {
597
+ const { x, y } = await resolvePoint(v.point, driver);
598
+ if (driver instanceof ios_js_1.IOSDriver) {
599
+ await driver.tap(x, y, 1.5);
600
+ }
601
+ else {
602
+ await driver.swipe(x, y, x, y, 1500);
603
+ }
604
+ }
605
+ else {
606
+ const isOptional = typeof v === 'object' && v !== null && v.optional === true;
607
+ const el = await waitForElement(driver, val, isOptional ? wait_js_1.OPTIONAL_TIMEOUT_MS : undefined, appIds, opts);
608
+ if (driver instanceof ios_js_1.IOSDriver) {
609
+ await driver.tap(el.centerX, el.centerY, 1.5);
610
+ }
611
+ else {
612
+ await driver.swipe(el.centerX, el.centerY, el.centerX, el.centerY, 1500);
613
+ }
614
+ }
615
+ await waitForSettle(driver);
616
+ break;
617
+ }
618
+ case 'inputText': {
619
+ await driver.inputText(val);
620
+ break;
621
+ }
622
+ case 'eraseText': {
623
+ const n = typeof val === 'number'
624
+ ? val
625
+ : (val?.charactersToErase ?? 50);
626
+ if (driver instanceof android_js_1.AndroidDriver) {
627
+ await driver.eraseAllText(n);
628
+ }
629
+ else {
630
+ for (let i = 0; i < n; i++)
631
+ await driver.pressKey('delete');
632
+ }
633
+ break;
634
+ }
635
+ case 'inputRandomText': {
636
+ const length = typeof val === 'number' ? val : (val?.length ?? 8);
637
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
638
+ let text = '';
639
+ for (let i = 0; i < length; i++)
640
+ text += chars[Math.floor(Math.random() * chars.length)];
641
+ await driver.inputText(text);
642
+ break;
643
+ }
644
+ case 'inputRandomNumber': {
645
+ const length = typeof val === 'number' ? val : (val?.length ?? 8);
646
+ let num = '';
647
+ for (let i = 0; i < length; i++)
648
+ num += Math.floor(Math.random() * 10).toString();
649
+ await driver.inputText(num);
650
+ break;
651
+ }
652
+ case 'inputRandomEmail': {
653
+ const local = Math.random().toString(36).slice(2, 10);
654
+ await driver.inputText(`${local}@example.com`);
655
+ break;
656
+ }
657
+ case 'inputRandomPersonName': {
658
+ const firstNames = [
659
+ 'Alice',
660
+ 'Bob',
661
+ 'Charlie',
662
+ 'Diana',
663
+ 'Eve',
664
+ 'Frank',
665
+ 'Grace',
666
+ 'Henry',
667
+ 'Iris',
668
+ 'Jack',
669
+ ];
670
+ const lastNames = [
671
+ 'Smith',
672
+ 'Johnson',
673
+ 'Williams',
674
+ 'Brown',
675
+ 'Jones',
676
+ 'Garcia',
677
+ 'Miller',
678
+ 'Davis',
679
+ 'Wilson',
680
+ 'Moore',
681
+ ];
682
+ const name = `${firstNames[Math.floor(Math.random() * firstNames.length)]} ${lastNames[Math.floor(Math.random() * lastNames.length)]}`;
683
+ await driver.inputText(name);
684
+ break;
685
+ }
686
+ case 'inputRandomCityName': {
687
+ const cities = [
688
+ 'New York',
689
+ 'Los Angeles',
690
+ 'Chicago',
691
+ 'Houston',
692
+ 'Phoenix',
693
+ 'Philadelphia',
694
+ 'San Antonio',
695
+ 'San Diego',
696
+ 'Dallas',
697
+ 'San Jose',
698
+ 'Austin',
699
+ 'Jacksonville',
700
+ 'Seattle',
701
+ 'Denver',
702
+ 'Boston',
703
+ ];
704
+ await driver.inputText(cities[Math.floor(Math.random() * cities.length)]);
705
+ break;
706
+ }
707
+ case 'inputRandomCountryName': {
708
+ const countries = [
709
+ 'United States',
710
+ 'Canada',
711
+ 'United Kingdom',
712
+ 'Australia',
713
+ 'Germany',
714
+ 'France',
715
+ 'Japan',
716
+ 'Brazil',
717
+ 'India',
718
+ 'Mexico',
719
+ 'Italy',
720
+ 'Spain',
721
+ 'Netherlands',
722
+ 'Sweden',
723
+ 'Norway',
724
+ ];
725
+ await driver.inputText(countries[Math.floor(Math.random() * countries.length)]);
726
+ break;
727
+ }
728
+ case 'inputRandomColorName': {
729
+ const colors = [
730
+ 'Red',
731
+ 'Blue',
732
+ 'Green',
733
+ 'Yellow',
734
+ 'Purple',
735
+ 'Orange',
736
+ 'Pink',
737
+ 'Brown',
738
+ 'Black',
739
+ 'White',
740
+ 'Gray',
741
+ 'Cyan',
742
+ 'Magenta',
743
+ 'Indigo',
744
+ 'Violet',
745
+ ];
746
+ await driver.inputText(colors[Math.floor(Math.random() * colors.length)]);
747
+ break;
748
+ }
749
+ // ── Scroll / swipe ─────────────────────────────────────────────────────
750
+ case 'scroll': {
751
+ const direction = val?.direction ?? 'DOWN';
752
+ await performSwipe(driver, direction);
753
+ break;
754
+ }
755
+ case 'swipe': {
756
+ if (typeof val === 'string') {
757
+ await performSwipe(driver, val);
758
+ }
759
+ else {
760
+ const v = val;
761
+ await performSwipe(driver, v.direction ?? 'DOWN', v.start, v.end, v.duration);
762
+ }
763
+ break;
764
+ }
765
+ // ── Navigation ─────────────────────────────────────────────────────────
766
+ case 'back': {
767
+ if (driver instanceof android_js_1.AndroidDriver)
768
+ await driver.back();
769
+ // iOS has no hardware back button — noop
770
+ break;
771
+ }
772
+ case 'openLink': {
773
+ // Accepts string URL or object { link: "...", browser?: bool }
774
+ const url = typeof val === 'string' ? val : val.link;
775
+ await driver.openLink(url);
776
+ await waitForSettle(driver);
777
+ break;
778
+ }
779
+ case 'openBrowser': {
780
+ await driver.openLink(val);
781
+ await waitForSettle(driver);
782
+ break;
783
+ }
784
+ case 'hide keyboard': {
785
+ if (driver instanceof ios_js_1.IOSDriver) {
786
+ await driver.pressKey('return').catch(() => {
787
+ /* noop if no keyboard */
788
+ });
789
+ }
790
+ else {
791
+ await driver.pressKeyEvent(111); // KEYCODE_ESCAPE
792
+ }
793
+ break;
794
+ }
795
+ // ── Assertions ─────────────────────────────────────────────────────────
796
+ case 'assertVisible': {
797
+ const optional = typeof val === 'object' &&
798
+ val !== null &&
799
+ val.optional === true;
800
+ if (optional) {
801
+ await findElementNoThrow(driver, val, wait_js_1.OPTIONAL_TIMEOUT_MS, appIds, opts);
802
+ }
803
+ else {
804
+ await waitForElement(driver, val, undefined, appIds, opts);
805
+ }
806
+ break;
807
+ }
808
+ case 'assertNotVisible': {
809
+ const el = await findElementNoThrow(driver, val, 1000, appIds, opts);
810
+ if (el !== null) {
811
+ throw new Error(`assertNotVisible failed: element is visible: ${JSON.stringify(val)}`);
812
+ }
813
+ break;
814
+ }
815
+ case 'assertTrue': {
816
+ // Evaluate a JS expression; throw if falsy
817
+ // Accepts string expression or { condition: "expr" }
818
+ const expr = typeof val === 'string' ? val : val.condition;
819
+ await (0, js_engine_js_1.executeScript)(`output.__assertTrue = !!(${expr});`, opts.env, opts.output, 'assertTrue', getConductorObj(driver, opts.output));
820
+ const result = opts.output['__assertTrue'];
821
+ delete opts.output['__assertTrue'];
822
+ if (!result)
823
+ throw new Error(`assertTrue failed: ${expr}`);
824
+ break;
825
+ }
826
+ case 'assertFalse': {
827
+ // Evaluate a JS expression; throw if truthy
828
+ // Accepts string expression or { condition: "expr" }
829
+ const expr = typeof val === 'string' ? val : val.condition;
830
+ await (0, js_engine_js_1.executeScript)(`output.__assertFalse = !(${expr});`, opts.env, opts.output, 'assertFalse', getConductorObj(driver, opts.output));
831
+ const result = opts.output['__assertFalse'];
832
+ delete opts.output['__assertFalse'];
833
+ if (!result)
834
+ throw new Error(`assertFalse failed: ${expr}`);
835
+ break;
836
+ }
837
+ case 'extendedWaitUntil': {
838
+ const v = val;
839
+ const timeoutMs = v.timeout ?? 30000;
840
+ const sub = ' '.repeat(opts.depth + 2);
841
+ if (v.visible !== undefined) {
842
+ const condLabel = selectorLabel(v.visible);
843
+ process.stdout.write(`${sub}→ visible ${condLabel} ... `);
844
+ try {
845
+ await waitForElement(driver, v.visible, timeoutMs, appIds, opts);
846
+ console.log('ok');
847
+ }
848
+ catch (err) {
849
+ const msg = err instanceof Error ? err.message : String(err);
850
+ console.log(`FAILED\n${sub} ${msg}`);
851
+ throw err;
852
+ }
853
+ }
854
+ else if (v.notVisible !== undefined) {
855
+ const condLabel = selectorLabel(v.notVisible);
856
+ process.stdout.write(`${sub}→ notVisible ${condLabel} ... `);
857
+ const deadline = Date.now() + timeoutMs;
858
+ while (Date.now() < deadline) {
859
+ const el = await findElementNoThrow(driver, v.notVisible, 1000, appIds, opts);
860
+ if (el === null) {
861
+ console.log('ok');
862
+ return;
863
+ }
864
+ await (0, utils_js_1.sleep)(500);
865
+ }
866
+ const msg = `element still visible after ${timeoutMs}ms`;
867
+ console.log(`FAILED\n${sub} ${msg}`);
868
+ throw new Error(`extendedWaitUntil.notVisible: ${msg}`);
869
+ }
870
+ break;
871
+ }
872
+ case 'copyTextFrom': {
873
+ const el = await waitForElement(driver, val, undefined, appIds, opts);
874
+ const copiedText = el.text ?? '';
875
+ opts.output['textContent'] = copiedText;
876
+ opts.output['__copiedText'] = copiedText;
877
+ break;
878
+ }
879
+ // ── App lifecycle ──────────────────────────────────────────────────────
880
+ case 'launchApp': {
881
+ let appId;
882
+ let clearStateFlag = false;
883
+ let clearKeychainFlag = false;
884
+ let stopAppFlag = true;
885
+ let permissions;
886
+ let launchArgs;
887
+ if (val == null || val === '') {
888
+ appId =
889
+ opts.appId ??
890
+ (() => {
891
+ throw new Error('launchApp: no appId in command or flow header');
892
+ })();
893
+ }
894
+ else if (typeof val === 'string') {
895
+ appId = val;
896
+ }
897
+ else {
898
+ const v = val;
899
+ appId =
900
+ v.appId ??
901
+ opts.appId ??
902
+ (() => {
903
+ throw new Error('launchApp: no appId in command or flow header');
904
+ })();
905
+ clearStateFlag = v.clearState ?? false;
906
+ clearKeychainFlag = v.clearKeychain ?? false;
907
+ permissions = v.permissions;
908
+ launchArgs = v.arguments;
909
+ stopAppFlag = v.stopApp ?? true;
910
+ }
911
+ if (clearKeychainFlag)
912
+ await driver.clearKeychain();
913
+ if (clearStateFlag)
914
+ await driver.clearAppState(appId);
915
+ if (permissions)
916
+ await driver.setPermissions(appId, permissions);
917
+ if (stopAppFlag) {
918
+ if (driver instanceof ios_js_1.IOSDriver)
919
+ await driver.terminateApp(appId);
920
+ else if (driver instanceof android_js_1.AndroidDriver)
921
+ await driver.stopApp(appId);
922
+ }
923
+ await driver.launchApp(appId, launchArgs);
924
+ if (driver instanceof ios_js_1.IOSDriver && permissions) {
925
+ // Determine allow vs deny from the permissions map.
926
+ // `all` is the canonical key; fall back to majority vote across explicit keys.
927
+ const allValue = permissions['all'];
928
+ let shouldAllow;
929
+ if (allValue !== undefined) {
930
+ shouldAllow = allValue === 'allow';
931
+ }
932
+ else {
933
+ const vals = Object.values(permissions);
934
+ shouldAllow =
935
+ vals.filter((v) => v === 'allow').length >= vals.filter((v) => v === 'deny').length;
936
+ }
937
+ // Store in shared output so all subsequent hierarchy polls across flow boundaries
938
+ // will also dismiss permission dialogs (dialogs can appear during metro loading etc.)
939
+ opts.output[OUTPUT_IOS_SHOULD_ALLOW] = shouldAllow;
940
+ // Also eagerly dismiss any dialogs that appeared immediately after launch
941
+ await dismissIOSPermissionDialogs(driver, shouldAllow, appIds ?? []);
942
+ }
943
+ break;
944
+ }
945
+ case 'stopApp': {
946
+ const appId = val == null || val === ''
947
+ ? (opts.appId ??
948
+ (() => {
949
+ throw new Error('stopApp: no appId in command or flow header');
950
+ })())
951
+ : resolveAppId(val, 'stopApp');
952
+ if (driver instanceof ios_js_1.IOSDriver) {
953
+ await driver.terminateApp(appId);
954
+ }
955
+ else {
956
+ await driver.stopApp(appId);
957
+ }
958
+ break;
959
+ }
960
+ case 'killApp': {
961
+ const appId = val == null || val === ''
962
+ ? (opts.appId ??
963
+ (() => {
964
+ throw new Error('killApp: no appId in command or flow header');
965
+ })())
966
+ : resolveAppId(val, 'killApp');
967
+ if (driver instanceof ios_js_1.IOSDriver) {
968
+ await driver.terminateApp(appId);
969
+ }
970
+ else {
971
+ await driver.stopApp(appId);
972
+ }
973
+ break;
974
+ }
975
+ case 'clearState': {
976
+ const appId = val == null || val === ''
977
+ ? (opts.appId ??
978
+ (() => {
979
+ throw new Error('clearState: no appId in command or flow header');
980
+ })())
981
+ : resolveAppId(val, 'clearState');
982
+ await driver.clearAppState(appId);
983
+ break;
984
+ }
985
+ case 'clearKeychain': {
986
+ await driver.clearKeychain();
987
+ break;
988
+ }
989
+ // ── Keys ───────────────────────────────────────────────────────────────
990
+ case 'pressKey': {
991
+ const keyName = val.toUpperCase();
992
+ if (driver instanceof ios_js_1.IOSDriver) {
993
+ // Home and Lock are hardware buttons on iOS, not software keys
994
+ if (keyName === 'HOME') {
995
+ await driver.pressButton('home');
996
+ }
997
+ else if (keyName === 'LOCK' || keyName === 'POWER') {
998
+ await driver.pressButton('lock');
999
+ }
1000
+ else {
1001
+ await driver.pressKey(mapIosKey(keyName));
1002
+ }
1003
+ }
1004
+ else {
1005
+ const keycode = ANDROID_KEYCODES[keyName];
1006
+ if (keycode === undefined)
1007
+ throw new Error(`pressKey: unknown key "${val}"`);
1008
+ await driver.pressKeyEvent(keycode);
1009
+ }
1010
+ break;
1011
+ }
1012
+ case 'hideKeyboard': {
1013
+ if (driver instanceof ios_js_1.IOSDriver) {
1014
+ await driver.pressKey('return').catch(() => {
1015
+ /* noop if no keyboard */
1016
+ });
1017
+ }
1018
+ else {
1019
+ await driver.pressKeyEvent(111); // KEYCODE_ESCAPE
1020
+ }
1021
+ break;
1022
+ }
1023
+ case 'pasteText': {
1024
+ const clip = opts.output['__clipboard'] ?? '';
1025
+ if (clip) {
1026
+ await driver.inputText(clip);
1027
+ }
1028
+ else if (driver instanceof android_js_1.AndroidDriver) {
1029
+ await driver.pressKeyEvent(279); // KEYCODE_PASTE
1030
+ }
1031
+ // iOS without stored clipboard: no-op (best effort)
1032
+ break;
1033
+ }
1034
+ case 'setClipboard': {
1035
+ // Store for use by pasteText within this flow
1036
+ opts.output['__clipboard'] = val;
1037
+ break;
1038
+ }
1039
+ // ── Device state ───────────────────────────────────────────────────────
1040
+ case 'setLocation': {
1041
+ const v = val;
1042
+ await driver.setLocation(v.latitude, v.longitude);
1043
+ break;
1044
+ }
1045
+ case 'setOrientation': {
1046
+ await driver.setOrientation(val);
1047
+ break;
1048
+ }
1049
+ case 'setPermissions': {
1050
+ const v = val;
1051
+ // Support both { permissions: {...} } and flat { camera: allow } forms
1052
+ let appId;
1053
+ let permissions;
1054
+ if (v &&
1055
+ typeof v === 'object' &&
1056
+ 'permissions' in v &&
1057
+ typeof v.permissions === 'object') {
1058
+ const vv = v;
1059
+ appId = vv.appId ?? opts.appId ?? '';
1060
+ permissions = vv.permissions;
1061
+ }
1062
+ else {
1063
+ appId = opts.appId ?? '';
1064
+ permissions = v;
1065
+ }
1066
+ await driver.setPermissions(appId, permissions);
1067
+ break;
1068
+ }
1069
+ case 'addMedia': {
1070
+ // Accepts string, { path: "..." }, or { files: [...] }
1071
+ const files = typeof val === 'string'
1072
+ ? [val]
1073
+ : Array.isArray(val.files)
1074
+ ? val.files
1075
+ : [val.path];
1076
+ for (const f of files)
1077
+ await driver.addMedia(resolvePath(f, opts.cwd));
1078
+ break;
1079
+ }
1080
+ case 'setAirplaneMode': {
1081
+ // Accepts boolean, string, or { value: bool }
1082
+ const raw = val && typeof val === 'object' ? val.value : val;
1083
+ const enabled = raw === true || raw === 'enable' || raw === 'enabled';
1084
+ await driver.setAirplaneMode(enabled);
1085
+ break;
1086
+ }
1087
+ case 'toggleAirplaneMode': {
1088
+ if (driver instanceof android_js_1.AndroidDriver) {
1089
+ const current = await driver.getAirplaneMode();
1090
+ await driver.setAirplaneMode(!current);
1091
+ }
1092
+ else {
1093
+ throw new Error('toggleAirplaneMode is not supported on iOS simulators');
1094
+ }
1095
+ break;
1096
+ }
1097
+ case 'travel': {
1098
+ // points can be objects { latitude, longitude } or "lat,lon" strings (Conductor format)
1099
+ const rawTravel = val;
1100
+ const v = {
1101
+ speed: rawTravel.speed,
1102
+ points: rawTravel.points.map((p) => {
1103
+ if (typeof p === 'string') {
1104
+ const [lat, lon] = p.split(',').map(Number);
1105
+ return { latitude: lat, longitude: lon };
1106
+ }
1107
+ return p;
1108
+ }),
1109
+ };
1110
+ const EARTH_RADIUS = 6371000; // meters
1111
+ for (let i = 0; i < v.points.length; i++) {
1112
+ const pt = v.points[i];
1113
+ await driver.setLocation(pt.latitude, pt.longitude);
1114
+ if (i < v.points.length - 1 && v.speed && v.speed > 0) {
1115
+ const next = v.points[i + 1];
1116
+ // Haversine approximate distance
1117
+ const dLat = ((next.latitude - pt.latitude) * Math.PI) / 180;
1118
+ const dLon = ((next.longitude - pt.longitude) * Math.PI) / 180;
1119
+ const a = Math.sin(dLat / 2) ** 2 +
1120
+ Math.cos((pt.latitude * Math.PI) / 180) *
1121
+ Math.cos((next.latitude * Math.PI) / 180) *
1122
+ Math.sin(dLon / 2) ** 2;
1123
+ const distM = 2 * EARTH_RADIUS * Math.asin(Math.sqrt(a));
1124
+ const delayMs = Math.round((distM / v.speed) * 1000);
1125
+ await (0, utils_js_1.sleep)(Math.min(delayMs, 10000)); // cap at 10s per step
1126
+ }
1127
+ }
1128
+ break;
1129
+ }
1130
+ case 'startRecording': {
1131
+ const outPath = typeof val === 'string'
1132
+ ? val
1133
+ : (val?.path ?? `recording-${Date.now()}.mp4`);
1134
+ await driver.startRecording(resolvePath(outPath, opts.cwd));
1135
+ break;
1136
+ }
1137
+ case 'stopRecording': {
1138
+ await driver.stopRecording();
1139
+ break;
1140
+ }
1141
+ // ── Screenshot ─────────────────────────────────────────────────────────
1142
+ case 'takeScreenshot': {
1143
+ const outPath = typeof val === 'string'
1144
+ ? val
1145
+ : (val?.path ?? `screenshot-${Date.now()}.png`);
1146
+ const buf = await driver.screenshot();
1147
+ await promises_1.default.writeFile(outPath, buf);
1148
+ break;
1149
+ }
1150
+ // ── Flow control ───────────────────────────────────────────────────────
1151
+ case 'runFlow': {
1152
+ const childDepth = opts.depth + 1;
1153
+ if (typeof val === 'string') {
1154
+ const resolved = resolvePath(val, opts.cwd);
1155
+ const sub = await parseFlowFile(resolved, opts.cliEnv);
1156
+ await executeFlow(sub, driver, {
1157
+ cwd: path_1.default.dirname(resolved),
1158
+ env: opts.cliEnv,
1159
+ output: opts.output,
1160
+ depth: childDepth,
1161
+ benchmark: opts.benchmark,
1162
+ });
1163
+ }
1164
+ else {
1165
+ const v = val;
1166
+ if (v.when && !(await evaluateWhen(v.when, driver, opts)))
1167
+ break;
1168
+ if (v.commands) {
1169
+ await executeCommands(v.commands, driver, { ...opts, depth: childDepth });
1170
+ }
1171
+ else if (v.file) {
1172
+ const resolved = resolvePath(v.file, opts.cwd);
1173
+ // Merge: CLI env < inline env block from runFlow (already resolveDeep'd)
1174
+ const childEnv = { ...opts.cliEnv, ...(v.env ?? {}) };
1175
+ const sub = await parseFlowFile(resolved, childEnv);
1176
+ await executeFlow(sub, driver, {
1177
+ cwd: path_1.default.dirname(resolved),
1178
+ env: childEnv,
1179
+ output: opts.output,
1180
+ depth: childDepth,
1181
+ benchmark: opts.benchmark,
1182
+ });
1183
+ }
1184
+ }
1185
+ break;
1186
+ }
1187
+ case 'waitForAnimationToEnd': {
1188
+ // Optional timeout (ms); falls back to waitForSettle's own 3s budget
1189
+ const wfaTimeout = val && typeof val === 'object' ? val.timeout : undefined;
1190
+ if (driver instanceof ios_js_1.IOSDriver) {
1191
+ await (0, wait_js_1.waitForIOSHierarchyToSettle)(() => driver.viewHierarchy().then((h) => h.axElement), wfaTimeout);
1192
+ }
1193
+ else {
1194
+ await (0, wait_js_1.waitForAndroidHierarchyToSettle)(() => driver.viewHierarchy(), wfaTimeout);
1195
+ }
1196
+ break;
1197
+ }
1198
+ case 'scrollUntilVisible': {
1199
+ const r = val;
1200
+ const timeoutMs = r.timeout ?? 30000;
1201
+ const direction = r.direction ?? 'DOWN';
1202
+ const deadline = Date.now() + timeoutMs;
1203
+ while (Date.now() < deadline) {
1204
+ const el = await findElementNoThrow(driver, r.element, 1000, appIds, opts);
1205
+ if (el !== null)
1206
+ return;
1207
+ await performSwipe(driver, direction);
1208
+ }
1209
+ throw new Error(`scrollUntilVisible: element not found after ${timeoutMs}ms`);
1210
+ }
1211
+ case 'repeat': {
1212
+ const r = val;
1213
+ const maxTimes = r.times ?? (r.while ? Infinity : 1);
1214
+ for (let i = 0; i < maxTimes; i++) {
1215
+ if (r.while) {
1216
+ const w = r.while;
1217
+ if (w.notVisible !== undefined) {
1218
+ const el = await findElementNoThrow(driver, w.notVisible, 1000, appIds, opts);
1219
+ if (el !== null)
1220
+ break; // element appeared → condition false → stop
1221
+ }
1222
+ else if (w.visible !== undefined) {
1223
+ const el = await findElementNoThrow(driver, w.visible, 1000, appIds, opts);
1224
+ if (el === null)
1225
+ break; // element gone → condition false → stop
1226
+ }
1227
+ else if (w.true !== undefined) {
1228
+ const expr = w.true;
1229
+ await (0, js_engine_js_1.executeScript)(`output.__repeatCond = !!(${expr});`, opts.env, opts.output, 'repeat.while', getConductorObj(driver, opts.output));
1230
+ const cond = opts.output['__repeatCond'];
1231
+ delete opts.output['__repeatCond'];
1232
+ if (!cond)
1233
+ break;
1234
+ }
1235
+ }
1236
+ await executeCommands(r.commands, driver, { ...opts, depth: opts.depth + 1 });
1237
+ }
1238
+ break;
1239
+ }
1240
+ case 'retry': {
1241
+ const r = val;
1242
+ let lastErr;
1243
+ for (let attempt = 0; attempt <= r.maxRetries; attempt++) {
1244
+ try {
1245
+ await executeCommands(r.commands, driver, { ...opts, depth: opts.depth + 1 });
1246
+ lastErr = undefined;
1247
+ break;
1248
+ }
1249
+ catch (err) {
1250
+ lastErr = err;
1251
+ }
1252
+ }
1253
+ if (lastErr !== undefined)
1254
+ throw lastErr;
1255
+ break;
1256
+ }
1257
+ // ── Scripting ──────────────────────────────────────────────────────────
1258
+ case 'runScript': {
1259
+ const { file, env: scriptEnv } = typeof val === 'string'
1260
+ ? { file: val, env: undefined }
1261
+ : val;
1262
+ const scriptPath = resolvePath(file, opts.cwd);
1263
+ const script = await promises_1.default.readFile(scriptPath, 'utf-8');
1264
+ // Script env: flow env merged with command-level env overrides
1265
+ const mergedEnv = { ...opts.env, ...(scriptEnv ?? {}) };
1266
+ await (0, js_engine_js_1.executeScript)(script, mergedEnv, opts.output, scriptPath, getConductorObj(driver, opts.output));
1267
+ break;
1268
+ }
1269
+ case 'evalScript': {
1270
+ // Accepts inline string or { script: "..." }
1271
+ const script = typeof val === 'string' ? val : val.script;
1272
+ await (0, js_engine_js_1.executeScript)(script, opts.env, opts.output, 'evalScript', getConductorObj(driver, opts.output));
1273
+ break;
1274
+ }
1275
+ default:
1276
+ throw new Error(`Unknown flow command: "${key}"`);
1277
+ }
1278
+ }
1279
+ // ── Utilities ─────────────────────────────────────────────────────────────────
1280
+ function resolvePath(filePath, cwd) {
1281
+ return path_1.default.isAbsolute(filePath) ? filePath : path_1.default.join(cwd ?? process.cwd(), filePath);
1282
+ }
1283
+ function resolveAppId(val, cmdName) {
1284
+ if (typeof val === 'string' && val)
1285
+ return val;
1286
+ if (val && typeof val === 'object') {
1287
+ const appId = val.appId;
1288
+ if (appId)
1289
+ return appId;
1290
+ }
1291
+ throw new Error(`${cmdName}: no appId specified`);
1292
+ }
1293
+ function mapIosKey(key) {
1294
+ switch (key) {
1295
+ case 'DELETE':
1296
+ case 'BACKSPACE':
1297
+ return 'delete';
1298
+ case 'RETURN':
1299
+ case 'ENTER':
1300
+ return 'return';
1301
+ case 'TAB':
1302
+ return 'tab';
1303
+ case 'SPACE':
1304
+ return 'space';
1305
+ default:
1306
+ // Best-effort cast; the driver will reject unknown keys at runtime
1307
+ return key.toLowerCase();
1308
+ }
1309
+ }