@houwert/conductor 0.24.0 → 0.25.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.
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.parseAriaSnapshot = parseAriaSnapshot;
7
+ exports.mergeCanvasNodes = mergeCanvasNodes;
7
8
  exports.startWebServer = startWebServer;
8
9
  exports.stopWebServer = stopWebServer;
9
10
  exports.getCdpPort = getCdpPort;
@@ -225,11 +226,22 @@ async function resolveBoundingBoxes(page, elements) {
225
226
  const el = queue.shift();
226
227
  if (el.ref) {
227
228
  try {
228
- const locator = page.locator(`aria-ref=${el.ref}`);
229
- const box = await locator.boundingBox({ timeout: 750 });
230
- if (box && box.width > 0 && box.height > 0) {
231
- el.bounds = { x: box.x, y: box.y, width: box.width, height: box.height };
229
+ // One round-trip per ref for bounds + the element's own `data-testid`. Reading identity
230
+ // from the element itself (not by geometric overlap) is what lets canvas focus later be
231
+ // joined to the right node by identity see mergeCanvasNodes.
232
+ const info = (await page
233
+ .locator(`aria-ref=${el.ref}`)
234
+ .first()
235
+ .evaluate(`(node) => {
236
+ const r = node.getBoundingClientRect();
237
+ return { x: r.x, y: r.y, width: r.width, height: r.height,
238
+ testId: node.getAttribute('data-testid') || '' };
239
+ }`, undefined, { timeout: 750 }));
240
+ if (info.width > 0 && info.height > 0) {
241
+ el.bounds = { x: info.x, y: info.y, width: info.width, height: info.height };
232
242
  }
243
+ if (info.testId)
244
+ el.testId = info.testId;
233
245
  }
234
246
  catch {
235
247
  // Element not visible or locator failed — skip
@@ -469,44 +481,56 @@ async function stampFocusFromDocumentActiveElement(page, elements) {
469
481
  rectPick.best.focused = true;
470
482
  }
471
483
  }
472
- /** IoU + center-inside score of a candidate node's bounds against a mirror rect. */
473
- function overlapScore(bounds, m) {
474
- const x1 = Math.max(m.x, bounds.x);
475
- const y1 = Math.max(m.y, bounds.y);
476
- const x2 = Math.min(m.x + m.width, bounds.x + bounds.width);
477
- const y2 = Math.min(m.y + m.height, bounds.y + bounds.height);
478
- const inter = Math.max(0, x2 - x1) * Math.max(0, y2 - y1);
479
- if (inter <= 0)
480
- return 0;
481
- const union = m.width * m.height + bounds.width * bounds.height - inter;
482
- const iou = union > 0 ? inter / union : 0;
483
- const mcx = m.x + m.width / 2;
484
- const mcy = m.y + m.height / 2;
485
- const centerInside = mcx >= bounds.x &&
486
- mcx <= bounds.x + bounds.width &&
487
- mcy >= bounds.y &&
488
- mcy <= bounds.y + bounds.height;
489
- return centerInside ? iou + 1 : iou;
490
- }
491
- /** Find the existing tree node whose bounds best overlap a mirror rect (>0.5 score). */
492
- function bestOverlappingNode(elements, m) {
493
- let best = null;
494
- let bestScore = 0.5;
495
- const walk = (els) => {
484
+ /**
485
+ * Merge canvas scene-graph nodes into the ARIA tree by identity (`data-testid`), never by
486
+ * geometry. A mirror node that shares a testId with an existing (natively-read) tree node
487
+ * enriches it and, authoritatively, carries its `data-focused` state onto it. Mirror nodes with
488
+ * no counterpart (canvas-only / off-screen scene nodes) are appended as their own nodes.
489
+ *
490
+ * Because focus rides identity not bounds an open drawer overlapping a focused tile can no
491
+ * longer steal the tile's focus, and a tile's identity can't smear onto a neighbour it happens
492
+ * to overlap.
493
+ */
494
+ function mergeCanvasNodes(elements, mirror) {
495
+ const byTestId = new Map();
496
+ const index = (els) => {
496
497
  for (const el of els) {
497
- if (el.bounds) {
498
- const s = overlapScore(el.bounds, m);
499
- if (s > bestScore) {
500
- bestScore = s;
501
- best = el;
502
- }
503
- }
498
+ if (el.testId)
499
+ byTestId.set(el.testId, el);
504
500
  if (el.children)
505
- walk(el.children);
501
+ index(el.children);
506
502
  }
507
503
  };
508
- walk(elements);
509
- return best;
504
+ index(elements);
505
+ for (const m of mirror) {
506
+ const bounds = m.width > 0 && m.height > 0
507
+ ? { x: m.x, y: m.y, width: m.width, height: m.height }
508
+ : undefined;
509
+ const existing = m.testId ? byTestId.get(m.testId) : undefined;
510
+ if (existing) {
511
+ if (m.focused)
512
+ existing.focused = true;
513
+ if (!existing.name && m.name)
514
+ existing.name = m.name;
515
+ if (bounds && !existing.bounds)
516
+ existing.bounds = bounds;
517
+ }
518
+ else {
519
+ const node = {
520
+ role: m.role,
521
+ name: m.name,
522
+ ref: '',
523
+ testId: m.testId || undefined,
524
+ bounds,
525
+ enabled: !m.disabled,
526
+ focused: m.focused,
527
+ children: [],
528
+ };
529
+ elements.push(node);
530
+ if (m.testId)
531
+ byTestId.set(m.testId, node);
532
+ }
533
+ }
510
534
  }
511
535
  /**
512
536
  * Harvest a canvas DOM-inspector mirror into the hierarchy. Canvas webtv frameworks
@@ -514,10 +538,11 @@ function bestOverlappingNode(elements, m) {
514
538
  * off-screen `<div>`s carrying `data-testid` (real identity) and `data-focused="true"` (focus
515
539
  * — the canvas owns `document.activeElement`, so the normal focus path can't see it).
516
540
  *
517
- * Each mirror node is matched to an existing ARIA node by bounds overlap and used to enrich it
518
- * (testId + focus); unmatched mirror nodes are appended as new nodes. Mirror rects come from
519
- * `getBoundingClientRect`, i.e. the same viewport-CSS-pixel space taps use — drive TV apps at
520
- * the app's native resolution (e.g. `set-viewport 1920 1080`) so lower nodes aren't off-screen.
541
+ * Nodes are joined to the ARIA tree by identity (see mergeCanvasNodes); canvas-only nodes are
542
+ * appended. Mirror rects come from `getBoundingClientRect`, i.e. the same viewport-CSS-pixel
543
+ * space taps use — drive TV apps at the app's native resolution (e.g. `set-viewport 1920 1080`)
544
+ * so lower scene nodes aren't off-screen. Returns true when a mirror was present (a canvas app),
545
+ * so the caller can skip the DOM `activeElement` focus fallback.
521
546
  */
522
547
  async function harvestDomMirror(page, elements) {
523
548
  let mirror = null;
@@ -541,40 +566,12 @@ async function harvestDomMirror(page, elements) {
541
566
  })()`));
542
567
  }
543
568
  catch {
544
- return;
569
+ return false;
545
570
  }
546
571
  if (!mirror || mirror.length === 0)
547
- return;
548
- // data-focused is authoritative for canvas apps (activeElement is the <canvas>, never the
549
- // focused scene node), so clear any focus already inferred from the ARIA snapshot.
550
- if (mirror.some((m) => m.focused))
551
- clearFocusedFlags(elements);
552
- for (const m of mirror) {
553
- const bounds = m.width > 0 && m.height > 0
554
- ? { x: m.x, y: m.y, width: m.width, height: m.height }
555
- : undefined;
556
- const target = bounds ? bestOverlappingNode(elements, m) : null;
557
- if (target) {
558
- if (m.testId)
559
- target.testId = m.testId;
560
- if (m.focused)
561
- target.focused = true;
562
- if (!target.name && m.name)
563
- target.name = m.name;
564
- }
565
- else {
566
- elements.push({
567
- role: m.role,
568
- name: m.name,
569
- ref: '',
570
- testId: m.testId || undefined,
571
- bounds,
572
- enabled: !m.disabled,
573
- focused: m.focused,
574
- children: [],
575
- });
576
- }
577
- }
572
+ return false;
573
+ mergeCanvasNodes(elements, mirror);
574
+ return true;
578
575
  }
579
576
  // ── Web server ───────────────────────────────────────────────────────────────
580
577
  const DEFAULT_VIEWPORT = { width: 1280, height: 720 };
@@ -983,12 +980,16 @@ async function handleRequest(req, res, dlog) {
983
980
  await resolveBoundingBoxes(p, elements);
984
981
  await resolveBoundingBoxesBatch(p, elements);
985
982
  await resolveBoundingBoxesByRole(p, elements);
983
+ // Focus source depends on the app, and the two are mutually exclusive:
984
+ // • Canvas/inspector apps (Lightning/WPE) own document.activeElement on the <canvas>,
985
+ // so focus lives on scene nodes as `data-focused` — merged by identity.
986
+ // • Plain DOM apps expose focus via ARIA `[active]` / document.activeElement.
986
987
  clearFocusedFlags(elements);
987
- applyFocusFromAriaSnapshotYaml(ariaSnapshot, elements);
988
- // Canvas webtv: merge the data-testid/data-focused mirror before falling back to
989
- // document.activeElement (which is the <canvas>, not the focused scene node).
990
- await harvestDomMirror(p, elements);
991
- await stampFocusFromDocumentActiveElement(p, elements);
988
+ const isCanvasApp = await harvestDomMirror(p, elements);
989
+ if (!isCanvasApp) {
990
+ applyFocusFromAriaSnapshotYaml(ariaSnapshot, elements);
991
+ await stampFocusFromDocumentActiveElement(p, elements);
992
+ }
992
993
  jsonResponse(res, {
993
994
  url: p.url(),
994
995
  title: await p.title(),
@@ -5,6 +5,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.detectPlatform = detectPlatform;
7
7
  exports.getDriverPort = getDriverPort;
8
+ exports.getInputPort = getInputPort;
9
+ exports.getInprocDylibPath = getInprocDylibPath;
10
+ exports.getHidBinaryPath = getHidBinaryPath;
8
11
  exports.installDriver = installDriver;
9
12
  exports.isSimulatorBooted = isSimulatorBooted;
10
13
  exports.isPortOpen = isPortOpen;
@@ -89,6 +92,7 @@ const TVOS_BASE_PORT = 2075;
89
92
  const ANDROID_BASE_PORT = 3763;
90
93
  const WEB_BASE_PORT = 4075;
91
94
  const VEGA_BASE_PORT = 5075;
95
+ const INPUT_BASE_PORT = 7075;
92
96
  const PORT_FILE = path_1.default.join(os_1.default.homedir(), '.conductor', 'ports.json');
93
97
  const PORT_LOCK = PORT_FILE + '.lock';
94
98
  const PORT_LOCK_TIMEOUT_MS = 5000;
@@ -175,6 +179,27 @@ async function getDriverPort(platform, deviceId) {
175
179
  return port;
176
180
  });
177
181
  }
182
+ /**
183
+ * Assign and persist a streaming-input WebSocket port for a device. Kept in a
184
+ * separate namespace from the driver port (a device has both): the driver port
185
+ * serves the XCUITest/gRPC HTTP surface, this one the persistent input socket.
186
+ */
187
+ async function getInputPort(deviceId) {
188
+ return withPortLock(() => {
189
+ const state = readPortState();
190
+ if (!state.inputAssignments)
191
+ state.inputAssignments = {};
192
+ if (state.nextInputPort === undefined)
193
+ state.nextInputPort = INPUT_BASE_PORT;
194
+ if (state.inputAssignments[deviceId] !== undefined) {
195
+ return state.inputAssignments[deviceId];
196
+ }
197
+ const port = state.nextInputPort++;
198
+ state.inputAssignments[deviceId] = port;
199
+ writePortState(state);
200
+ return port;
201
+ });
202
+ }
178
203
  // ── Driver paths (bundled dev fallback + runtime download cache) ──────────────
179
204
  /**
180
205
  * Walk up from __dirname to find the package root (the directory containing
@@ -225,6 +250,30 @@ async function getDriversDir() {
225
250
  });
226
251
  return _driversDirPromise;
227
252
  }
253
+ /**
254
+ * Absolute path to the injectable in-process control library
255
+ * (`<platform>-inproc/Conductor.framework/Conductor`), built by
256
+ * `packages/ios-inproc/tools/build-inproc-dylib.sh`. Passed to the target app
257
+ * via SIMCTL_CHILD_DYLD_INSERT_LIBRARIES at launch. iOS and tvOS ship separate
258
+ * builds (different simulator SDK).
259
+ */
260
+ async function getInprocDylibPath(platform = 'ios') {
261
+ const dir = await getDriversDir();
262
+ return path_1.default.join(dir, `${platform}-inproc`, 'Conductor.framework', 'Conductor');
263
+ }
264
+ /**
265
+ * Absolute path to the host-side CoreSimulator HID injector (`ios-hid/conductor-hid`),
266
+ * built by `packages/ios-hid/tools/build-hid.sh`. Optional: only used for live
267
+ * held-touch drags when CONDUCTOR_IOS_HID=1 and the binary is present. Returns
268
+ * null if it hasn't been built.
269
+ */
270
+ async function getHidBinaryPath() {
271
+ const dir = await getDriversDir().catch(() => null);
272
+ if (!dir)
273
+ return null;
274
+ const p = path_1.default.join(dir, 'ios-hid', 'conductor-hid');
275
+ return fs_1.default.existsSync(p) ? p : null;
276
+ }
228
277
  async function ensureDriversCache(pkgRoot) {
229
278
  const pkgJsonPath = path_1.default.join(pkgRoot, 'package.json');
230
279
  const pkg = JSON.parse(fs_1.default.readFileSync(pkgJsonPath, 'utf-8'));
@@ -0,0 +1,131 @@
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.compileEval = compileEval;
7
+ /**
8
+ * Compiles user Swift into a fresh dylib exporting `conductor_eval`, then drops
9
+ * it into the target app's container so the injected library can dlopen it.
10
+ *
11
+ * `expr` mode wraps an expression; `full` mode takes the whole function body.
12
+ * Each build is a uniquely-named dylib to avoid dlopen caching.
13
+ */
14
+ const child_process_1 = require("child_process");
15
+ const util_1 = require("util");
16
+ const crypto_1 = __importDefault(require("crypto"));
17
+ const promises_1 = __importDefault(require("fs/promises"));
18
+ const os_1 = __importDefault(require("os"));
19
+ const path_1 = __importDefault(require("path"));
20
+ const run = (0, util_1.promisify)(child_process_1.execFile);
21
+ const EXPR_TEMPLATE = (code) => `import Foundation
22
+ import UIKit
23
+ import SwiftUI
24
+
25
+ @_cdecl("conductor_eval")
26
+ public func conductor_eval() -> UnsafePointer<CChar> {
27
+ let __result: Any = {
28
+ ${code}
29
+ }()
30
+ return UnsafePointer(strdup(String(describing: __result))!)
31
+ }
32
+ `;
33
+ const FULL_TEMPLATE = (code) => `import Foundation
34
+ import UIKit
35
+ import SwiftUI
36
+
37
+ @_cdecl("conductor_eval")
38
+ public func conductor_eval() -> UnsafePointer<CChar> {
39
+ ${code}
40
+ }
41
+ `;
42
+ async function detectTarget(platform) {
43
+ const sdkName = platform === 'tvos' ? 'appletvsimulator' : 'iphonesimulator';
44
+ const sdkPlatform = platform === 'tvos' ? 'tvos' : 'ios';
45
+ const [{ stdout: sdkPath }, { stdout: sdkVer }] = await Promise.all([
46
+ run('xcrun', ['--sdk', sdkName, '--show-sdk-path']),
47
+ run('xcrun', ['--sdk', sdkName, '--show-sdk-version']),
48
+ ]);
49
+ const major = sdkVer.trim().split('.')[0];
50
+ const arch = os_1.default.arch() === 'arm64' ? 'arm64' : 'x86_64';
51
+ return {
52
+ sdkName,
53
+ sdkPath: sdkPath.trim(),
54
+ target: `${arch}-apple-${sdkPlatform}${major}.0-simulator`,
55
+ };
56
+ }
57
+ /**
58
+ * Compile `code` and place the dylib inside the app container's tmp so the
59
+ * sandboxed app can dlopen it. Returns the container-relative host path (which,
60
+ * on the simulator, is the same path the app opens).
61
+ */
62
+ async function compileEval(code, mode, platform, deviceId, bundleId) {
63
+ const start = Date.now();
64
+ const { sdkName, sdkPath, target } = await detectTarget(platform);
65
+ const source = (mode === 'full' ? FULL_TEMPLATE : EXPR_TEMPLATE)(code);
66
+ const hash = crypto_1.default.createHash('md5').update(source).digest('hex').slice(0, 10);
67
+ const workDir = path_1.default.join(os_1.default.tmpdir(), 'conductor-eval');
68
+ await promises_1.default.mkdir(workDir, { recursive: true });
69
+ const swiftFile = path_1.default.join(workDir, `eval_${hash}.swift`);
70
+ const dylibName = `eval_${hash}_${Date.now()}.dylib`;
71
+ const dylibPath = path_1.default.join(workDir, dylibName);
72
+ await promises_1.default.writeFile(swiftFile, source, 'utf-8');
73
+ try {
74
+ await run('xcrun', [
75
+ '-sdk',
76
+ sdkName,
77
+ 'swiftc',
78
+ '-target',
79
+ target,
80
+ '-sdk',
81
+ sdkPath,
82
+ '-emit-library',
83
+ '-Onone',
84
+ '-enable-testing',
85
+ '-o',
86
+ dylibPath,
87
+ // Unresolved symbols (app/system) resolve at dlopen time in the host process.
88
+ '-Xlinker',
89
+ '-undefined',
90
+ '-Xlinker',
91
+ 'dynamic_lookup',
92
+ swiftFile,
93
+ ]);
94
+ }
95
+ catch (err) {
96
+ return {
97
+ ok: false,
98
+ error: `compile failed:\n${err.stderr ?? String(err)}`,
99
+ };
100
+ }
101
+ // Ad-hoc sign (sim rejects unsigned dylibs for dlopen on newer runtimes).
102
+ await run('codesign', ['--force', '--sign', '-', dylibPath]).catch(() => { });
103
+ // Copy into the app's data container tmp so the sandboxed app can read it.
104
+ try {
105
+ const { stdout } = await run('xcrun', [
106
+ 'simctl',
107
+ 'get_app_container',
108
+ deviceId,
109
+ bundleId,
110
+ 'data',
111
+ ]);
112
+ const container = stdout.trim();
113
+ // System apps (and any without a data container) print "(null)"; use the raw
114
+ // path there — simulator apps are host processes and can dlopen it directly.
115
+ if (!container.startsWith('/'))
116
+ throw new Error('no data container');
117
+ const containerTmp = path_1.default.join(container, 'tmp');
118
+ await promises_1.default.mkdir(containerTmp, { recursive: true });
119
+ const dest = path_1.default.join(containerTmp, dylibName);
120
+ await promises_1.default.copyFile(dylibPath, dest);
121
+ return { ok: true, dylibPath: dest, info: `compiled in ${Date.now() - start}ms` };
122
+ }
123
+ catch {
124
+ // Fall back to the raw temp path — simulator apps can usually open it directly.
125
+ return {
126
+ ok: true,
127
+ dylibPath,
128
+ info: `compiled in ${Date.now() - start}ms (container copy skipped)`,
129
+ };
130
+ }
131
+ }
@@ -0,0 +1,95 @@
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.IOSHidClient = void 0;
7
+ /**
8
+ * Node client for the host-side CoreSimulator HID injector
9
+ * (`packages/ios-hid`, built to `drivers/ios-hid/conductor-hid`).
10
+ *
11
+ * This is the streaming pointer backend: unlike XCUITest's atomic
12
+ * `_XCT_synthesizeEvent`, it holds a touch DOWN and streams moves, so a live
13
+ * drag animates on-device as the finger moves. Opt-in (CONDUCTOR_IOS_HID=1) and
14
+ * single-touch — multitouch/discrete gestures stay on the XCUITest path.
15
+ *
16
+ * Talks newline-delimited JSON over the binary's stdio, one request in flight
17
+ * at a time (FIFO correlation, mirroring the XCTest driver's simplicity).
18
+ */
19
+ const child_process_1 = require("child_process");
20
+ const readline_1 = __importDefault(require("readline"));
21
+ const PHASE_TO_TYPE = {
22
+ down: 0,
23
+ move: 1,
24
+ up: 2,
25
+ cancel: 2, // release the held touch
26
+ };
27
+ class IOSHidClient {
28
+ constructor(binaryPath, udid) {
29
+ this.binaryPath = binaryPath;
30
+ this.udid = udid;
31
+ this.proc = null;
32
+ this.rl = null;
33
+ this.pending = [];
34
+ }
35
+ start() {
36
+ if (this.proc)
37
+ return;
38
+ this.proc = (0, child_process_1.spawn)(this.binaryPath, [], { stdio: ['pipe', 'pipe', 'inherit'] });
39
+ this.rl = readline_1.default.createInterface({ input: this.proc.stdout });
40
+ this.rl.on('line', (line) => {
41
+ const resolve = this.pending.shift();
42
+ if (!resolve)
43
+ return;
44
+ try {
45
+ resolve(JSON.parse(line));
46
+ }
47
+ catch {
48
+ resolve({ ok: false, error: `bad response: ${line}` });
49
+ }
50
+ });
51
+ this.proc.on('exit', () => {
52
+ this.proc = null;
53
+ this.rl = null;
54
+ // Fail any in-flight requests so callers don't hang.
55
+ while (this.pending.length)
56
+ this.pending.shift()({ ok: false, error: 'hid process exited' });
57
+ });
58
+ }
59
+ stop() {
60
+ this.proc?.kill();
61
+ this.proc = null;
62
+ this.rl = null;
63
+ }
64
+ send(req) {
65
+ if (!this.proc)
66
+ this.start();
67
+ return new Promise((resolve) => {
68
+ this.pending.push(resolve);
69
+ this.proc.stdin.write(JSON.stringify(req) + '\n');
70
+ });
71
+ }
72
+ async ping() {
73
+ const r = await this.send({ cmd: 'ping' });
74
+ return r.ok;
75
+ }
76
+ /** Inject a touch phase at normalized coords. */
77
+ async touch(nx, ny, phase) {
78
+ const r = await this.send({
79
+ cmd: 'touch',
80
+ udid: this.udid,
81
+ x: nx,
82
+ y: ny,
83
+ type: PHASE_TO_TYPE[phase],
84
+ });
85
+ if (!r.ok)
86
+ throw new Error(`hid touch failed (rc=${r.rc ?? '?'}) ${r.error ?? ''}`.trim());
87
+ }
88
+ /** Adapter for the router's live-pointer path (single finger; id ignored). */
89
+ asLivePointer() {
90
+ return {
91
+ pointer: (_id, phase, nx, ny) => this.touch(nx, ny, phase),
92
+ };
93
+ }
94
+ }
95
+ exports.IOSHidClient = IOSHidClient;