@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.
- package/.claude-plugin/plugin.json +6 -0
- package/README.md +39 -0
- package/dist/commands/assert-not-visible.js +47 -0
- package/dist/commands/assert-visible.js +58 -0
- package/dist/commands/back.js +25 -0
- package/dist/commands/cheat-sheet.js +100 -0
- package/dist/commands/daemon.js +61 -0
- package/dist/commands/device-pool.js +202 -0
- package/dist/commands/erase-text.js +26 -0
- package/dist/commands/foreground-app.js +50 -0
- package/dist/commands/hide-keyboard.js +27 -0
- package/dist/commands/inspect.js +37 -0
- package/dist/commands/install.js +64 -0
- package/dist/commands/launch-app.js +42 -0
- package/dist/commands/list-apps.js +60 -0
- package/dist/commands/list-devices.js +61 -0
- package/dist/commands/open-link.js +22 -0
- package/dist/commands/press-key.js +91 -0
- package/dist/commands/run-flow-inline.js +25 -0
- package/dist/commands/run-flow.js +29 -0
- package/dist/commands/run-parallel.js +143 -0
- package/dist/commands/screenshot.js +29 -0
- package/dist/commands/scroll-until-visible.js +69 -0
- package/dist/commands/scroll.js +36 -0
- package/dist/commands/session.js +49 -0
- package/dist/commands/set-location.js +18 -0
- package/dist/commands/set-orientation.js +23 -0
- package/dist/commands/start-device.js +178 -0
- package/dist/commands/stop-app.js +32 -0
- package/dist/commands/swipe.js +72 -0
- package/dist/commands/tap.js +69 -0
- package/dist/commands/type.js +22 -0
- package/dist/daemon/client.js +112 -0
- package/dist/daemon/protocol.js +25 -0
- package/dist/daemon/server.js +208 -0
- package/dist/drivers/android.js +343 -0
- package/dist/drivers/bootstrap.js +371 -0
- package/dist/drivers/element-resolver.js +371 -0
- package/dist/drivers/flow-runner.js +1309 -0
- package/dist/drivers/ios.js +328 -0
- package/dist/drivers/js-engine.js +150 -0
- package/dist/drivers/wait.js +211 -0
- package/dist/index.js +426 -0
- package/dist/output.js +36 -0
- package/dist/pkg-root.js +28 -0
- package/dist/postinstall.js +12 -0
- package/dist/runner.js +190 -0
- package/dist/session.js +66 -0
- package/dist/update-check.js +109 -0
- package/dist/utils.js +19 -0
- package/dist/verbose.js +17 -0
- package/drivers/android/conductor-app.apk +0 -0
- package/drivers/android/conductor-server.apk +0 -0
- package/drivers/ios/conductor-driver-ios-config.xctestrun +126 -0
- package/drivers/ios/conductor-driver-ios.zip +0 -0
- package/drivers/ios/conductor-driver-iosUITests-Runner.zip +0 -0
- package/package.json +52 -0
- package/proto/conductor_android.proto +116 -0
- package/skills/conductor/SKILL.md +677 -0
- 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
|
+
}
|