@houwert/conductor 0.15.0 → 0.17.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.
@@ -16,6 +16,13 @@ const promises_1 = __importDefault(require("fs/promises"));
16
16
  const os_1 = __importDefault(require("os"));
17
17
  const path_1 = __importDefault(require("path"));
18
18
  const child_process_1 = require("child_process");
19
+ /**
20
+ * How long a captured view hierarchy may be reused. Bounds the staleness of a
21
+ * cached snapshot when the screen changes without a driver-issued command
22
+ * (timer-driven UI, async animation). Mirrors the equivalent cache TTL in the
23
+ * agent-device runner.
24
+ */
25
+ const HIERARCHY_CACHE_TTL_MS = 750;
19
26
  class IOSDriver {
20
27
  constructor(port = 1075, host = '127.0.0.1', deviceId, platform = 'ios') {
21
28
  this.port = port;
@@ -23,6 +30,13 @@ class IOSDriver {
23
30
  this.deviceId = deviceId;
24
31
  this.platform = platform;
25
32
  this._recordingProcess = null;
33
+ /**
34
+ * Short-lived cache of the most recent view hierarchy, keyed by request
35
+ * params. Only served when the caller explicitly opts in (e.g. the first
36
+ * probe of a wait loop) and dropped by any UI-mutating command, so polling
37
+ * loops and post-action reads always see a fresh tree.
38
+ */
39
+ this.hierarchyCache = null;
26
40
  }
27
41
  request(method, path, body) {
28
42
  return new Promise((resolve, reject) => {
@@ -71,6 +85,10 @@ class IOSDriver {
71
85
  throw new Error('IOSDriver: deviceId is required for this operation');
72
86
  return this.deviceId;
73
87
  }
88
+ /** Drop the cached view hierarchy — call after any command that mutates the UI. */
89
+ invalidateHierarchyCache() {
90
+ this.hierarchyCache = null;
91
+ }
74
92
  simctl(args) {
75
93
  const _id = this.requireDeviceId();
76
94
  return new Promise((resolve, reject) => {
@@ -111,9 +129,10 @@ class IOSDriver {
111
129
  }
112
130
  async tap(x, y, duration) {
113
131
  await this.post('touch', { x, y, ...(duration !== undefined ? { duration } : {}) });
132
+ this.invalidateHierarchyCache();
114
133
  }
115
134
  async swipe(startX, startY, endX, endY, duration, appIds) {
116
- await this.post('swipeV2', {
135
+ await this.post('swipe', {
117
136
  startX,
118
137
  startY,
119
138
  endX,
@@ -121,15 +140,29 @@ class IOSDriver {
121
140
  duration,
122
141
  ...(appIds ? { appIds } : {}),
123
142
  });
143
+ this.invalidateHierarchyCache();
144
+ }
145
+ /**
146
+ * Multi-finger gesture playback. `paths` is one entry per finger; each entry's
147
+ * `steps` are the (x, y, dt) frames making up that finger's path. The driver
148
+ * synthesizes a multi-finger XCSynthesizedEventRecord when paths.length > 1.
149
+ * `dt` is the delay in seconds since the previous step (or the initial offset
150
+ * for the first step).
151
+ */
152
+ async gesturePath(paths) {
153
+ await this.post('gesturePath', { paths });
124
154
  }
125
155
  async inputText(text, appIds = []) {
126
156
  await this.post('inputText', { text, appIds });
157
+ this.invalidateHierarchyCache();
127
158
  }
128
159
  async pressKey(key) {
129
160
  await this.post('pressKey', { key });
161
+ this.invalidateHierarchyCache();
130
162
  }
131
163
  async pressButton(button) {
132
164
  await this.post('pressButton', { button });
165
+ this.invalidateHierarchyCache();
133
166
  }
134
167
  async launchApp(bundleId, args) {
135
168
  if (args && Object.keys(args).length > 0) {
@@ -145,9 +178,11 @@ class IOSDriver {
145
178
  else {
146
179
  await this.post('launchApp', { bundleId });
147
180
  }
181
+ this.invalidateHierarchyCache();
148
182
  }
149
183
  async terminateApp(appId) {
150
184
  await this.post('terminateApp', { appId });
185
+ this.invalidateHierarchyCache();
151
186
  }
152
187
  async clearAppState(bundleId) {
153
188
  const deviceId = this.requireDeviceId();
@@ -167,11 +202,13 @@ class IOSDriver {
167
202
  finally {
168
203
  await promises_1.default.rm(tmpDir, { recursive: true, force: true }).catch(() => { });
169
204
  }
205
+ this.invalidateHierarchyCache();
170
206
  }
171
207
  async uninstallApp(bundleId) {
172
208
  const deviceId = this.requireDeviceId();
173
209
  await this.simctl(['terminate', deviceId, bundleId]).catch(() => { });
174
210
  await this.simctl(['uninstall', deviceId, bundleId]);
211
+ this.invalidateHierarchyCache();
175
212
  }
176
213
  async clearKeychain() {
177
214
  const deviceId = this.requireDeviceId();
@@ -180,6 +217,28 @@ class IOSDriver {
180
217
  async openLink(url) {
181
218
  const deviceId = this.requireDeviceId();
182
219
  await this.simctl(['openurl', deviceId, url]);
220
+ this.invalidateHierarchyCache();
221
+ }
222
+ /** Read the simulator's clipboard. Uses `xcrun simctl pbpaste <udid>`. */
223
+ async clipboardRead() {
224
+ const deviceId = this.requireDeviceId();
225
+ return this.simctlCapture(['pbpaste', deviceId]);
226
+ }
227
+ /** Write to the simulator's clipboard. Uses `xcrun simctl pbcopy <udid>` over stdin. */
228
+ async clipboardWrite(text) {
229
+ const deviceId = this.requireDeviceId();
230
+ await new Promise((resolve, reject) => {
231
+ const proc = (0, child_process_1.spawn)('xcrun', ['simctl', 'pbcopy', deviceId], {
232
+ stdio: ['pipe', 'ignore', 'pipe'],
233
+ });
234
+ let stderr = '';
235
+ proc.stderr?.on('data', (c) => {
236
+ stderr += c.toString();
237
+ });
238
+ proc.on('close', (code) => code === 0 ? resolve() : reject(new Error(`xcrun simctl pbcopy failed: ${stderr.trim()}`)));
239
+ proc.on('error', reject);
240
+ proc.stdin?.end(text);
241
+ });
183
242
  }
184
243
  async setLocation(latitude, longitude) {
185
244
  const deviceId = this.requireDeviceId();
@@ -187,6 +246,7 @@ class IOSDriver {
187
246
  }
188
247
  async setOrientation(orientation) {
189
248
  await this.post('setOrientation', { orientation });
249
+ this.invalidateHierarchyCache();
190
250
  }
191
251
  async setPermissions(appId, permissions) {
192
252
  // All iOS permissions the XCTest runner's interruption monitor can handle.
@@ -298,7 +358,14 @@ class IOSDriver {
298
358
  this._recordingProcess = null;
299
359
  }
300
360
  }
301
- async viewHierarchy(excludeKeyboardElements = false, appIds = []) {
361
+ async viewHierarchy(excludeKeyboardElements = false, appIds = [], opts = {}) {
362
+ const key = `${excludeKeyboardElements}:${appIds.join(',')}`;
363
+ if (opts.cache &&
364
+ this.hierarchyCache &&
365
+ this.hierarchyCache.key === key &&
366
+ Date.now() - this.hierarchyCache.at < HIERARCHY_CACHE_TTL_MS) {
367
+ return this.hierarchyCache.value;
368
+ }
302
369
  const { status, data } = await this.request('POST', '/viewHierarchy', {
303
370
  appIds,
304
371
  excludeKeyboardElements,
@@ -306,9 +373,27 @@ class IOSDriver {
306
373
  if (status < 200 || status >= 300) {
307
374
  throw new Error(`iOS driver viewHierarchy failed (HTTP ${status}): ${data.toString('utf-8').slice(0, 200)}`);
308
375
  }
376
+ const value = JSON.parse(data.toString('utf-8'));
377
+ this.hierarchyCache = { key, value, at: Date.now() };
378
+ return value;
379
+ }
380
+ /**
381
+ * Resolve a single element directly via the runner instead of dumping and
382
+ * matching the whole view hierarchy. Returns `matchCount` so callers can
383
+ * fall back to the snapshot path when the result is ambiguous (>1) or empty.
384
+ */
385
+ async queryElement(selectorKey, selectorValue, appIds = []) {
386
+ const { status, data } = await this.request('POST', '/queryElement', {
387
+ selectorKey,
388
+ selectorValue,
389
+ appIds,
390
+ });
391
+ if (status < 200 || status >= 300) {
392
+ throw new Error(`iOS driver queryElement failed (HTTP ${status}): ${data.toString('utf-8').slice(0, 200)}`);
393
+ }
309
394
  return JSON.parse(data.toString('utf-8'));
310
395
  }
311
- async screenshot() {
396
+ async screenshot(_opts = {}) {
312
397
  const { status, data } = await this.request('GET', '/screenshot');
313
398
  if (status < 200 || status >= 300) {
314
399
  throw new Error(`iOS driver screenshot failed (HTTP ${status})`);
@@ -0,0 +1,291 @@
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.MetroCdpClient = void 0;
7
+ exports.resolveDebuggerUrl = resolveDebuggerUrl;
8
+ exports.cdpCall = cdpCall;
9
+ /**
10
+ * One-shot CDP client to Metro's debugger endpoint.
11
+ *
12
+ * Sibling to `MetroLogSource` in log-sources/metro.ts: that class stays connected
13
+ * to stream `Runtime.consoleAPICalled`; this one opens a short-lived socket for
14
+ * request/response calls (`Page.reload`, `Runtime.evaluate`, etc.).
15
+ *
16
+ * Reuses `fetchTargets()` / target selection from `log-sources/metro.ts` and
17
+ * `metro-discovery.ts` — do not duplicate discovery logic here.
18
+ */
19
+ const ws_1 = __importDefault(require("ws"));
20
+ const metro_js_1 = require("./log-sources/metro.js");
21
+ const metro_discovery_js_1 = require("./log-sources/metro-discovery.js");
22
+ /**
23
+ * Resolve a Metro target's `webSocketDebuggerUrl` honoring deviceId / targetIndex.
24
+ * Throws with a clear message if Metro is unreachable or no target matches.
25
+ */
26
+ async function resolveDebuggerUrl(opts) {
27
+ const port = opts.port ?? 8081;
28
+ const host = opts.host ?? 'localhost';
29
+ const targets = await (0, metro_js_1.fetchTargets)(port, host);
30
+ const withWs = targets.filter((t) => t.webSocketDebuggerUrl);
31
+ if (withWs.length === 0) {
32
+ throw new Error(`Metro on ${host}:${port} returned no debugger targets. Is an app running on a device/simulator?`);
33
+ }
34
+ if (opts.targetIndex !== undefined) {
35
+ if (opts.targetIndex < 0 || opts.targetIndex >= withWs.length) {
36
+ throw new Error(`--target ${opts.targetIndex} is out of range (have ${withWs.length}).`);
37
+ }
38
+ return withWs[opts.targetIndex].webSocketDebuggerUrl;
39
+ }
40
+ if (opts.deviceId && opts.platform) {
41
+ const displayName = await (0, metro_discovery_js_1.getDeviceDisplayName)(opts.platform, opts.deviceId);
42
+ if (displayName) {
43
+ const target = (0, metro_discovery_js_1.selectTargetForDevice)(withWs, displayName);
44
+ if (target)
45
+ return target.webSocketDebuggerUrl;
46
+ }
47
+ }
48
+ // Prefer the Hermes/React target by title, otherwise first.
49
+ const target = withWs.find((t) => t.title && /hermes|react/i.test(t.title)) ?? withWs[0];
50
+ return target.webSocketDebuggerUrl;
51
+ }
52
+ /**
53
+ * Open a short-lived CDP socket, send a single method, return the result.
54
+ * Closes the socket whether the call succeeds or throws.
55
+ */
56
+ async function cdpCall(method, params, opts) {
57
+ const wsUrl = await resolveDebuggerUrl(opts);
58
+ return cdpCallOnUrl(wsUrl, method, params, opts.timeoutMs ?? 10000);
59
+ }
60
+ async function cdpCallOnUrl(wsUrl, method, params, timeoutMs) {
61
+ return new Promise((resolve, reject) => {
62
+ const ws = new ws_1.default(wsUrl);
63
+ let settled = false;
64
+ const timer = setTimeout(() => {
65
+ if (settled)
66
+ return;
67
+ settled = true;
68
+ ws.terminate();
69
+ reject(new Error(`CDP ${method} timed out after ${timeoutMs}ms`));
70
+ }, timeoutMs);
71
+ function finish(err, value) {
72
+ if (settled)
73
+ return;
74
+ settled = true;
75
+ clearTimeout(timer);
76
+ ws.removeAllListeners();
77
+ try {
78
+ ws.close();
79
+ }
80
+ catch {
81
+ // ignore
82
+ }
83
+ if (err)
84
+ reject(err);
85
+ else
86
+ resolve(value);
87
+ }
88
+ const req = { id: 1, method };
89
+ if (params)
90
+ req.params = params;
91
+ ws.on('open', () => {
92
+ ws.send(JSON.stringify(req));
93
+ });
94
+ ws.on('message', (data) => {
95
+ try {
96
+ const msg = JSON.parse(data.toString());
97
+ if (msg.id !== req.id)
98
+ return;
99
+ if (msg.error) {
100
+ finish(new Error(`CDP ${method}: ${msg.error.message}`));
101
+ }
102
+ else {
103
+ finish(null, msg.result);
104
+ }
105
+ }
106
+ catch (err) {
107
+ finish(err instanceof Error ? err : new Error(String(err)));
108
+ }
109
+ });
110
+ ws.on('error', (err) => finish(err));
111
+ ws.on('close', () => {
112
+ if (!settled)
113
+ finish(new Error(`CDP socket closed before ${method} completed`));
114
+ });
115
+ });
116
+ }
117
+ /**
118
+ * Stateful CDP client. Open once, issue many calls. Used by the debugger
119
+ * commands that need session-scoped state (loaded scripts, enabled domains).
120
+ */
121
+ class MetroCdpClient {
122
+ constructor() {
123
+ this.ws = null;
124
+ this.nextId = 1;
125
+ this.pending = new Map();
126
+ this.events = new Map();
127
+ this.enabledDomains = new Set();
128
+ this.loadedScripts = new Map();
129
+ this.bindings = new Set();
130
+ this.callbackPending = new Map();
131
+ }
132
+ async connect(opts) {
133
+ const wsUrl = await resolveDebuggerUrl(opts);
134
+ await new Promise((resolve, reject) => {
135
+ const ws = new ws_1.default(wsUrl);
136
+ ws.on('open', () => {
137
+ this.ws = ws;
138
+ resolve();
139
+ });
140
+ ws.on('message', (data) => this.handleMessage(data.toString()));
141
+ ws.on('error', (err) => {
142
+ if (!this.ws)
143
+ reject(err);
144
+ });
145
+ ws.on('close', () => {
146
+ this.ws = null;
147
+ });
148
+ });
149
+ }
150
+ handleMessage(raw) {
151
+ try {
152
+ const msg = JSON.parse(raw);
153
+ if (typeof msg.id === 'number') {
154
+ const slot = this.pending.get(msg.id);
155
+ if (slot) {
156
+ this.pending.delete(msg.id);
157
+ if (msg.error)
158
+ slot.reject(new Error(msg.error.message));
159
+ else
160
+ slot.resolve(msg.result);
161
+ }
162
+ return;
163
+ }
164
+ if (msg.method) {
165
+ if (msg.method === 'Debugger.scriptParsed') {
166
+ const p = msg.params;
167
+ if (p?.scriptId)
168
+ this.loadedScripts.set(p.scriptId, { url: p.url });
169
+ }
170
+ const handlers = this.events.get(msg.method);
171
+ if (handlers)
172
+ for (const h of handlers)
173
+ h(msg.params);
174
+ }
175
+ }
176
+ catch {
177
+ // ignore parse errors
178
+ }
179
+ }
180
+ on(method, handler) {
181
+ const arr = this.events.get(method) ?? [];
182
+ arr.push(handler);
183
+ this.events.set(method, arr);
184
+ }
185
+ async send(method, params) {
186
+ if (!this.ws)
187
+ throw new Error('CDP client not connected');
188
+ const id = this.nextId++;
189
+ return new Promise((resolve, reject) => {
190
+ this.pending.set(id, {
191
+ resolve: (v) => resolve(v),
192
+ reject,
193
+ });
194
+ this.ws.send(JSON.stringify({ id, method, params }));
195
+ });
196
+ }
197
+ async enableDomain(domain) {
198
+ if (this.enabledDomains.has(domain))
199
+ return;
200
+ await this.send(`${domain}.enable`);
201
+ this.enabledDomains.add(domain);
202
+ }
203
+ /**
204
+ * Install a `Runtime.addBinding` callback. The injected JS calls
205
+ * `globalThis.__conductor_callback(JSON.stringify({ requestId, ... }))` and
206
+ * this method routes the payload back to the awaiter keyed on `requestId`.
207
+ *
208
+ * Returns a function that, given a requestId, returns a promise resolving
209
+ * to the next payload tagged with that requestId. Useful for async fiber
210
+ * walkers that can't return synchronously from `Runtime.evaluate`.
211
+ */
212
+ async installCallbackBinding(bindingName = '__conductor_callback') {
213
+ await this.enableDomain('Runtime');
214
+ if (!this.bindings.has(bindingName)) {
215
+ await this.send('Runtime.addBinding', { name: bindingName });
216
+ this.bindings.add(bindingName);
217
+ this.on('Runtime.bindingCalled', (params) => {
218
+ const p = params;
219
+ if (p.name !== bindingName)
220
+ return;
221
+ try {
222
+ const parsed = JSON.parse(p.payload);
223
+ if (parsed.requestId && this.callbackPending.has(parsed.requestId)) {
224
+ const slot = this.callbackPending.get(parsed.requestId);
225
+ this.callbackPending.delete(parsed.requestId);
226
+ slot.resolve(parsed);
227
+ }
228
+ }
229
+ catch {
230
+ // ignore malformed payloads
231
+ }
232
+ });
233
+ }
234
+ return (requestId, timeoutMs = 5000) => new Promise((resolve, reject) => {
235
+ const timer = setTimeout(() => {
236
+ this.callbackPending.delete(requestId);
237
+ reject(new Error(`callback ${requestId} timed out after ${timeoutMs}ms`));
238
+ }, timeoutMs);
239
+ this.callbackPending.set(requestId, {
240
+ resolve: (v) => {
241
+ clearTimeout(timer);
242
+ resolve(v);
243
+ },
244
+ reject,
245
+ });
246
+ });
247
+ }
248
+ /**
249
+ * Evaluate a JS expression in the app's runtime. Returns the value or throws
250
+ * a thrown JS exception's description. Awaits promises by default.
251
+ */
252
+ async evaluate(expression, returnByValue = true) {
253
+ await this.enableDomain('Runtime');
254
+ const result = await this.send('Runtime.evaluate', {
255
+ expression,
256
+ returnByValue,
257
+ awaitPromise: true,
258
+ generatePreview: false,
259
+ });
260
+ if (result.exceptionDetails) {
261
+ const msg = result.exceptionDetails.exception?.description ??
262
+ result.exceptionDetails.text ??
263
+ 'evaluation threw';
264
+ throw new Error(msg);
265
+ }
266
+ return (result.result.value ?? result.result.description);
267
+ }
268
+ getEnabledDomains() {
269
+ return new Set(this.enabledDomains);
270
+ }
271
+ getLoadedScripts() {
272
+ return this.loadedScripts;
273
+ }
274
+ isConnected() {
275
+ return this.ws !== null;
276
+ }
277
+ close() {
278
+ if (this.ws) {
279
+ try {
280
+ this.ws.close();
281
+ }
282
+ catch {
283
+ // ignore
284
+ }
285
+ this.ws = null;
286
+ }
287
+ this.pending.clear();
288
+ this.events.clear();
289
+ }
290
+ }
291
+ exports.MetroCdpClient = MetroCdpClient;