@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,328 @@
|
|
|
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.IOSDriver = void 0;
|
|
7
|
+
/**
|
|
8
|
+
* Direct HTTP client for the Conductor iOS XCTest driver.
|
|
9
|
+
* The driver runs inside the simulator at http://127.0.0.1:1075 (or custom port).
|
|
10
|
+
*
|
|
11
|
+
* Protocol: plain HTTP REST with JSON bodies.
|
|
12
|
+
* Endpoints map directly to XCUITest actions — no JVM required.
|
|
13
|
+
*/
|
|
14
|
+
const http_1 = __importDefault(require("http"));
|
|
15
|
+
const promises_1 = __importDefault(require("fs/promises"));
|
|
16
|
+
const os_1 = __importDefault(require("os"));
|
|
17
|
+
const path_1 = __importDefault(require("path"));
|
|
18
|
+
const child_process_1 = require("child_process");
|
|
19
|
+
class IOSDriver {
|
|
20
|
+
constructor(port = 1075, host = '127.0.0.1', deviceId) {
|
|
21
|
+
this.port = port;
|
|
22
|
+
this.host = host;
|
|
23
|
+
this.deviceId = deviceId;
|
|
24
|
+
this._recordingProcess = null;
|
|
25
|
+
}
|
|
26
|
+
request(method, path, body) {
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
const bodyBuf = body !== undefined ? Buffer.from(JSON.stringify(body), 'utf-8') : undefined;
|
|
29
|
+
const options = {
|
|
30
|
+
hostname: this.host,
|
|
31
|
+
port: this.port,
|
|
32
|
+
path,
|
|
33
|
+
method,
|
|
34
|
+
headers: {
|
|
35
|
+
...(bodyBuf
|
|
36
|
+
? { 'Content-Type': 'application/json', 'Content-Length': bodyBuf.length }
|
|
37
|
+
: {}),
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
const req = http_1.default.request(options, (res) => {
|
|
41
|
+
const chunks = [];
|
|
42
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
43
|
+
res.on('end', () => resolve({ status: res.statusCode ?? 0, data: Buffer.concat(chunks) }));
|
|
44
|
+
res.on('error', reject);
|
|
45
|
+
});
|
|
46
|
+
req.setTimeout(30000, () => {
|
|
47
|
+
req.destroy(new Error(`iOS driver request timed out: ${method} ${path}`));
|
|
48
|
+
});
|
|
49
|
+
req.on('error', reject);
|
|
50
|
+
if (bodyBuf)
|
|
51
|
+
req.write(bodyBuf);
|
|
52
|
+
req.end();
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
async post(path, body) {
|
|
56
|
+
const { status, data } = await this.request('POST', `/${path}`, body);
|
|
57
|
+
if (status < 200 || status >= 300) {
|
|
58
|
+
throw new Error(`iOS driver ${path} failed (HTTP ${status}): ${data.toString('utf-8').slice(0, 200)}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
async get(path) {
|
|
62
|
+
const { status, data } = await this.request('GET', `/${path}`);
|
|
63
|
+
if (status < 200 || status >= 300) {
|
|
64
|
+
throw new Error(`iOS driver GET ${path} failed (HTTP ${status}): ${data.toString('utf-8').slice(0, 200)}`);
|
|
65
|
+
}
|
|
66
|
+
return JSON.parse(data.toString('utf-8'));
|
|
67
|
+
}
|
|
68
|
+
requireDeviceId() {
|
|
69
|
+
if (!this.deviceId)
|
|
70
|
+
throw new Error('IOSDriver: deviceId is required for this operation');
|
|
71
|
+
return this.deviceId;
|
|
72
|
+
}
|
|
73
|
+
simctl(args) {
|
|
74
|
+
const _id = this.requireDeviceId();
|
|
75
|
+
return new Promise((resolve, reject) => {
|
|
76
|
+
const proc = (0, child_process_1.spawn)('xcrun', ['simctl', ...args], { stdio: 'ignore' });
|
|
77
|
+
proc.on('close', (code) => code === 0 ? resolve() : reject(new Error(`xcrun simctl ${args[0]} failed (exit ${code})`)));
|
|
78
|
+
proc.on('error', reject);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
simctlCapture(args) {
|
|
82
|
+
this.requireDeviceId();
|
|
83
|
+
return new Promise((resolve, reject) => {
|
|
84
|
+
const proc = (0, child_process_1.spawn)('xcrun', ['simctl', ...args], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
85
|
+
let out = '';
|
|
86
|
+
let err = '';
|
|
87
|
+
proc.stdout?.on('data', (chunk) => {
|
|
88
|
+
out += chunk.toString();
|
|
89
|
+
});
|
|
90
|
+
proc.stderr?.on('data', (chunk) => {
|
|
91
|
+
err += chunk.toString();
|
|
92
|
+
});
|
|
93
|
+
proc.on('close', (code) => code === 0
|
|
94
|
+
? resolve(out.trim())
|
|
95
|
+
: reject(new Error(`xcrun simctl ${args[0]} failed: ${err.trim()}`)));
|
|
96
|
+
proc.on('error', reject);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
async isAlive() {
|
|
100
|
+
try {
|
|
101
|
+
const { status } = await this.request('GET', '/status');
|
|
102
|
+
return status >= 200 && status < 300;
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
async deviceInfo() {
|
|
109
|
+
return this.get('deviceInfo');
|
|
110
|
+
}
|
|
111
|
+
async tap(x, y, duration) {
|
|
112
|
+
await this.post('touch', { x, y, ...(duration !== undefined ? { duration } : {}) });
|
|
113
|
+
}
|
|
114
|
+
async swipe(startX, startY, endX, endY, duration, appIds) {
|
|
115
|
+
await this.post('swipeV2', {
|
|
116
|
+
startX,
|
|
117
|
+
startY,
|
|
118
|
+
endX,
|
|
119
|
+
endY,
|
|
120
|
+
duration,
|
|
121
|
+
...(appIds ? { appIds } : {}),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
async inputText(text, appIds = []) {
|
|
125
|
+
await this.post('inputText', { text, appIds });
|
|
126
|
+
}
|
|
127
|
+
async pressKey(key) {
|
|
128
|
+
await this.post('pressKey', { key });
|
|
129
|
+
}
|
|
130
|
+
async pressButton(button) {
|
|
131
|
+
await this.post('pressButton', { button });
|
|
132
|
+
}
|
|
133
|
+
async launchApp(bundleId, args) {
|
|
134
|
+
if (args && Object.keys(args).length > 0) {
|
|
135
|
+
const deviceId = this.requireDeviceId();
|
|
136
|
+
// xctest /launchApp doesn't support launch args — use simctl
|
|
137
|
+
await this.simctl(['terminate', deviceId, bundleId]).catch(() => { });
|
|
138
|
+
const argPairs = [];
|
|
139
|
+
for (const [key, value] of Object.entries(args)) {
|
|
140
|
+
argPairs.push(`-${key}`, value);
|
|
141
|
+
}
|
|
142
|
+
await this.simctl(['launch', '--terminate-running-process', deviceId, bundleId, ...argPairs]);
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
await this.post('launchApp', { bundleId });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
async terminateApp(appId) {
|
|
149
|
+
await this.post('terminateApp', { appId });
|
|
150
|
+
}
|
|
151
|
+
async clearAppState(bundleId) {
|
|
152
|
+
const deviceId = this.requireDeviceId();
|
|
153
|
+
// Terminate first to prevent app from saving state after clear
|
|
154
|
+
await this.simctl(['terminate', deviceId, bundleId]).catch(() => { });
|
|
155
|
+
// Capture the .app bundle path before uninstalling — uninstall deletes the UUID directory
|
|
156
|
+
// so the path is invalid by the time we need to reinstall from it.
|
|
157
|
+
const appPath = await this.simctlCapture(['get_app_container', deviceId, bundleId, 'app']);
|
|
158
|
+
// Copy the .app bundle to a temp dir so it survives the uninstall
|
|
159
|
+
const tmpDir = await promises_1.default.mkdtemp(path_1.default.join(os_1.default.tmpdir(), 'conductor-clear-state-'));
|
|
160
|
+
const tmpAppPath = path_1.default.join(tmpDir, path_1.default.basename(appPath));
|
|
161
|
+
try {
|
|
162
|
+
await promises_1.default.cp(appPath, tmpAppPath, { recursive: true });
|
|
163
|
+
await this.simctl(['uninstall', deviceId, bundleId]);
|
|
164
|
+
await this.simctl(['install', deviceId, tmpAppPath]);
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
await promises_1.default.rm(tmpDir, { recursive: true, force: true }).catch(() => { });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
async clearKeychain() {
|
|
171
|
+
const deviceId = this.requireDeviceId();
|
|
172
|
+
await this.simctl(['keychain', deviceId, 'reset']);
|
|
173
|
+
}
|
|
174
|
+
async openLink(url) {
|
|
175
|
+
const deviceId = this.requireDeviceId();
|
|
176
|
+
await this.simctl(['openurl', deviceId, url]);
|
|
177
|
+
}
|
|
178
|
+
async setLocation(latitude, longitude) {
|
|
179
|
+
const deviceId = this.requireDeviceId();
|
|
180
|
+
await this.simctl(['location', deviceId, 'set', `${latitude},${longitude}`]);
|
|
181
|
+
}
|
|
182
|
+
async setOrientation(orientation) {
|
|
183
|
+
await this.post('setOrientation', { orientation });
|
|
184
|
+
}
|
|
185
|
+
async setPermissions(appId, permissions) {
|
|
186
|
+
// All iOS permissions the XCTest runner's interruption monitor can handle.
|
|
187
|
+
const IOS_ALL_PERMISSIONS = [
|
|
188
|
+
'notifications',
|
|
189
|
+
'camera',
|
|
190
|
+
'microphone',
|
|
191
|
+
'photos',
|
|
192
|
+
'location',
|
|
193
|
+
'contacts',
|
|
194
|
+
'calendar',
|
|
195
|
+
'reminders',
|
|
196
|
+
'bluetooth',
|
|
197
|
+
'health',
|
|
198
|
+
'motion',
|
|
199
|
+
'speech',
|
|
200
|
+
'tracking',
|
|
201
|
+
'faceId',
|
|
202
|
+
'homeKit',
|
|
203
|
+
'mediaLibrary',
|
|
204
|
+
'siri',
|
|
205
|
+
'localNetwork',
|
|
206
|
+
];
|
|
207
|
+
// Expand 'all' to every known permission
|
|
208
|
+
const expanded = { ...permissions };
|
|
209
|
+
const allValue = expanded['all'];
|
|
210
|
+
if (allValue !== undefined) {
|
|
211
|
+
delete expanded['all'];
|
|
212
|
+
for (const perm of IOS_ALL_PERMISSIONS) {
|
|
213
|
+
if (!(perm in expanded)) {
|
|
214
|
+
expanded[perm] = allValue;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
// ── simctl privacy ────────────────────────────────────────────────────────
|
|
219
|
+
// For simulators: use simctl privacy grant/revoke to pre-approve permissions
|
|
220
|
+
// in TCC so no dialog ever appears. This covers ALL permission types including
|
|
221
|
+
// ATT (tracking), local network, etc. that the XCTest runner can't intercept.
|
|
222
|
+
//
|
|
223
|
+
// simctl service names differ from Maestro key names for some entries.
|
|
224
|
+
const SIMCTL_SERVICE = {
|
|
225
|
+
notifications: 'notifications',
|
|
226
|
+
camera: 'camera',
|
|
227
|
+
microphone: 'microphone',
|
|
228
|
+
photos: 'photos',
|
|
229
|
+
location: 'location',
|
|
230
|
+
contacts: 'contacts',
|
|
231
|
+
calendar: 'calendar',
|
|
232
|
+
reminders: 'reminders',
|
|
233
|
+
bluetooth: 'bluetooth',
|
|
234
|
+
motion: 'motion',
|
|
235
|
+
speech: 'speech',
|
|
236
|
+
tracking: 'tracking',
|
|
237
|
+
faceId: 'faceid',
|
|
238
|
+
homeKit: 'homekit',
|
|
239
|
+
mediaLibrary: 'media-library',
|
|
240
|
+
siri: 'siri',
|
|
241
|
+
};
|
|
242
|
+
const deviceId = this.requireDeviceId();
|
|
243
|
+
if (allValue !== undefined) {
|
|
244
|
+
// Best-effort bulk grant/revoke. 'all' covers TCC-managed permissions but
|
|
245
|
+
// NOT ATT (tracking), which needs an explicit individual call below.
|
|
246
|
+
const action = allValue === 'allow' ? 'grant' : 'revoke';
|
|
247
|
+
try {
|
|
248
|
+
await this.simctl(['privacy', deviceId, action, 'all', appId]);
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
/* not installed yet */
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
// Always grant/revoke each permission individually — some services (e.g. tracking/ATT)
|
|
255
|
+
// are not included in 'grant all' and require an explicit call.
|
|
256
|
+
for (const [perm, value] of Object.entries(expanded)) {
|
|
257
|
+
const service = SIMCTL_SERVICE[perm];
|
|
258
|
+
if (!service)
|
|
259
|
+
continue;
|
|
260
|
+
const action = value === 'allow' ? 'grant' : 'revoke';
|
|
261
|
+
try {
|
|
262
|
+
await this.simctl(['privacy', deviceId, action, service, appId]);
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
/* ignore */
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
// Also notify the XCTest runner — its interruption monitor handles the
|
|
269
|
+
// notifications dialog and acts as a fallback for any dialog we missed.
|
|
270
|
+
await this.post('setPermissions', { permissions: expanded });
|
|
271
|
+
}
|
|
272
|
+
async addMedia(filePath) {
|
|
273
|
+
const deviceId = this.requireDeviceId();
|
|
274
|
+
await this.simctl(['addmedia', deviceId, filePath]);
|
|
275
|
+
}
|
|
276
|
+
async setAirplaneMode(_enabled) {
|
|
277
|
+
throw new Error('setAirplaneMode is not supported on iOS simulators');
|
|
278
|
+
}
|
|
279
|
+
async getAirplaneMode() {
|
|
280
|
+
throw new Error('getAirplaneMode is not supported on iOS simulators');
|
|
281
|
+
}
|
|
282
|
+
async startRecording(outputPath) {
|
|
283
|
+
const deviceId = this.requireDeviceId();
|
|
284
|
+
if (this._recordingProcess)
|
|
285
|
+
await this.stopRecording();
|
|
286
|
+
this._recordingProcess = (0, child_process_1.spawn)('xcrun', ['simctl', 'io', deviceId, 'recordVideo', '--codec', 'hevc', outputPath], { stdio: 'ignore' });
|
|
287
|
+
}
|
|
288
|
+
async stopRecording() {
|
|
289
|
+
if (this._recordingProcess) {
|
|
290
|
+
this._recordingProcess.kill('SIGINT');
|
|
291
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
292
|
+
this._recordingProcess = null;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
async viewHierarchy(excludeKeyboardElements = false, appIds = []) {
|
|
296
|
+
const { status, data } = await this.request('POST', '/viewHierarchy', {
|
|
297
|
+
appIds,
|
|
298
|
+
excludeKeyboardElements,
|
|
299
|
+
});
|
|
300
|
+
if (status < 200 || status >= 300) {
|
|
301
|
+
throw new Error(`iOS driver viewHierarchy failed (HTTP ${status}): ${data.toString('utf-8').slice(0, 200)}`);
|
|
302
|
+
}
|
|
303
|
+
return JSON.parse(data.toString('utf-8'));
|
|
304
|
+
}
|
|
305
|
+
async screenshot() {
|
|
306
|
+
const { status, data } = await this.request('GET', '/screenshot');
|
|
307
|
+
if (status < 200 || status >= 300) {
|
|
308
|
+
throw new Error(`iOS driver screenshot failed (HTTP ${status})`);
|
|
309
|
+
}
|
|
310
|
+
return data;
|
|
311
|
+
}
|
|
312
|
+
async isScreenStatic() {
|
|
313
|
+
const result = await this.get('isScreenStatic');
|
|
314
|
+
return result.isScreenStatic;
|
|
315
|
+
}
|
|
316
|
+
async runningApp(appIds) {
|
|
317
|
+
const { status, data } = await this.request('POST', '/runningApp', { appIds });
|
|
318
|
+
if (status < 200 || status >= 300) {
|
|
319
|
+
throw new Error(`iOS driver runningApp failed (HTTP ${status}): ${data.toString('utf-8').slice(0, 200)}`);
|
|
320
|
+
}
|
|
321
|
+
const parsed = JSON.parse(data.toString('utf-8'));
|
|
322
|
+
const id = parsed.runningAppBundleId;
|
|
323
|
+
if (!id)
|
|
324
|
+
throw new Error('Could not determine foreground app');
|
|
325
|
+
return id;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
exports.IOSDriver = IOSDriver;
|
|
@@ -0,0 +1,150 @@
|
|
|
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.executeScript = executeScript;
|
|
7
|
+
/**
|
|
8
|
+
* JavaScript execution engine for runScript commands.
|
|
9
|
+
*
|
|
10
|
+
* Mirrors Maestro's GraalJsEngine behaviour using Node's built-in vm module:
|
|
11
|
+
* - All flow env vars injected as globals
|
|
12
|
+
* - `output` object shared with the rest of the flow (mutations persist)
|
|
13
|
+
* - `http` client with get/post/put/delete/request
|
|
14
|
+
* - `json(text)` and `relativePoint(x, y)` helpers
|
|
15
|
+
* - Top-level `await` supported (script is wrapped in an async IIFE)
|
|
16
|
+
*/
|
|
17
|
+
const node_vm_1 = __importDefault(require("node:vm"));
|
|
18
|
+
const node_child_process_1 = require("node:child_process");
|
|
19
|
+
/**
|
|
20
|
+
* Synchronous HTTP request — matches Maestro's GraalVM behaviour where http.*
|
|
21
|
+
* calls block until the response arrives (no await needed in scripts).
|
|
22
|
+
*
|
|
23
|
+
* Implemented via spawnSync so the VM thread blocks while fetch runs in a
|
|
24
|
+
* child Node process, keeping script semantics identical to the JVM engine.
|
|
25
|
+
*/
|
|
26
|
+
function httpRequest(url, method, options = {}) {
|
|
27
|
+
let fullUrl = url;
|
|
28
|
+
if (options.params && Object.keys(options.params).length > 0) {
|
|
29
|
+
fullUrl += '?' + new URLSearchParams(options.params).toString();
|
|
30
|
+
}
|
|
31
|
+
const headers = { ...(options.headers ?? {}) };
|
|
32
|
+
const bodyStr = options.body !== undefined
|
|
33
|
+
? typeof options.body === 'string'
|
|
34
|
+
? options.body
|
|
35
|
+
: JSON.stringify(options.body)
|
|
36
|
+
: undefined;
|
|
37
|
+
if (bodyStr !== undefined && !headers['Content-Type']) {
|
|
38
|
+
headers['Content-Type'] = 'application/json';
|
|
39
|
+
}
|
|
40
|
+
const fetchOpts = { method };
|
|
41
|
+
if (Object.keys(headers).length > 0)
|
|
42
|
+
fetchOpts.headers = headers;
|
|
43
|
+
if (bodyStr !== undefined)
|
|
44
|
+
fetchOpts.body = bodyStr;
|
|
45
|
+
// Run fetch in a child process so this call is synchronous from the script's
|
|
46
|
+
// perspective (mirrors the blocking Java HTTP client in Maestro's GraalVM).
|
|
47
|
+
const childScript = `(async () => {
|
|
48
|
+
try {
|
|
49
|
+
const res = await fetch(${JSON.stringify(fullUrl)}, ${JSON.stringify(fetchOpts)});
|
|
50
|
+
const body = await res.text();
|
|
51
|
+
const headers = {};
|
|
52
|
+
res.headers.forEach((v, k) => { headers[k] = v; });
|
|
53
|
+
process.stdout.write(JSON.stringify({ ok: res.ok, status: res.status, body, headers }));
|
|
54
|
+
} catch (e) {
|
|
55
|
+
process.stderr.write(String(e));
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
})();`;
|
|
59
|
+
const result = (0, node_child_process_1.spawnSync)(process.execPath, ['-e', childScript], {
|
|
60
|
+
encoding: 'utf8',
|
|
61
|
+
timeout: 30000,
|
|
62
|
+
});
|
|
63
|
+
if (result.error)
|
|
64
|
+
throw result.error;
|
|
65
|
+
if (result.status !== 0)
|
|
66
|
+
throw new Error(result.stderr || 'HTTP request failed');
|
|
67
|
+
return JSON.parse(result.stdout);
|
|
68
|
+
}
|
|
69
|
+
const httpBinding = {
|
|
70
|
+
get: (url, opts) => httpRequest(url, 'GET', opts),
|
|
71
|
+
post: (url, opts) => httpRequest(url, 'POST', opts),
|
|
72
|
+
put: (url, opts) => httpRequest(url, 'PUT', opts),
|
|
73
|
+
delete: (url, opts) => httpRequest(url, 'DELETE', opts),
|
|
74
|
+
request: (url, opts) => httpRequest(url, opts?.method ?? 'GET', opts),
|
|
75
|
+
};
|
|
76
|
+
// ── Script execution ──────────────────────────────────────────────────────────
|
|
77
|
+
/**
|
|
78
|
+
* Execute a JavaScript script in an isolated VM context.
|
|
79
|
+
*
|
|
80
|
+
* @param script JS source code
|
|
81
|
+
* @param env Flow env vars injected as global variables (read-only by convention)
|
|
82
|
+
* @param output Shared output object — mutations persist back to the flow
|
|
83
|
+
* @param sourceName Path shown in stack traces
|
|
84
|
+
* @param maestroObj Optional maestro object injected as `maestro` global
|
|
85
|
+
*/
|
|
86
|
+
async function executeScript(script, env, output, sourceName = 'script', maestroObj) {
|
|
87
|
+
const sandbox = {
|
|
88
|
+
// Env vars as globals (mirrors Maestro GraalJsEngine behaviour)
|
|
89
|
+
...env,
|
|
90
|
+
// Output properties as globals so conditions can reference them directly
|
|
91
|
+
// (e.g. `auth` in a when.true condition refers to `output.auth`)
|
|
92
|
+
...output,
|
|
93
|
+
// Shared output — mutations are visible to subsequent commands
|
|
94
|
+
output,
|
|
95
|
+
// Maestro object (platform, copiedText, etc.)
|
|
96
|
+
maestro: maestroObj ?? {},
|
|
97
|
+
// API surface
|
|
98
|
+
http: httpBinding,
|
|
99
|
+
json: (text) => JSON.parse(text),
|
|
100
|
+
relativePoint: (x, y) => `${Math.ceil(x * 100)}%,${Math.ceil(y * 100)}%`,
|
|
101
|
+
console: {
|
|
102
|
+
log: (...args) => {
|
|
103
|
+
console.log(...args);
|
|
104
|
+
},
|
|
105
|
+
warn: (...args) => {
|
|
106
|
+
console.warn(...args);
|
|
107
|
+
},
|
|
108
|
+
error: (...args) => {
|
|
109
|
+
console.error(...args);
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
// Standard JS globals (not available in vm context by default)
|
|
113
|
+
Date,
|
|
114
|
+
Math,
|
|
115
|
+
JSON,
|
|
116
|
+
parseInt,
|
|
117
|
+
parseFloat,
|
|
118
|
+
isNaN,
|
|
119
|
+
isFinite,
|
|
120
|
+
Number,
|
|
121
|
+
String,
|
|
122
|
+
Boolean,
|
|
123
|
+
Array,
|
|
124
|
+
Object,
|
|
125
|
+
RegExp,
|
|
126
|
+
Error,
|
|
127
|
+
TypeError,
|
|
128
|
+
RangeError,
|
|
129
|
+
Map,
|
|
130
|
+
Set,
|
|
131
|
+
Symbol,
|
|
132
|
+
Uint8Array,
|
|
133
|
+
// Async plumbing
|
|
134
|
+
Promise,
|
|
135
|
+
setTimeout,
|
|
136
|
+
clearTimeout,
|
|
137
|
+
};
|
|
138
|
+
// Proxy prevents ReferenceError for undeclared variables (e.g. ${auth == 'sign-in'}
|
|
139
|
+
// when `auth` is an optional env param not passed by the caller). Matches Maestro's
|
|
140
|
+
// GraalJS behaviour where undeclared vars resolve to undefined instead of throwing.
|
|
141
|
+
const proxy = new Proxy(sandbox, {
|
|
142
|
+
has: () => true,
|
|
143
|
+
get: (target, key) => (key in target ? target[key] : undefined),
|
|
144
|
+
});
|
|
145
|
+
node_vm_1.default.createContext(proxy);
|
|
146
|
+
// Wrap in async IIFE so scripts can use top-level await
|
|
147
|
+
const wrapped = `(async () => {\n${script}\n})()`;
|
|
148
|
+
const promise = node_vm_1.default.runInContext(wrapped, proxy, { filename: sourceName });
|
|
149
|
+
await promise;
|
|
150
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.OPTIONAL_TIMEOUT_MS = void 0;
|
|
4
|
+
exports.waitForIOSElement = waitForIOSElement;
|
|
5
|
+
exports.waitForAndroidElement = waitForAndroidElement;
|
|
6
|
+
exports.waitUntilIOSElementGone = waitUntilIOSElementGone;
|
|
7
|
+
exports.waitUntilAndroidElementGone = waitUntilAndroidElementGone;
|
|
8
|
+
exports.waitForIOSScreenToSettle = waitForIOSScreenToSettle;
|
|
9
|
+
exports.waitForIOSTransitionToSettle = waitForIOSTransitionToSettle;
|
|
10
|
+
exports.waitForIOSHierarchyToSettle = waitForIOSHierarchyToSettle;
|
|
11
|
+
exports.waitForAndroidHierarchyToSettle = waitForAndroidHierarchyToSettle;
|
|
12
|
+
const element_resolver_js_1 = require("./element-resolver.js");
|
|
13
|
+
const utils_js_1 = require("../utils.js");
|
|
14
|
+
const DEFAULT_TIMEOUT_MS = 17000;
|
|
15
|
+
const DEFAULT_INTERVAL_MS = 500;
|
|
16
|
+
exports.OPTIONAL_TIMEOUT_MS = 7000;
|
|
17
|
+
async function waitForIOSElement(getHierarchy, selector, timeoutMs = DEFAULT_TIMEOUT_MS, intervalMs = DEFAULT_INTERVAL_MS) {
|
|
18
|
+
const deadline = Date.now() + timeoutMs;
|
|
19
|
+
while (Date.now() < deadline) {
|
|
20
|
+
try {
|
|
21
|
+
const root = await getHierarchy();
|
|
22
|
+
const el = (0, element_resolver_js_1.findIOSElement)(root, selector);
|
|
23
|
+
if (el)
|
|
24
|
+
return el;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// Hierarchy fetch failed; keep retrying
|
|
28
|
+
}
|
|
29
|
+
await (0, utils_js_1.sleep)(intervalMs);
|
|
30
|
+
}
|
|
31
|
+
const desc = selectorDesc(selector);
|
|
32
|
+
throw new Error(`Element not found after ${timeoutMs}ms: ${desc}`);
|
|
33
|
+
}
|
|
34
|
+
async function waitForAndroidElement(getHierarchy, selector, timeoutMs = DEFAULT_TIMEOUT_MS, intervalMs = DEFAULT_INTERVAL_MS) {
|
|
35
|
+
const deadline = Date.now() + timeoutMs;
|
|
36
|
+
while (Date.now() < deadline) {
|
|
37
|
+
try {
|
|
38
|
+
const xml = await getHierarchy();
|
|
39
|
+
const el = (0, element_resolver_js_1.findAndroidElement)(xml, selector);
|
|
40
|
+
if (el)
|
|
41
|
+
return el;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// Hierarchy fetch failed; keep retrying
|
|
45
|
+
}
|
|
46
|
+
await (0, utils_js_1.sleep)(intervalMs);
|
|
47
|
+
}
|
|
48
|
+
const desc = selectorDesc(selector);
|
|
49
|
+
throw new Error(`Element not found after ${timeoutMs}ms: ${desc}`);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Wait until an iOS element is gone from the hierarchy.
|
|
53
|
+
* Fast path: if element is absent on the first check, resolves immediately.
|
|
54
|
+
* Otherwise polls every 500 ms until absent or outer timeout (default 7 s) expires.
|
|
55
|
+
*/
|
|
56
|
+
async function waitUntilIOSElementGone(getHierarchy, selector, timeoutMs = exports.OPTIONAL_TIMEOUT_MS) {
|
|
57
|
+
const deadline = Date.now() + timeoutMs;
|
|
58
|
+
do {
|
|
59
|
+
try {
|
|
60
|
+
const root = await getHierarchy();
|
|
61
|
+
if (!(0, element_resolver_js_1.findIOSElement)(root, selector))
|
|
62
|
+
return; // fast path or gone
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return; // hierarchy fetch failed — treat as gone
|
|
66
|
+
}
|
|
67
|
+
await (0, utils_js_1.sleep)(DEFAULT_INTERVAL_MS);
|
|
68
|
+
} while (Date.now() < deadline);
|
|
69
|
+
const desc = selectorDesc(selector);
|
|
70
|
+
throw new Error(`Element still visible after ${timeoutMs}ms: ${desc}`);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Wait until an Android element is gone from the hierarchy.
|
|
74
|
+
* Fast path: if element is absent on the first check, resolves immediately.
|
|
75
|
+
* Otherwise polls every 500 ms until absent or outer timeout (default 7 s) expires.
|
|
76
|
+
*/
|
|
77
|
+
async function waitUntilAndroidElementGone(getHierarchy, selector, timeoutMs = exports.OPTIONAL_TIMEOUT_MS) {
|
|
78
|
+
const deadline = Date.now() + timeoutMs;
|
|
79
|
+
do {
|
|
80
|
+
try {
|
|
81
|
+
const xml = await getHierarchy();
|
|
82
|
+
if (!(0, element_resolver_js_1.findAndroidElement)(xml, selector))
|
|
83
|
+
return; // fast path or gone
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return; // hierarchy fetch failed — treat as gone
|
|
87
|
+
}
|
|
88
|
+
await (0, utils_js_1.sleep)(DEFAULT_INTERVAL_MS);
|
|
89
|
+
} while (Date.now() < deadline);
|
|
90
|
+
const desc = selectorDesc(selector);
|
|
91
|
+
throw new Error(`Element still visible after ${timeoutMs}ms: ${desc}`);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Wait until the iOS screen is visually static, mirroring Maestro's `waitUntilScreenIsStatic`.
|
|
95
|
+
* Calls the XCTest runner's /isScreenStatic endpoint, which takes two back-to-back screenshots
|
|
96
|
+
* and returns true when their SHA256 hashes match. Retries until stable or timeout.
|
|
97
|
+
* Times out silently — the next command's waitForElement will handle any remaining delay.
|
|
98
|
+
*/
|
|
99
|
+
async function waitForIOSScreenToSettle(isScreenStatic, timeoutMs = 3000) {
|
|
100
|
+
const deadline = Date.now() + timeoutMs;
|
|
101
|
+
do {
|
|
102
|
+
try {
|
|
103
|
+
if (await isScreenStatic())
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// request failed — keep retrying
|
|
108
|
+
}
|
|
109
|
+
} while (Date.now() < deadline);
|
|
110
|
+
// Timed out — proceed anyway
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Two-phase settle for any action that triggers a screen transition.
|
|
114
|
+
*
|
|
115
|
+
* A plain `waitForIOSScreenToSettle` can return false-positive immediately if
|
|
116
|
+
* called before the transition animation has started.
|
|
117
|
+
*
|
|
118
|
+
* Phase 1 — wait for transition to START (screen becomes non-static), up to `changeTimeoutMs`.
|
|
119
|
+
* If the screen never moves, the action completed without animation; proceed immediately.
|
|
120
|
+
* Phase 2 — wait for transition to FINISH (screen becomes static again), up to `settleTimeoutMs`.
|
|
121
|
+
*/
|
|
122
|
+
async function waitForIOSTransitionToSettle(isScreenStatic, changeTimeoutMs = 1500, settleTimeoutMs = 3000) {
|
|
123
|
+
// Phase 1: wait until screen starts changing
|
|
124
|
+
const changeDeadline = Date.now() + changeTimeoutMs;
|
|
125
|
+
let navigationStarted = false;
|
|
126
|
+
do {
|
|
127
|
+
try {
|
|
128
|
+
if (!(await isScreenStatic())) {
|
|
129
|
+
navigationStarted = true;
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
// request failed — keep retrying
|
|
135
|
+
}
|
|
136
|
+
} while (Date.now() < changeDeadline);
|
|
137
|
+
if (!navigationStarted)
|
|
138
|
+
return; // link opened without visible navigation animation
|
|
139
|
+
// Phase 2: wait until screen stops changing
|
|
140
|
+
await waitForIOSScreenToSettle(isScreenStatic, settleTimeoutMs);
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Wait until the iOS view hierarchy stops changing between consecutive polls.
|
|
144
|
+
* Mirrors Maestro's `waitForAppToSettle` / `waitUntilScreenIsStatic` logic.
|
|
145
|
+
* Times out silently so the next command (which retries on its own) can proceed.
|
|
146
|
+
*/
|
|
147
|
+
async function waitForIOSHierarchyToSettle(getHierarchy, timeoutMs = 3000, intervalMs = 200) {
|
|
148
|
+
const deadline = Date.now() + timeoutMs;
|
|
149
|
+
let prev = null;
|
|
150
|
+
while (Date.now() < deadline) {
|
|
151
|
+
try {
|
|
152
|
+
const root = await getHierarchy();
|
|
153
|
+
const curr = JSON.stringify(root);
|
|
154
|
+
if (curr === prev)
|
|
155
|
+
return; // stable
|
|
156
|
+
prev = curr;
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
// hierarchy fetch failed — keep waiting
|
|
160
|
+
}
|
|
161
|
+
await (0, utils_js_1.sleep)(intervalMs);
|
|
162
|
+
}
|
|
163
|
+
// Timed out — proceed anyway; the next assertVisible will retry
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Wait until the Android view hierarchy XML stops changing between consecutive polls.
|
|
167
|
+
*/
|
|
168
|
+
async function waitForAndroidHierarchyToSettle(getHierarchy, timeoutMs = 3000, intervalMs = 200) {
|
|
169
|
+
const deadline = Date.now() + timeoutMs;
|
|
170
|
+
let prev = null;
|
|
171
|
+
while (Date.now() < deadline) {
|
|
172
|
+
try {
|
|
173
|
+
const xml = await getHierarchy();
|
|
174
|
+
if (xml === prev)
|
|
175
|
+
return; // stable
|
|
176
|
+
prev = xml;
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
// ignore
|
|
180
|
+
}
|
|
181
|
+
await (0, utils_js_1.sleep)(intervalMs);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
function selectorDesc(sel) {
|
|
185
|
+
const parts = [];
|
|
186
|
+
if (sel.query)
|
|
187
|
+
parts.push(`query="${sel.query}"`);
|
|
188
|
+
if (sel.text)
|
|
189
|
+
parts.push(`text="${sel.text}"`);
|
|
190
|
+
if (sel.id)
|
|
191
|
+
parts.push(`id="${sel.id}"`);
|
|
192
|
+
if (sel.index !== undefined)
|
|
193
|
+
parts.push(`index=${sel.index}`);
|
|
194
|
+
if (sel.enabled !== undefined)
|
|
195
|
+
parts.push(`enabled=${sel.enabled}`);
|
|
196
|
+
if (sel.checked !== undefined)
|
|
197
|
+
parts.push(`checked=${sel.checked}`);
|
|
198
|
+
if (sel.focused !== undefined)
|
|
199
|
+
parts.push(`focused=${sel.focused}`);
|
|
200
|
+
if (sel.selected !== undefined)
|
|
201
|
+
parts.push(`selected=${sel.selected}`);
|
|
202
|
+
if (sel.below)
|
|
203
|
+
parts.push(`below(${selectorDesc(sel.below)})`);
|
|
204
|
+
if (sel.above)
|
|
205
|
+
parts.push(`above(${selectorDesc(sel.above)})`);
|
|
206
|
+
if (sel.leftOf)
|
|
207
|
+
parts.push(`leftOf(${selectorDesc(sel.leftOf)})`);
|
|
208
|
+
if (sel.rightOf)
|
|
209
|
+
parts.push(`rightOf(${selectorDesc(sel.rightOf)})`);
|
|
210
|
+
return parts.join(', ') || '(no selector)';
|
|
211
|
+
}
|