driftx 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +216 -0
  3. package/dist/bin.js +834 -81
  4. package/dist/bin.js.map +1 -1
  5. package/driftx-plugin/skills/driftx/SKILL.md +281 -27
  6. package/ios-companion/DriftxCompanion/DriftxCompanionApp.swift +10 -0
  7. package/ios-companion/DriftxCompanion/Info.plist +22 -0
  8. package/ios-companion/DriftxCompanion.xcodeproj/project.pbxproj +376 -0
  9. package/ios-companion/DriftxCompanion.xcodeproj/xcshareddata/xcschemes/DriftxCompanionUITests.xcscheme +109 -0
  10. package/ios-companion/DriftxCompanionUITests/CompanionServer.swift +176 -0
  11. package/ios-companion/DriftxCompanionUITests/DriftxCompanionUITests.swift +15 -0
  12. package/ios-companion/DriftxCompanionUITests/HierarchyEndpoint.swift +140 -0
  13. package/ios-companion/DriftxCompanionUITests/Info.plist +22 -0
  14. package/ios-companion/DriftxCompanionUITests/InteractionEndpoint.swift +142 -0
  15. package/ios-companion/DriftxCompanionUITests/Router.swift +47 -0
  16. package/ios-companion/prebuilt/Debug-iphonesimulator/DriftxCompanion.app/DriftxCompanion +0 -0
  17. package/ios-companion/prebuilt/Debug-iphonesimulator/DriftxCompanion.app/DriftxCompanion.debug.dylib +0 -0
  18. package/ios-companion/prebuilt/Debug-iphonesimulator/DriftxCompanion.app/Info.plist +0 -0
  19. package/ios-companion/prebuilt/Debug-iphonesimulator/DriftxCompanion.app/PkgInfo +1 -0
  20. package/ios-companion/prebuilt/Debug-iphonesimulator/DriftxCompanion.app/__preview.dylib +0 -0
  21. package/ios-companion/prebuilt/Debug-iphonesimulator/DriftxCompanionUITests-Runner.app/DriftxCompanionUITests-Runner +0 -0
  22. package/ios-companion/prebuilt/Debug-iphonesimulator/DriftxCompanionUITests-Runner.app/Frameworks/Testing.framework/Info.plist +0 -0
  23. package/ios-companion/prebuilt/Debug-iphonesimulator/DriftxCompanionUITests-Runner.app/Frameworks/Testing.framework/Testing +0 -0
  24. package/ios-companion/prebuilt/Debug-iphonesimulator/DriftxCompanionUITests-Runner.app/Frameworks/Testing.framework/_CodeSignature/CodeResources +168 -0
  25. package/ios-companion/prebuilt/Debug-iphonesimulator/DriftxCompanionUITests-Runner.app/Frameworks/Testing.framework/version.plist +18 -0
  26. package/ios-companion/prebuilt/Debug-iphonesimulator/DriftxCompanionUITests-Runner.app/Frameworks/XCTAutomationSupport.framework/Info.plist +0 -0
  27. package/ios-companion/prebuilt/Debug-iphonesimulator/DriftxCompanionUITests-Runner.app/Frameworks/XCTAutomationSupport.framework/XCTAutomationSupport +0 -0
  28. package/ios-companion/prebuilt/Debug-iphonesimulator/DriftxCompanionUITests-Runner.app/Frameworks/XCTAutomationSupport.framework/_CodeSignature/CodeResources +113 -0
  29. package/ios-companion/prebuilt/Debug-iphonesimulator/DriftxCompanionUITests-Runner.app/Frameworks/XCTAutomationSupport.framework/version.plist +18 -0
  30. package/ios-companion/prebuilt/Debug-iphonesimulator/DriftxCompanionUITests-Runner.app/Frameworks/XCTest.framework/Info.plist +0 -0
  31. package/ios-companion/prebuilt/Debug-iphonesimulator/DriftxCompanionUITests-Runner.app/Frameworks/XCTest.framework/XCTest +0 -0
  32. package/ios-companion/prebuilt/Debug-iphonesimulator/DriftxCompanionUITests-Runner.app/Frameworks/XCTest.framework/_CodeSignature/CodeResources +817 -0
  33. package/ios-companion/prebuilt/Debug-iphonesimulator/DriftxCompanionUITests-Runner.app/Frameworks/XCTest.framework/version.plist +18 -0
  34. package/ios-companion/prebuilt/Debug-iphonesimulator/DriftxCompanionUITests-Runner.app/Frameworks/XCTestCore.framework/Info.plist +0 -0
  35. package/ios-companion/prebuilt/Debug-iphonesimulator/DriftxCompanionUITests-Runner.app/Frameworks/XCTestCore.framework/XCTestCore +0 -0
  36. package/ios-companion/prebuilt/Debug-iphonesimulator/DriftxCompanionUITests-Runner.app/Frameworks/XCTestCore.framework/_CodeSignature/CodeResources +113 -0
  37. package/ios-companion/prebuilt/Debug-iphonesimulator/DriftxCompanionUITests-Runner.app/Frameworks/XCTestCore.framework/version.plist +18 -0
  38. package/ios-companion/prebuilt/Debug-iphonesimulator/DriftxCompanionUITests-Runner.app/Frameworks/XCTestSupport.framework/Info.plist +0 -0
  39. package/ios-companion/prebuilt/Debug-iphonesimulator/DriftxCompanionUITests-Runner.app/Frameworks/XCTestSupport.framework/XCTestSupport +0 -0
  40. package/ios-companion/prebuilt/Debug-iphonesimulator/DriftxCompanionUITests-Runner.app/Frameworks/XCTestSupport.framework/_CodeSignature/CodeResources +113 -0
  41. package/ios-companion/prebuilt/Debug-iphonesimulator/DriftxCompanionUITests-Runner.app/Frameworks/XCTestSupport.framework/version.plist +18 -0
  42. package/ios-companion/prebuilt/Debug-iphonesimulator/DriftxCompanionUITests-Runner.app/Frameworks/XCUIAutomation.framework/Info.plist +0 -0
  43. package/ios-companion/prebuilt/Debug-iphonesimulator/DriftxCompanionUITests-Runner.app/Frameworks/XCUIAutomation.framework/XCUIAutomation +0 -0
  44. package/ios-companion/prebuilt/Debug-iphonesimulator/DriftxCompanionUITests-Runner.app/Frameworks/XCUIAutomation.framework/_CodeSignature/CodeResources +432 -0
  45. package/ios-companion/prebuilt/Debug-iphonesimulator/DriftxCompanionUITests-Runner.app/Frameworks/XCUIAutomation.framework/version.plist +18 -0
  46. package/ios-companion/prebuilt/Debug-iphonesimulator/DriftxCompanionUITests-Runner.app/Frameworks/XCUnit.framework/Info.plist +0 -0
  47. package/ios-companion/prebuilt/Debug-iphonesimulator/DriftxCompanionUITests-Runner.app/Frameworks/XCUnit.framework/XCUnit +0 -0
  48. package/ios-companion/prebuilt/Debug-iphonesimulator/DriftxCompanionUITests-Runner.app/Frameworks/XCUnit.framework/_CodeSignature/CodeResources +113 -0
  49. package/ios-companion/prebuilt/Debug-iphonesimulator/DriftxCompanionUITests-Runner.app/Frameworks/XCUnit.framework/version.plist +18 -0
  50. package/ios-companion/prebuilt/Debug-iphonesimulator/DriftxCompanionUITests-Runner.app/Frameworks/libXCTestSwiftSupport.dylib +0 -0
  51. package/ios-companion/prebuilt/Debug-iphonesimulator/DriftxCompanionUITests-Runner.app/Info.plist +254 -0
  52. package/ios-companion/prebuilt/Debug-iphonesimulator/DriftxCompanionUITests-Runner.app/PkgInfo +1 -0
  53. package/ios-companion/prebuilt/Debug-iphonesimulator/DriftxCompanionUITests-Runner.app/PlugIns/DriftxCompanionUITests.xctest/DriftxCompanionUITests +0 -0
  54. package/ios-companion/prebuilt/Debug-iphonesimulator/DriftxCompanionUITests-Runner.app/PlugIns/DriftxCompanionUITests.xctest/Info.plist +0 -0
  55. package/ios-companion/prebuilt/DriftxCompanionUITests.xctestrun +135 -0
  56. package/ios-companion/prebuilt/build-info.json +6 -0
  57. package/package.json +14 -3
package/dist/bin.js CHANGED
@@ -3,10 +3,10 @@
3
3
  // src/cli.ts
4
4
  import { Command } from "commander";
5
5
  import { createRequire } from "module";
6
- import { existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync8, readdirSync as readdirSync2, symlinkSync, unlinkSync as unlinkSync3, writeFileSync as writeFileSync4 } from "fs";
7
- import { dirname as dirname4, join as join5, resolve } from "path";
8
- import { homedir } from "os";
9
- import { fileURLToPath } from "url";
6
+ import { existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync10, readdirSync as readdirSync2, symlinkSync, unlinkSync as unlinkSync3, writeFileSync as writeFileSync6 } from "fs";
7
+ import { dirname as dirname5, join as join7, resolve as resolve2 } from "path";
8
+ import { homedir as homedir2 } from "os";
9
+ import { fileURLToPath as fileURLToPath2 } from "url";
10
10
 
11
11
  // src/shell.ts
12
12
  import { execFile } from "child_process";
@@ -14,7 +14,7 @@ var RealShell = class {
14
14
  async exec(cmd, args, options) {
15
15
  const controller = new AbortController();
16
16
  const timeout = options?.timeout;
17
- return new Promise((resolve2, reject) => {
17
+ return new Promise((resolve3, reject) => {
18
18
  const child = execFile(
19
19
  cmd,
20
20
  args,
@@ -27,7 +27,7 @@ var RealShell = class {
27
27
  if (error) {
28
28
  reject(error);
29
29
  } else {
30
- resolve2({ stdout, stderr });
30
+ resolve3({ stdout, stderr });
31
31
  }
32
32
  }
33
33
  );
@@ -120,6 +120,7 @@ var configSchema = z.object({
120
120
  regionMergeGap: z.number().int().nonnegative().optional(),
121
121
  regionMinArea: z.number().int().nonnegative().optional(),
122
122
  diffMaskColor: z.tuple([z.number(), z.number(), z.number(), z.number()]).optional(),
123
+ companionPort: z.number().int().positive().optional(),
123
124
  analyses: analysesSchema
124
125
  });
125
126
  var DEFAULTS = {
@@ -145,6 +146,7 @@ var DEFAULTS = {
145
146
  regionMergeGap: 8,
146
147
  regionMinArea: 100,
147
148
  diffMaskColor: [255, 0, 0, 128],
149
+ companionPort: 8300,
148
150
  analyses: {
149
151
  default: [],
150
152
  disabled: [],
@@ -160,6 +162,7 @@ function parseConfig(raw) {
160
162
  return {
161
163
  ...defaults,
162
164
  ...parsed,
165
+ companionPort: parsed.companionPort ?? defaults.companionPort,
163
166
  viewport: { ...defaults.viewport, ...parsed.viewport },
164
167
  timeouts: { ...defaults.timeouts, ...parsed.timeouts },
165
168
  retry: { ...defaults.retry, ...parsed.retry },
@@ -249,14 +252,14 @@ async function checkOne(spec, shell) {
249
252
  }
250
253
  async function checkMetro(port) {
251
254
  try {
252
- const { default: http2 } = await import("http");
253
- const status = await new Promise((resolve2, reject) => {
254
- const req = http2.get(`http://localhost:${port}/status`, { timeout: 2e3 }, (res) => {
255
+ const { default: http3 } = await import("http");
256
+ const status = await new Promise((resolve3, reject) => {
257
+ const req = http3.get(`http://localhost:${port}/status`, { timeout: 2e3 }, (res) => {
255
258
  let data = "";
256
259
  res.on("data", (chunk) => {
257
260
  data += chunk;
258
261
  });
259
- res.on("end", () => resolve2(data));
262
+ res.on("end", () => resolve3(data));
260
263
  });
261
264
  req.on("error", reject);
262
265
  req.on("timeout", () => {
@@ -523,7 +526,7 @@ function isScreenSettled(buf1, buf2, maxDelta) {
523
526
  return diffPixels / totalPixels <= maxDelta;
524
527
  }
525
528
  function delay(ms) {
526
- return new Promise((resolve2) => setTimeout(resolve2, ms));
529
+ return new Promise((resolve3) => setTimeout(resolve3, ms));
527
530
  }
528
531
  async function captureScreenshot(shell, device, options) {
529
532
  const logger = getLogger();
@@ -622,12 +625,12 @@ function isRetryable(error, policy) {
622
625
  return policy.retryableErrors.some((pattern) => error.message.includes(pattern));
623
626
  }
624
627
  function delay2(ms, signal) {
625
- return new Promise((resolve2, reject) => {
628
+ return new Promise((resolve3, reject) => {
626
629
  if (signal?.aborted) {
627
630
  reject(new Error("Aborted"));
628
631
  return;
629
632
  }
630
- const timer = setTimeout(resolve2, ms);
633
+ const timer = setTimeout(resolve3, ms);
631
634
  signal?.addEventListener("abort", () => {
632
635
  clearTimeout(timer);
633
636
  reject(new Error("Aborted"));
@@ -908,8 +911,133 @@ var FIBER_WALK_SCRIPT = `(function() {
908
911
  }
909
912
  }
910
913
  })()`;
914
+ var MEASURE_FIND_SCRIPT = (text) => `(function() {
915
+ try {
916
+ globalThis.__driftx_measure = null;
917
+ var hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
918
+ if (!hook || !hook.renderers) return JSON.stringify({error: "no hook"});
919
+ var targetText = ${JSON.stringify(text)};
920
+ var found = null;
921
+ function searchFiber(fiber) {
922
+ if (!fiber || found) return;
923
+ var props = fiber.memoizedProps || {};
924
+ if (typeof props.children === 'string' && props.children === targetText) { found = fiber; return; }
925
+ var child = fiber.child;
926
+ while (child) { searchFiber(child); child = child.sibling; }
927
+ }
928
+ hook.renderers.forEach(function(renderer, id) {
929
+ var roots = hook.getFiberRoots(id);
930
+ if (!roots) return;
931
+ roots.forEach(function(root) { if (root.current) searchFiber(root.current); });
932
+ });
933
+ if (!found) return JSON.stringify({error: "not found"});
934
+ var host = found;
935
+ while (host) {
936
+ if (host.tag === 5 && host.stateNode != null) break;
937
+ host = host.return;
938
+ }
939
+ if (!host || !host.stateNode) return JSON.stringify({error: "no host"});
940
+ var sn = host.stateNode;
941
+ if (typeof sn.measureInWindow === 'function') {
942
+ sn.measureInWindow(function(x, y, w, h) {
943
+ globalThis.__driftx_measure = {x: x, y: y, width: w, height: h};
944
+ });
945
+ return JSON.stringify({status: "measuring"});
946
+ }
947
+ return JSON.stringify({error: "no measureInWindow"});
948
+ } catch(e) { return JSON.stringify({error: e.message}); }
949
+ })()`;
950
+ var MEASURE_READ_SCRIPT = `(function() {
951
+ var r = globalThis.__driftx_measure;
952
+ globalThis.__driftx_measure = null;
953
+ return r ? JSON.stringify(r) : JSON.stringify(null);
954
+ })()`;
955
+ async function measureElementByText(metroPort, text, deviceName, timeoutMs = 1e4) {
956
+ const logger = getLogger();
957
+ const targets = await discoverTargets(metroPort);
958
+ const target = findRuntimeTarget(targets, deviceName);
959
+ if (!target) return null;
960
+ return new Promise((resolve3) => {
961
+ const timer = setTimeout(() => {
962
+ cleanup();
963
+ resolve3(null);
964
+ }, timeoutMs);
965
+ let ws;
966
+ function cleanup() {
967
+ try {
968
+ ws?.close();
969
+ } catch {
970
+ }
971
+ }
972
+ try {
973
+ ws = new WebSocket(target.webSocketDebuggerUrl);
974
+ } catch {
975
+ clearTimeout(timer);
976
+ return resolve3(null);
977
+ }
978
+ ws.on("open", () => {
979
+ ws.send(JSON.stringify({
980
+ id: 1,
981
+ method: "Runtime.evaluate",
982
+ params: { expression: MEASURE_FIND_SCRIPT(text), returnByValue: true }
983
+ }));
984
+ });
985
+ ws.on("message", (data) => {
986
+ try {
987
+ const msg = JSON.parse(data.toString());
988
+ if (msg.id === 1) {
989
+ const val = msg.result?.result?.value;
990
+ if (typeof val === "string") {
991
+ const parsed = JSON.parse(val);
992
+ if (parsed?.status === "measuring") {
993
+ setTimeout(() => {
994
+ ws.send(JSON.stringify({
995
+ id: 2,
996
+ method: "Runtime.evaluate",
997
+ params: { expression: MEASURE_READ_SCRIPT, returnByValue: true }
998
+ }));
999
+ }, 200);
1000
+ return;
1001
+ }
1002
+ }
1003
+ clearTimeout(timer);
1004
+ cleanup();
1005
+ resolve3(null);
1006
+ }
1007
+ if (msg.id === 2) {
1008
+ clearTimeout(timer);
1009
+ const val = msg.result?.result?.value;
1010
+ if (typeof val === "string") {
1011
+ const parsed = JSON.parse(val);
1012
+ if (parsed && typeof parsed.x === "number" && parsed.width > 0) {
1013
+ logger.debug(`CDP measure '${text}': ${JSON.stringify(parsed)}`);
1014
+ cleanup();
1015
+ return resolve3(parsed);
1016
+ }
1017
+ }
1018
+ cleanup();
1019
+ resolve3(null);
1020
+ }
1021
+ } catch {
1022
+ }
1023
+ });
1024
+ ws.on("error", () => {
1025
+ clearTimeout(timer);
1026
+ cleanup();
1027
+ resolve3(null);
1028
+ });
1029
+ });
1030
+ }
1031
+ async function detectBundleId(metroPort, deviceName) {
1032
+ const targets = await discoverTargets(metroPort);
1033
+ const target = findRuntimeTarget(targets, deviceName);
1034
+ if (!target) return null;
1035
+ if (target.appId) return target.appId;
1036
+ if (target.description && /^[a-zA-Z][a-zA-Z0-9._-]+$/.test(target.description)) return target.description;
1037
+ return null;
1038
+ }
911
1039
  async function discoverTargets(metroPort) {
912
- return new Promise((resolve2, reject) => {
1040
+ return new Promise((resolve3, reject) => {
913
1041
  const req = http.get(`http://localhost:${metroPort}/json/list`, { timeout: 2e3 }, (res) => {
914
1042
  let data = "";
915
1043
  res.on("data", (chunk) => {
@@ -917,16 +1045,16 @@ async function discoverTargets(metroPort) {
917
1045
  });
918
1046
  res.on("end", () => {
919
1047
  try {
920
- resolve2(JSON.parse(data));
1048
+ resolve3(JSON.parse(data));
921
1049
  } catch {
922
- resolve2([]);
1050
+ resolve3([]);
923
1051
  }
924
1052
  });
925
1053
  });
926
- req.on("error", () => resolve2([]));
1054
+ req.on("error", () => resolve3([]));
927
1055
  req.on("timeout", () => {
928
1056
  req.destroy();
929
- resolve2([]);
1057
+ resolve3([]);
930
1058
  });
931
1059
  });
932
1060
  }
@@ -953,16 +1081,16 @@ var CdpClient = class {
953
1081
  return [];
954
1082
  }
955
1083
  logger.debug(`Found CDP target: ${target.title} (${target.description})`);
956
- return new Promise((resolve2, reject) => {
1084
+ return new Promise((resolve3, reject) => {
957
1085
  const timer = setTimeout(() => {
958
1086
  this.cleanup();
959
- resolve2([]);
1087
+ resolve3([]);
960
1088
  }, timeoutMs);
961
1089
  try {
962
1090
  this.ws = new WebSocket(target.webSocketDebuggerUrl);
963
1091
  } catch {
964
1092
  clearTimeout(timer);
965
- resolve2([]);
1093
+ resolve3([]);
966
1094
  return;
967
1095
  }
968
1096
  this.ws.on("open", async () => {
@@ -970,10 +1098,10 @@ var CdpClient = class {
970
1098
  try {
971
1099
  const result = await this.evaluate(FIBER_WALK_SCRIPT);
972
1100
  clearTimeout(timer);
973
- resolve2(this.parseResult(result));
1101
+ resolve3(this.parseResult(result));
974
1102
  } catch {
975
1103
  clearTimeout(timer);
976
- resolve2([]);
1104
+ resolve3([]);
977
1105
  }
978
1106
  });
979
1107
  this.ws.on("message", (data) => {
@@ -991,7 +1119,7 @@ var CdpClient = class {
991
1119
  });
992
1120
  this.ws.on("error", () => {
993
1121
  clearTimeout(timer);
994
- resolve2([]);
1122
+ resolve3([]);
995
1123
  });
996
1124
  this.ws.on("close", () => {
997
1125
  this.connected = false;
@@ -1000,8 +1128,8 @@ var CdpClient = class {
1000
1128
  }
1001
1129
  evaluate(expression) {
1002
1130
  const id = ++this.msgId;
1003
- return new Promise((resolve2, reject) => {
1004
- this.pending.set(id, { resolve: resolve2, reject });
1131
+ return new Promise((resolve3, reject) => {
1132
+ this.pending.set(id, { resolve: resolve3, reject });
1005
1133
  this.ws.send(JSON.stringify({
1006
1134
  id,
1007
1135
  method: "Runtime.evaluate",
@@ -1168,13 +1296,56 @@ var StrategyCache = class {
1168
1296
  }
1169
1297
  };
1170
1298
 
1299
+ // src/ios-companion/hierarchy-parser.ts
1300
+ function cleanElementType(elementType) {
1301
+ const prefix = "XCUIElementType";
1302
+ if (elementType.startsWith(prefix)) {
1303
+ return elementType.slice(prefix.length);
1304
+ }
1305
+ return elementType.charAt(0).toUpperCase() + elementType.slice(1);
1306
+ }
1307
+ function isZeroSize(frame) {
1308
+ return frame.width === 0 && frame.height === 0;
1309
+ }
1310
+ function convertNode(node, idCounter) {
1311
+ const children = [];
1312
+ for (const child of node.children) {
1313
+ const converted = convertNode(child, idCounter);
1314
+ if (converted) children.push(converted);
1315
+ }
1316
+ if (children.length === 0 && isZeroSize(node.frame)) {
1317
+ return null;
1318
+ }
1319
+ return {
1320
+ id: String(idCounter.n++),
1321
+ name: cleanElementType(node.elementType),
1322
+ nativeName: node.elementType,
1323
+ testID: node.identifier || void 0,
1324
+ bounds: { x: node.frame.x, y: node.frame.y, width: node.frame.width, height: node.frame.height },
1325
+ text: node.label || void 0,
1326
+ children,
1327
+ inspectionTier: "basic"
1328
+ };
1329
+ }
1330
+ function parseCompanionHierarchy(nodes) {
1331
+ const idCounter = { n: 0 };
1332
+ const result = [];
1333
+ for (const node of nodes) {
1334
+ const converted = convertNode(node, idCounter);
1335
+ if (converted) result.push(converted);
1336
+ }
1337
+ return result;
1338
+ }
1339
+
1171
1340
  // src/inspect/tree-inspector.ts
1172
1341
  var TreeInspector = class {
1173
1342
  shell;
1174
1343
  fileCache;
1175
- constructor(shell, projectRoot) {
1344
+ companionLauncher;
1345
+ constructor(shell, projectRoot, companionLauncher) {
1176
1346
  this.shell = shell;
1177
1347
  this.fileCache = projectRoot ? new StrategyCache(projectRoot) : null;
1348
+ this.companionLauncher = companionLauncher ?? null;
1178
1349
  }
1179
1350
  invalidateCache(deviceId) {
1180
1351
  if (!this.fileCache) return;
@@ -1200,6 +1371,9 @@ var TreeInspector = class {
1200
1371
  if (device.platform === "android") {
1201
1372
  return { method: "uiautomator", reason: "Android native inspection" };
1202
1373
  }
1374
+ if (this.companionLauncher) {
1375
+ return { method: "xcuitest", reason: "iOS native inspection via XCUITest companion" };
1376
+ }
1203
1377
  return { method: "idb", reason: "iOS native inspection via idb" };
1204
1378
  }
1205
1379
  async inspect(device, options) {
@@ -1257,13 +1431,30 @@ var TreeInspector = class {
1257
1431
  logger.debug(`UIAutomator failed: ${err instanceof Error ? err.message : err}`);
1258
1432
  }
1259
1433
  }
1260
- if (strategy.method === "idb" || strategy.method === "cdp" && device.platform === "ios") {
1434
+ if (strategy.method === "xcuitest" || strategy.method === "cdp" && device.platform === "ios" && this.companionLauncher) {
1435
+ try {
1436
+ const client = await this.companionLauncher.ensureRunning(device.id, options.bundleId);
1437
+ const rawNodes = await client.hierarchy();
1438
+ const tree = parseCompanionHierarchy(rawNodes);
1439
+ logger.debug(`xcuitest: got ${tree.length} root nodes for ${device.name}`);
1440
+ return {
1441
+ ...base,
1442
+ strategy: { method: "xcuitest", reason: strategy.method === "cdp" ? "CDP fallback to native" : strategy.reason },
1443
+ tree,
1444
+ capabilities: { tree: "basic", sourceMapping: "none", styles: "none", protocol: "xcuitest" },
1445
+ hints
1446
+ };
1447
+ } catch (err) {
1448
+ logger.debug(`XCUITest companion failed: ${err instanceof Error ? err.message : err}`);
1449
+ }
1450
+ }
1451
+ if (strategy.method === "idb" || strategy.method === "cdp" && device.platform === "ios" || strategy.method === "xcuitest") {
1261
1452
  try {
1262
1453
  const tree = await dumpIosAccessibility(this.shell, device.id, options.timeoutMs);
1263
1454
  logger.debug(`idb: got ${tree.length} root nodes for ${device.name}`);
1264
1455
  return {
1265
1456
  ...base,
1266
- strategy: { method: "idb", reason: strategy.method === "cdp" ? "CDP fallback to native" : strategy.reason },
1457
+ strategy: { method: "idb", reason: strategy.method === "xcuitest" ? "XCUITest fallback to idb" : strategy.method === "cdp" ? "CDP fallback to native" : strategy.reason },
1267
1458
  tree,
1268
1459
  capabilities: { tree: "basic", sourceMapping: "none", styles: "none", protocol: "idb" },
1269
1460
  hints
@@ -2323,12 +2514,12 @@ async function copyToClipboard(text) {
2323
2514
  logger.debug(`Clipboard not supported on ${process.platform}`);
2324
2515
  return;
2325
2516
  }
2326
- return new Promise((resolve2) => {
2517
+ return new Promise((resolve3) => {
2327
2518
  const proc = exec(cmd, (err) => {
2328
2519
  if (err) {
2329
2520
  logger.debug(`Clipboard copy failed: ${err.message}`);
2330
2521
  }
2331
- resolve2();
2522
+ resolve3();
2332
2523
  });
2333
2524
  proc.stdin?.write(text);
2334
2525
  proc.stdin?.end();
@@ -2568,35 +2759,35 @@ var AndroidBackend = class {
2568
2759
 
2569
2760
  // src/interact/ios.ts
2570
2761
  var IosBackend = class {
2571
- constructor(shell) {
2762
+ constructor(shell, companion) {
2572
2763
  this.shell = shell;
2764
+ this.companion = companion;
2573
2765
  }
2574
- async tap(device, point) {
2575
- await this.simctlIo(device, ["tap", String(point.x), String(point.y)]);
2766
+ async tap(_device, point) {
2767
+ await this.companion.tap(point.x, point.y);
2576
2768
  }
2577
- async longPress(device, point, _durationMs) {
2578
- await this.simctlIo(device, ["longpress", String(point.x), String(point.y)]);
2769
+ async longPress(_device, point, durationMs) {
2770
+ await this.companion.longPress(point.x, point.y, durationMs);
2579
2771
  }
2580
- async swipe(device, from, to, _durationMs) {
2581
- await this.simctlIo(device, ["swipe", String(from.x), String(from.y), String(to.x), String(to.y)]);
2772
+ async swipe(_device, from, to, durationMs) {
2773
+ await this.companion.swipe(from.x, from.y, to.x, to.y, durationMs);
2582
2774
  }
2583
- async type(device, text) {
2584
- await this.simctlIo(device, ["type", text]);
2775
+ async type(_device, text) {
2776
+ await this.companion.type(text);
2585
2777
  }
2586
- async keyEvent(device, key) {
2587
- await this.simctlIo(device, ["sendkey", key]);
2778
+ async keyEvent(_device, key) {
2779
+ await this.companion.keyEvent(key);
2588
2780
  }
2589
2781
  async openUrl(device, url) {
2590
2782
  await this.shell.exec("xcrun", ["simctl", "openurl", device.id, url]);
2591
2783
  }
2592
- async simctlIo(device, args) {
2593
- await this.shell.exec("xcrun", ["simctl", "io", device.id, ...args]);
2594
- }
2595
2784
  };
2596
2785
 
2597
2786
  // src/interact/backend.ts
2598
- function createBackend(shell, platform) {
2599
- return platform === "android" ? new AndroidBackend(shell) : new IosBackend(shell);
2787
+ function createBackend(shell, platform, companion) {
2788
+ if (platform === "android") return new AndroidBackend(shell);
2789
+ if (!companion) throw new Error("iOS interactions require the XCUITest companion. Run on a simulator with Xcode installed.");
2790
+ return new IosBackend(shell, companion);
2600
2791
  }
2601
2792
 
2602
2793
  // src/interact/resolver.ts
@@ -2608,6 +2799,17 @@ function resolveTarget(tree, query) {
2608
2799
  if (byName) return centerOf(byName, `name:${query}`);
2609
2800
  const byText = nodes.find((n) => n.text === query && hasSize(n));
2610
2801
  if (byText) return centerOf(byText, `text:${query}`);
2802
+ const lowerQuery = query.toLowerCase();
2803
+ const startsWithMatches = nodes.filter(
2804
+ (n) => n.text && hasSize(n) && (n.text.toLowerCase().startsWith(lowerQuery + ",") || n.text.toLowerCase().startsWith(lowerQuery + " "))
2805
+ );
2806
+ const byTextStartsWith = smallest(startsWithMatches);
2807
+ if (byTextStartsWith) return centerOf(byTextStartsWith, `text~:${query}`);
2808
+ const containsMatches = nodes.filter(
2809
+ (n) => n.text && hasSize(n) && n.text.toLowerCase().includes(lowerQuery)
2810
+ );
2811
+ const byTextContains = smallest(containsMatches);
2812
+ if (byTextContains) return centerOf(byTextContains, `text~:${query}`);
2611
2813
  return null;
2612
2814
  }
2613
2815
  function flattenTree(nodes) {
@@ -2623,6 +2825,13 @@ function flattenTree(nodes) {
2623
2825
  function hasSize(node) {
2624
2826
  return node.bounds.width > 0 && node.bounds.height > 0;
2625
2827
  }
2828
+ function area(node) {
2829
+ return node.bounds.width * node.bounds.height;
2830
+ }
2831
+ function smallest(nodes) {
2832
+ if (nodes.length === 0) return void 0;
2833
+ return nodes.reduce((best, n) => area(n) < area(best) ? n : best);
2834
+ }
2626
2835
  function centerOf(node, resolvedFrom) {
2627
2836
  return {
2628
2837
  x: Math.round(node.bounds.x + node.bounds.width / 2),
@@ -2672,13 +2881,15 @@ var GestureExecutor = class {
2672
2881
  return { success: false, action: "longPress", durationMs: Date.now() - start, error: String(e) };
2673
2882
  }
2674
2883
  }
2675
- async swipe(device, direction, distance = 600, durationMs = 300) {
2884
+ async swipe(device, direction, distance, durationMs = 300) {
2676
2885
  const start = Date.now();
2677
2886
  try {
2678
- const cx = device.screenSize?.width ? Math.round(device.screenSize.width / 2) : 540;
2679
- const cy = device.screenSize?.height ? Math.round(device.screenSize.height / 2) : 960;
2887
+ const isIos = device.platform === "ios";
2888
+ const cx = device.screenSize?.width ? Math.round(device.screenSize.width / 2) : isIos ? 197 : 540;
2889
+ const cy = device.screenSize?.height ? Math.round(device.screenSize.height / 2) : isIos ? 426 : 960;
2890
+ const actualDistance = distance ?? (isIos ? 300 : 600);
2680
2891
  const from = { x: cx, y: cy };
2681
- const half = Math.round(distance / 2);
2892
+ const half = Math.round(actualDistance / 2);
2682
2893
  const to = direction === "up" ? { x: cx, y: cy - half } : direction === "down" ? { x: cx, y: cy + half } : direction === "left" ? { x: cx - half, y: cy } : { x: cx + half, y: cy };
2683
2894
  await this.backend.swipe(device, from, to, durationMs);
2684
2895
  return { success: true, action: "swipe", durationMs: Date.now() - start };
@@ -2721,6 +2932,470 @@ var GestureExecutor = class {
2721
2932
  }
2722
2933
  };
2723
2934
 
2935
+ // src/ios-companion/launcher.ts
2936
+ import { spawn } from "child_process";
2937
+ import { resolve, dirname as dirname4, join as join5 } from "path";
2938
+ import { fileURLToPath } from "url";
2939
+ import { existsSync as existsSync6, readFileSync as readFileSync8, writeFileSync as writeFileSync4 } from "fs";
2940
+ import { tmpdir as tmpdir3 } from "os";
2941
+
2942
+ // src/ios-companion/client.ts
2943
+ import http2 from "http";
2944
+ var CompanionClient = class {
2945
+ constructor(port) {
2946
+ this.port = port;
2947
+ }
2948
+ async status() {
2949
+ return this.request("GET", "/status");
2950
+ }
2951
+ async configure(bundleId) {
2952
+ await this.request("POST", "/configure", { bundleId }, 3e4);
2953
+ }
2954
+ async tap(x, y) {
2955
+ await this.request("POST", "/tap", { x, y });
2956
+ }
2957
+ async longPress(x, y, durationMs) {
2958
+ await this.request("POST", "/longPress", { x, y, durationMs });
2959
+ }
2960
+ async swipe(fromX, fromY, toX, toY, durationMs) {
2961
+ await this.request("POST", "/swipe", { fromX, fromY, toX, toY, durationMs });
2962
+ }
2963
+ async type(text) {
2964
+ await this.request("POST", "/type", { text });
2965
+ }
2966
+ async keyEvent(key) {
2967
+ await this.request("POST", "/keyEvent", { key });
2968
+ }
2969
+ async find(text) {
2970
+ try {
2971
+ const res = await this.request("POST", "/find", { text }, 3e4);
2972
+ const frame = res.frame;
2973
+ return frame ?? null;
2974
+ } catch {
2975
+ return null;
2976
+ }
2977
+ }
2978
+ async hierarchy() {
2979
+ return this.requestRaw("GET", "/hierarchy", void 0, 3e4);
2980
+ }
2981
+ requestRaw(method, path6, body, timeoutMs = 1e4) {
2982
+ return new Promise((resolve3, reject) => {
2983
+ const payload = body !== void 0 ? JSON.stringify(body) : void 0;
2984
+ const req = http2.request(
2985
+ {
2986
+ hostname: "localhost",
2987
+ port: this.port,
2988
+ path: path6,
2989
+ method,
2990
+ timeout: timeoutMs,
2991
+ headers: payload ? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) } : void 0
2992
+ },
2993
+ (res) => {
2994
+ let data = "";
2995
+ res.on("data", (chunk) => {
2996
+ data += chunk;
2997
+ });
2998
+ res.on("end", () => {
2999
+ if (res.statusCode !== 200) {
3000
+ reject(new Error(`Companion ${method} ${path6} returned ${res.statusCode}: ${data}`));
3001
+ return;
3002
+ }
3003
+ try {
3004
+ resolve3(JSON.parse(data));
3005
+ } catch {
3006
+ reject(new Error(`Companion ${method} ${path6} returned invalid JSON: ${data}`));
3007
+ }
3008
+ });
3009
+ }
3010
+ );
3011
+ req.on("error", (err) => reject(new Error(`Companion ${method} ${path6}: ${err.message}`)));
3012
+ req.on("timeout", () => {
3013
+ req.destroy();
3014
+ reject(new Error(`Companion ${method} ${path6} timed out after ${timeoutMs}ms`));
3015
+ });
3016
+ if (payload) req.write(payload);
3017
+ req.end();
3018
+ });
3019
+ }
3020
+ request(method, path6, body, timeoutMs = 3e4) {
3021
+ return new Promise((resolve3, reject) => {
3022
+ const payload = body !== void 0 ? JSON.stringify(body) : void 0;
3023
+ const req = http2.request(
3024
+ {
3025
+ hostname: "localhost",
3026
+ port: this.port,
3027
+ path: path6,
3028
+ method,
3029
+ timeout: timeoutMs,
3030
+ headers: payload ? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) } : void 0
3031
+ },
3032
+ (res) => {
3033
+ let data = "";
3034
+ res.on("data", (chunk) => {
3035
+ data += chunk;
3036
+ });
3037
+ res.on("end", () => {
3038
+ if (res.statusCode !== 200) {
3039
+ reject(new Error(`Companion ${method} ${path6} returned ${res.statusCode}: ${data}`));
3040
+ return;
3041
+ }
3042
+ let parsed;
3043
+ try {
3044
+ parsed = JSON.parse(data);
3045
+ } catch {
3046
+ reject(new Error(`Companion ${method} ${path6} returned invalid JSON: ${data}`));
3047
+ return;
3048
+ }
3049
+ if (parsed.success === false) {
3050
+ reject(new Error(`Companion ${method} ${path6} failed: ${parsed.error ?? "unknown error"}`));
3051
+ return;
3052
+ }
3053
+ resolve3(parsed);
3054
+ });
3055
+ }
3056
+ );
3057
+ req.on("error", (err) => reject(new Error(`Companion ${method} ${path6}: ${err.message}`)));
3058
+ req.on("timeout", () => {
3059
+ req.destroy();
3060
+ reject(new Error(`Companion ${method} ${path6} timed out after ${timeoutMs}ms`));
3061
+ });
3062
+ if (payload) req.write(payload);
3063
+ req.end();
3064
+ });
3065
+ }
3066
+ };
3067
+
3068
+ // src/ios-companion/xctestrun.ts
3069
+ function rewriteXctestrun(content, prebuiltDir, port) {
3070
+ let result = content;
3071
+ const rootMatch = result.match(/((?:__TESTROOT__|[^<\s]+?))\/Debug-/);
3072
+ if (rootMatch) {
3073
+ const originalRoot = rootMatch[1];
3074
+ const escaped = originalRoot.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3075
+ result = result.replace(new RegExp(escaped, "g"), prebuiltDir);
3076
+ }
3077
+ const envTag = "<key>TestingEnvironmentVariables</key>";
3078
+ const envIdx = result.indexOf(envTag);
3079
+ if (envIdx !== -1) {
3080
+ const dictOpenIdx = result.indexOf("<dict>", envIdx);
3081
+ if (dictOpenIdx !== -1) {
3082
+ const insertPos = dictOpenIdx + "<dict>".length;
3083
+ const portEntry = `
3084
+ <key>DRIFTX_PORT</key>
3085
+ <string>${port}</string>`;
3086
+ result = result.slice(0, insertPos) + portEntry + result.slice(insertPos);
3087
+ }
3088
+ } else {
3089
+ const testConfigKey = "<key>DriftxCompanionUITests</key>";
3090
+ const configIdx = result.indexOf(testConfigKey);
3091
+ if (configIdx !== -1) {
3092
+ const closingDict = result.indexOf("</dict>", configIdx);
3093
+ if (closingDict !== -1) {
3094
+ const envBlock = ` <key>TestingEnvironmentVariables</key>
3095
+ <dict>
3096
+ <key>DRIFTX_PORT</key>
3097
+ <string>${port}</string>
3098
+ </dict>
3099
+ `;
3100
+ result = result.slice(0, closingDict) + envBlock + result.slice(closingDict);
3101
+ }
3102
+ }
3103
+ }
3104
+ return result;
3105
+ }
3106
+
3107
+ // src/ios-companion/launcher.ts
3108
+ var PKG_ROOT = resolve(dirname4(fileURLToPath(import.meta.url)), "..");
3109
+ var PREBUILT_DIR = join5(PKG_ROOT, "ios-companion", "prebuilt");
3110
+ var SOURCE_DIR = join5(PKG_ROOT, "ios-companion");
3111
+ var POLL_INTERVAL_MS = 500;
3112
+ var PREBUILT_LAUNCH_TIMEOUT_MS = 3e4;
3113
+ var SOURCE_LAUNCH_TIMEOUT_MS = 12e4;
3114
+ function resolveLaunchStrategy(input) {
3115
+ if (input.localXcodeMajor === null && !input.prebuiltExists) {
3116
+ return { type: "error", reason: "Xcode is required for iOS simulator support" };
3117
+ }
3118
+ if (!input.prebuiltExists) {
3119
+ return { type: "source", sourceDir: input.sourceDir, reason: "prebuilt not found" };
3120
+ }
3121
+ if (input.buildInfoXcodeMajor === null) {
3122
+ return { type: "source", sourceDir: input.sourceDir, reason: "build-info.json missing or invalid" };
3123
+ }
3124
+ if (input.localXcodeMajor !== null && input.buildInfoXcodeMajor !== input.localXcodeMajor) {
3125
+ return {
3126
+ type: "source",
3127
+ sourceDir: input.sourceDir,
3128
+ reason: `Xcode major version mismatch: prebuilt=${input.buildInfoXcodeMajor}, local=${input.localXcodeMajor}`
3129
+ };
3130
+ }
3131
+ return { type: "prebuilt", prebuiltDir: input.prebuiltDir };
3132
+ }
3133
+ function readBuildInfo(prebuiltDir) {
3134
+ try {
3135
+ const raw = readFileSync8(join5(prebuiltDir, "build-info.json"), "utf-8");
3136
+ return JSON.parse(raw);
3137
+ } catch {
3138
+ return null;
3139
+ }
3140
+ }
3141
+ async function getLocalXcodeMajor() {
3142
+ try {
3143
+ const { execSync } = await import("child_process");
3144
+ const output = execSync("xcodebuild -version", { encoding: "utf-8", timeout: 5e3 });
3145
+ const match = output.match(/Xcode\s+(\d+)/);
3146
+ return match ? parseInt(match[1], 10) : null;
3147
+ } catch {
3148
+ return null;
3149
+ }
3150
+ }
3151
+ var CompanionLauncher = class {
3152
+ processes = /* @__PURE__ */ new Map();
3153
+ clients = /* @__PURE__ */ new Map();
3154
+ portIndex = 0;
3155
+ basePort;
3156
+ constructor(basePort = 8300) {
3157
+ this.basePort = basePort;
3158
+ const cleanup = () => this.stop();
3159
+ process.on("exit", cleanup);
3160
+ process.on("SIGINT", cleanup);
3161
+ process.on("SIGTERM", cleanup);
3162
+ }
3163
+ async ensureRunning(deviceId, bundleId) {
3164
+ const existing = this.clients.get(deviceId);
3165
+ if (existing) {
3166
+ try {
3167
+ await existing.status();
3168
+ return existing;
3169
+ } catch {
3170
+ this.clients.delete(deviceId);
3171
+ this.killProcess(deviceId);
3172
+ }
3173
+ }
3174
+ const port = this.getPort(deviceId);
3175
+ const client = new CompanionClient(port);
3176
+ try {
3177
+ await client.status();
3178
+ this.clients.set(deviceId, client);
3179
+ return client;
3180
+ } catch {
3181
+ }
3182
+ const logger = getLogger();
3183
+ const buildInfo = readBuildInfo(PREBUILT_DIR);
3184
+ const localXcodeMajor = await getLocalXcodeMajor();
3185
+ const strategy = resolveLaunchStrategy({
3186
+ prebuiltDir: PREBUILT_DIR,
3187
+ prebuiltExists: existsSync6(join5(PREBUILT_DIR, "DriftxCompanionUITests.xctestrun")),
3188
+ buildInfoXcodeMajor: buildInfo?.xcodeMajor ?? null,
3189
+ localXcodeMajor,
3190
+ sourceDir: SOURCE_DIR,
3191
+ sourceExists: existsSync6(join5(SOURCE_DIR, "DriftxCompanion.xcodeproj"))
3192
+ });
3193
+ logger.debug(`Companion launch strategy: ${strategy.type}${strategy.reason ? ` (${strategy.reason})` : ""}`);
3194
+ if (strategy.type === "error") {
3195
+ throw new Error(strategy.reason ?? "Cannot launch iOS companion");
3196
+ }
3197
+ let launched = false;
3198
+ if (strategy.type === "prebuilt") {
3199
+ try {
3200
+ this.launchPrebuilt(deviceId, port);
3201
+ await this.waitForReadyWithEarlyExit(deviceId, client, PREBUILT_LAUNCH_TIMEOUT_MS);
3202
+ launched = true;
3203
+ } catch (err) {
3204
+ logger.debug(`Pre-built launch failed, falling back to source: ${err}`);
3205
+ this.killProcess(deviceId);
3206
+ }
3207
+ }
3208
+ if (!launched) {
3209
+ logger.debug(`Launching companion from source for device ${deviceId} on port ${port}`);
3210
+ this.launchSource(deviceId, port);
3211
+ await this.waitForReadyWithEarlyExit(deviceId, client, SOURCE_LAUNCH_TIMEOUT_MS);
3212
+ }
3213
+ if (bundleId) {
3214
+ logger.debug(`Configuring companion for bundle ${bundleId}`);
3215
+ await client.configure(bundleId);
3216
+ }
3217
+ this.clients.set(deviceId, client);
3218
+ return client;
3219
+ }
3220
+ stop(deviceId) {
3221
+ if (deviceId) {
3222
+ this.killProcess(deviceId);
3223
+ this.clients.delete(deviceId);
3224
+ return;
3225
+ }
3226
+ for (const id of this.processes.keys()) {
3227
+ this.killProcess(id);
3228
+ }
3229
+ this.clients.clear();
3230
+ }
3231
+ getPort(deviceId) {
3232
+ const existing = [...this.processes.keys()].indexOf(deviceId);
3233
+ if (existing >= 0) return this.basePort + existing;
3234
+ return this.basePort + this.portIndex++;
3235
+ }
3236
+ launchPrebuilt(deviceId, port) {
3237
+ const xctestrunPath = join5(PREBUILT_DIR, "DriftxCompanionUITests.xctestrun");
3238
+ const original = readFileSync8(xctestrunPath, "utf-8");
3239
+ const rewritten = rewriteXctestrun(original, PREBUILT_DIR, port);
3240
+ const tmpXctestrun = join5(tmpdir3(), `driftx-companion-${deviceId}-${port}.xctestrun`);
3241
+ writeFileSync4(tmpXctestrun, rewritten);
3242
+ const proc = spawn("xcodebuild", [
3243
+ "test-without-building",
3244
+ "-xctestrun",
3245
+ tmpXctestrun,
3246
+ "-destination",
3247
+ `platform=iOS Simulator,id=${deviceId}`,
3248
+ "-only-testing:DriftxCompanionUITests/DriftxCompanionUITests/testCompanionServer"
3249
+ ], {
3250
+ stdio: ["ignore", "pipe", "pipe"]
3251
+ });
3252
+ const logger = getLogger();
3253
+ proc.stdout?.on("data", (data) => {
3254
+ logger.debug(`[companion:${deviceId}] ${data.toString().trimEnd()}`);
3255
+ });
3256
+ proc.stderr?.on("data", (data) => {
3257
+ logger.debug(`[companion:${deviceId}] ${data.toString().trimEnd()}`);
3258
+ });
3259
+ proc.on("exit", (code) => {
3260
+ logger.debug(`Companion (prebuilt) for ${deviceId} exited with code ${code}`);
3261
+ this.processes.delete(deviceId);
3262
+ });
3263
+ this.processes.set(deviceId, proc);
3264
+ }
3265
+ launchSource(deviceId, port) {
3266
+ const env = { ...process.env, DRIFTX_PORT: String(port) };
3267
+ const proc = spawn("xcodebuild", [
3268
+ "test",
3269
+ "-project",
3270
+ resolve(SOURCE_DIR, "DriftxCompanion.xcodeproj"),
3271
+ "-scheme",
3272
+ "DriftxCompanionUITests",
3273
+ "-destination",
3274
+ `platform=iOS Simulator,id=${deviceId}`,
3275
+ "-only-testing:DriftxCompanionUITests/DriftxCompanionUITests/testCompanionServer"
3276
+ ], {
3277
+ env,
3278
+ stdio: ["ignore", "pipe", "pipe"]
3279
+ });
3280
+ const logger = getLogger();
3281
+ proc.stdout?.on("data", (data) => {
3282
+ logger.debug(`[companion:${deviceId}] ${data.toString().trimEnd()}`);
3283
+ });
3284
+ proc.stderr?.on("data", (data) => {
3285
+ logger.debug(`[companion:${deviceId}] ${data.toString().trimEnd()}`);
3286
+ });
3287
+ proc.on("exit", (code) => {
3288
+ logger.debug(`Companion (source) for ${deviceId} exited with code ${code}`);
3289
+ this.processes.delete(deviceId);
3290
+ });
3291
+ this.processes.set(deviceId, proc);
3292
+ }
3293
+ async waitForReadyWithEarlyExit(deviceId, client, timeoutMs) {
3294
+ const proc = this.processes.get(deviceId);
3295
+ let earlyExitCode = null;
3296
+ const exitListener = (code) => {
3297
+ earlyExitCode = code;
3298
+ };
3299
+ proc?.on("exit", exitListener);
3300
+ const deadline = Date.now() + timeoutMs;
3301
+ try {
3302
+ while (Date.now() < deadline) {
3303
+ if (earlyExitCode !== null) {
3304
+ throw new Error(`xcodebuild exited early with code ${earlyExitCode}`);
3305
+ }
3306
+ try {
3307
+ await client.status();
3308
+ return;
3309
+ } catch {
3310
+ await this.sleep(POLL_INTERVAL_MS);
3311
+ }
3312
+ }
3313
+ throw new Error(`Companion server did not become ready within ${timeoutMs}ms`);
3314
+ } finally {
3315
+ proc?.removeListener("exit", exitListener);
3316
+ }
3317
+ }
3318
+ killProcess(deviceId) {
3319
+ const proc = this.processes.get(deviceId);
3320
+ if (proc) {
3321
+ try {
3322
+ proc.kill("SIGTERM");
3323
+ } catch {
3324
+ }
3325
+ this.processes.delete(deviceId);
3326
+ }
3327
+ }
3328
+ sleep(ms) {
3329
+ return new Promise((r) => setTimeout(r, ms));
3330
+ }
3331
+ };
3332
+
3333
+ // src/update-notifier.ts
3334
+ import { readFileSync as readFileSync9, writeFileSync as writeFileSync5, mkdirSync as mkdirSync4 } from "fs";
3335
+ import { join as join6 } from "path";
3336
+ import { homedir } from "os";
3337
+ var CACHE_DIR = join6(homedir(), ".driftx");
3338
+ var CACHE_FILE = join6(CACHE_DIR, "update-check.json");
3339
+ var CHECK_INTERVAL_MS = 24 * 60 * 60 * 1e3;
3340
+ function shouldCheck(cache) {
3341
+ if (!cache) return true;
3342
+ return Date.now() - cache.lastCheck > CHECK_INTERVAL_MS;
3343
+ }
3344
+ function formatUpdateMessage(current, latest) {
3345
+ if (compareVersions(current, latest) >= 0) return null;
3346
+ return ` Update available: ${current} \u2192 ${latest}
3347
+ Run: npm install -g driftx`;
3348
+ }
3349
+ function compareVersions(a, b) {
3350
+ const pa = a.split(".").map(Number);
3351
+ const pb = b.split(".").map(Number);
3352
+ for (let i = 0; i < 3; i++) {
3353
+ const diff = (pa[i] || 0) - (pb[i] || 0);
3354
+ if (diff !== 0) return diff;
3355
+ }
3356
+ return 0;
3357
+ }
3358
+ function readCache() {
3359
+ try {
3360
+ return JSON.parse(readFileSync9(CACHE_FILE, "utf-8"));
3361
+ } catch {
3362
+ return null;
3363
+ }
3364
+ }
3365
+ function writeCache(cache) {
3366
+ try {
3367
+ mkdirSync4(CACHE_DIR, { recursive: true });
3368
+ writeFileSync5(CACHE_FILE, JSON.stringify(cache));
3369
+ } catch {
3370
+ }
3371
+ }
3372
+ async function fetchLatestVersion() {
3373
+ try {
3374
+ const controller = new AbortController();
3375
+ const timer = setTimeout(() => controller.abort(), 3e3);
3376
+ const res = await fetch("https://registry.npmjs.org/driftx/latest", { signal: controller.signal });
3377
+ clearTimeout(timer);
3378
+ if (!res.ok) return null;
3379
+ const data = await res.json();
3380
+ return data.version ?? null;
3381
+ } catch {
3382
+ return null;
3383
+ }
3384
+ }
3385
+ async function checkForUpdate(currentVersion) {
3386
+ const cache = readCache();
3387
+ if (!shouldCheck(cache)) {
3388
+ return cache ? formatUpdateMessage(currentVersion, cache.latestVersion) : null;
3389
+ }
3390
+ fetchLatestVersion().then((latest) => {
3391
+ if (latest) {
3392
+ writeCache({ lastCheck: Date.now(), latestVersion: latest });
3393
+ }
3394
+ }).catch(() => {
3395
+ });
3396
+ return cache ? formatUpdateMessage(currentVersion, cache.latestVersion) : null;
3397
+ }
3398
+
2724
3399
  // src/cli.ts
2725
3400
  var require2 = createRequire(import.meta.url);
2726
3401
  var pkg = require2("../package.json");
@@ -2732,8 +3407,27 @@ function getFormatterContext(opts) {
2732
3407
  };
2733
3408
  }
2734
3409
  function createProgram() {
3410
+ let companionLauncher;
3411
+ let resolvedBundleId;
3412
+ function getCompanionLauncher(port) {
3413
+ if (!companionLauncher) {
3414
+ companionLauncher = new CompanionLauncher(port);
3415
+ }
3416
+ return companionLauncher;
3417
+ }
3418
+ async function getBundleId(program2, metroPort, deviceName) {
3419
+ const explicit = program2.opts().bundleId;
3420
+ if (explicit) return explicit;
3421
+ if (resolvedBundleId) return resolvedBundleId;
3422
+ const detected = await detectBundleId(metroPort, deviceName);
3423
+ if (detected) {
3424
+ resolvedBundleId = detected;
3425
+ getLogger().debug(`Auto-detected bundle ID: ${detected}`);
3426
+ }
3427
+ return resolvedBundleId;
3428
+ }
2735
3429
  const program = new Command();
2736
- program.name("driftx").description("Visual diff tool for React Native and Android development").version(pkg.version).option("--verbose", "enable debug logging").option("--quiet", "suppress all output except errors").option("--format <type>", "output format: terminal, markdown, json", "terminal").option("--copy", "copy output to clipboard");
3430
+ program.name("driftx").description("Visual diff tool for React Native and Android development").version(pkg.version).option("--verbose", "enable debug logging").option("--quiet", "suppress all output except errors").option("--format <type>", "output format: terminal, markdown, json", "terminal").option("--copy", "copy output to clipboard").option("--bundle-id <id>", "iOS app bundle identifier for companion");
2737
3431
  program.hook("preAction", (_thisCommand, actionCommand) => {
2738
3432
  const opts = actionCommand.optsWithGlobals();
2739
3433
  const level = opts.verbose ? "debug" : opts.quiet ? "silent" : "info";
@@ -2752,13 +3446,13 @@ function createProgram() {
2752
3446
  const files = readdirSync2(cwd);
2753
3447
  let packageJson;
2754
3448
  try {
2755
- packageJson = JSON.parse(readFileSync8(join5(cwd, "package.json"), "utf-8"));
3449
+ packageJson = JSON.parse(readFileSync10(join7(cwd, "package.json"), "utf-8"));
2756
3450
  } catch {
2757
3451
  }
2758
3452
  const framework = detectFramework(files, packageJson);
2759
3453
  const config = generateConfig(framework);
2760
- const configPath = join5(cwd, ".driftxrc.json");
2761
- writeFileSync4(configPath, JSON.stringify(config, null, 2) + "\n");
3454
+ const configPath = join7(cwd, ".driftxrc.json");
3455
+ writeFileSync6(configPath, JSON.stringify(config, null, 2) + "\n");
2762
3456
  console.log(`Created ${configPath} (framework: ${framework})`);
2763
3457
  });
2764
3458
  program.command("devices").description("List connected devices and simulators").action(async function() {
@@ -2814,13 +3508,15 @@ function createProgram() {
2814
3508
  } else {
2815
3509
  device = await pickDevice(booted);
2816
3510
  }
2817
- const inspector = new TreeInspector(shell, process.cwd());
3511
+ const launcher = device.platform === "ios" ? getCompanionLauncher(config.companionPort) : void 0;
3512
+ const inspector = new TreeInspector(shell, process.cwd(), launcher);
3513
+ const globalOpts = this.optsWithGlobals();
2818
3514
  const result = await inspector.inspect(device, {
2819
3515
  metroPort: config.metroPort,
2820
3516
  devToolsPort: config.devToolsPort,
2821
- timeoutMs: config.timeouts.treeInspectionMs
3517
+ timeoutMs: config.timeouts.treeInspectionMs,
3518
+ bundleId: await getBundleId(program, config.metroPort, device.name)
2822
3519
  });
2823
- const globalOpts = this.optsWithGlobals();
2824
3520
  if (opts.json) globalOpts.format = "json";
2825
3521
  const ctx = getFormatterContext(globalOpts);
2826
3522
  await formatOutput(inspectFormatter, result, ctx);
@@ -2839,20 +3535,46 @@ function createProgram() {
2839
3535
  } else {
2840
3536
  device = await pickDevice(booted);
2841
3537
  }
2842
- const backend = createBackend(shell, device.platform);
3538
+ const launcher = device.platform === "ios" ? getCompanionLauncher(config.companionPort) : void 0;
3539
+ const companion = launcher ? await launcher.ensureRunning(device.id, await getBundleId(program, config.metroPort, device.name)) : void 0;
3540
+ const backend = createBackend(shell, device.platform, companion);
2843
3541
  const executor = new GestureExecutor(backend);
2844
3542
  let result;
2845
3543
  if (opts.xy) {
2846
3544
  const [x, y] = target.split(",").map(Number);
2847
3545
  result = await executor.tapXY(device, x, y);
2848
3546
  } else {
2849
- const inspector = new TreeInspector(shell, process.cwd());
3547
+ const inspector = new TreeInspector(shell, process.cwd(), launcher);
2850
3548
  const inspectResult = await inspector.inspect(device, {
2851
3549
  metroPort: config.metroPort,
2852
3550
  devToolsPort: config.devToolsPort,
2853
- timeoutMs: config.timeouts.treeInspectionMs
3551
+ timeoutMs: config.timeouts.treeInspectionMs,
3552
+ bundleId: await getBundleId(program, config.metroPort, device.name)
2854
3553
  });
2855
3554
  result = await executor.tap(device, inspectResult.tree, target);
3555
+ if (!result.success && result.error?.includes("Target not found") && companion) {
3556
+ const rawNodes = await companion.hierarchy();
3557
+ const companionTree = parseCompanionHierarchy(rawNodes);
3558
+ result = await executor.tap(device, companionTree, target);
3559
+ }
3560
+ if (!result.success && result.error?.includes("Target not found") && companion) {
3561
+ const frame = await companion.find(target);
3562
+ if (frame) {
3563
+ const cx = Math.round(frame.x + frame.width / 2);
3564
+ const cy = Math.round(frame.y + frame.height / 2);
3565
+ result = await executor.tapXY(device, cx, cy);
3566
+ result.target = { x: cx, y: cy, resolvedFrom: `xcuitest-find:${target}` };
3567
+ }
3568
+ }
3569
+ if (!result.success && result.error?.includes("Target not found")) {
3570
+ const bounds = await measureElementByText(config.metroPort, target, device.name);
3571
+ if (bounds) {
3572
+ const cx = Math.round(bounds.x + bounds.width / 2);
3573
+ const cy = Math.round(bounds.y + bounds.height / 2);
3574
+ result = await executor.tapXY(device, cx, cy);
3575
+ result.target = { x: cx, y: cy, resolvedFrom: `cdp-measure:${target}` };
3576
+ }
3577
+ }
2856
3578
  }
2857
3579
  console.log(JSON.stringify(result, null, 2));
2858
3580
  });
@@ -2870,19 +3592,38 @@ function createProgram() {
2870
3592
  } else {
2871
3593
  device = await pickDevice(booted);
2872
3594
  }
2873
- const inspector = new TreeInspector(shell, process.cwd());
3595
+ const launcher = device.platform === "ios" ? getCompanionLauncher(config.companionPort) : void 0;
3596
+ const companion = launcher ? await launcher.ensureRunning(device.id, await getBundleId(program, config.metroPort, device.name)) : void 0;
3597
+ const inspector = new TreeInspector(shell, process.cwd(), launcher);
2874
3598
  const inspectResult = await inspector.inspect(device, {
2875
3599
  metroPort: config.metroPort,
2876
3600
  devToolsPort: config.devToolsPort,
2877
- timeoutMs: config.timeouts.treeInspectionMs
3601
+ timeoutMs: config.timeouts.treeInspectionMs,
3602
+ bundleId: await getBundleId(program, config.metroPort, device.name)
2878
3603
  });
2879
- const backend = createBackend(shell, device.platform);
3604
+ const backend = createBackend(shell, device.platform, companion);
2880
3605
  const executor = new GestureExecutor(backend);
2881
- const result = await executor.typeInto(device, inspectResult.tree, target, text);
3606
+ let result = await executor.typeInto(device, inspectResult.tree, target, text);
3607
+ if (!result.success && result.error?.includes("Target not found") && companion) {
3608
+ const rawNodes = await companion.hierarchy();
3609
+ const companionTree = parseCompanionHierarchy(rawNodes);
3610
+ result = await executor.typeInto(device, companionTree, target, text);
3611
+ }
3612
+ if (!result.success && result.error?.includes("Target not found") && companion) {
3613
+ const frame = await companion.find(target);
3614
+ if (frame) {
3615
+ const cx = Math.round(frame.x + frame.width / 2);
3616
+ const cy = Math.round(frame.y + frame.height / 2);
3617
+ await backend.tap(device, { x: cx, y: cy });
3618
+ await backend.type(device, text);
3619
+ result = { success: true, action: "typeInto", target: { x: cx, y: cy, resolvedFrom: `xcuitest-find:${target}` }, durationMs: Date.now() };
3620
+ }
3621
+ }
2882
3622
  console.log(JSON.stringify(result, null, 2));
2883
3623
  });
2884
- program.command("swipe <direction>").description("Swipe up, down, left, or right").option("-d, --device <id>", "device ID or name").action(async (direction, opts) => {
3624
+ program.command("swipe <direction>").description("Swipe up, down, left, or right").option("-d, --device <id>", "device ID or name").option("--distance <n>", "swipe distance in points", parseInt).action(async (direction, opts) => {
2885
3625
  const shell = new RealShell();
3626
+ const config = await loadConfig();
2886
3627
  const discovery = new DeviceDiscovery(shell);
2887
3628
  const devices = await discovery.list();
2888
3629
  const booted = devices.filter((d) => d.state === "booted");
@@ -2894,13 +3635,16 @@ function createProgram() {
2894
3635
  } else {
2895
3636
  device = await pickDevice(booted);
2896
3637
  }
2897
- const backend = createBackend(shell, device.platform);
3638
+ const launcher = device.platform === "ios" ? getCompanionLauncher(config.companionPort) : void 0;
3639
+ const companion = launcher ? await launcher.ensureRunning(device.id, await getBundleId(program, config.metroPort, device.name)) : void 0;
3640
+ const backend = createBackend(shell, device.platform, companion);
2898
3641
  const executor = new GestureExecutor(backend);
2899
- const result = await executor.swipe(device, direction);
3642
+ const result = await executor.swipe(device, direction, opts.distance);
2900
3643
  console.log(JSON.stringify(result, null, 2));
2901
3644
  });
2902
3645
  program.command("go-back").description("Press the back button").option("-d, --device <id>", "device ID or name").action(async (opts) => {
2903
3646
  const shell = new RealShell();
3647
+ const config = await loadConfig();
2904
3648
  const discovery = new DeviceDiscovery(shell);
2905
3649
  const devices = await discovery.list();
2906
3650
  const booted = devices.filter((d) => d.state === "booted");
@@ -2912,13 +3656,16 @@ function createProgram() {
2912
3656
  } else {
2913
3657
  device = await pickDevice(booted);
2914
3658
  }
2915
- const backend = createBackend(shell, device.platform);
3659
+ const launcher = device.platform === "ios" ? getCompanionLauncher(config.companionPort) : void 0;
3660
+ const companion = launcher ? await launcher.ensureRunning(device.id, await getBundleId(program, config.metroPort, device.name)) : void 0;
3661
+ const backend = createBackend(shell, device.platform, companion);
2916
3662
  const executor = new GestureExecutor(backend);
2917
3663
  const result = await executor.goBack(device);
2918
3664
  console.log(JSON.stringify(result, null, 2));
2919
3665
  });
2920
3666
  program.command("open-url <url>").description("Open a deep link or URL on the device").option("-d, --device <id>", "device ID or name").action(async (url, opts) => {
2921
3667
  const shell = new RealShell();
3668
+ const config = await loadConfig();
2922
3669
  const discovery = new DeviceDiscovery(shell);
2923
3670
  const devices = await discovery.list();
2924
3671
  const booted = devices.filter((d) => d.state === "booted");
@@ -2930,25 +3677,27 @@ function createProgram() {
2930
3677
  } else {
2931
3678
  device = await pickDevice(booted);
2932
3679
  }
2933
- const backend = createBackend(shell, device.platform);
3680
+ const launcher = device.platform === "ios" ? getCompanionLauncher(config.companionPort) : void 0;
3681
+ const companion = launcher ? await launcher.ensureRunning(device.id, await getBundleId(program, config.metroPort, device.name)) : void 0;
3682
+ const backend = createBackend(shell, device.platform, companion);
2934
3683
  const executor = new GestureExecutor(backend);
2935
3684
  const result = await executor.openUrl(device, url);
2936
3685
  console.log(JSON.stringify(result, null, 2));
2937
3686
  });
2938
3687
  program.command("setup-claude").description("Register driftx as a Claude Code plugin").action(() => {
2939
- const claudeDir = join5(homedir(), ".claude");
2940
- const pluginsDir = join5(claudeDir, "plugins");
2941
- const driftxPluginDir = join5(pluginsDir, "driftx");
2942
- const registryPath = join5(pluginsDir, "installed_plugins.json");
2943
- const packageRoot = resolve(dirname4(fileURLToPath(import.meta.url)), "..");
2944
- const skillSource = join5(packageRoot, "driftx-plugin");
2945
- if (!existsSync6(skillSource)) {
3688
+ const claudeDir = join7(homedir2(), ".claude");
3689
+ const pluginsDir = join7(claudeDir, "plugins");
3690
+ const driftxPluginDir = join7(pluginsDir, "driftx");
3691
+ const registryPath = join7(pluginsDir, "installed_plugins.json");
3692
+ const packageRoot = resolve2(dirname5(fileURLToPath2(import.meta.url)), "..");
3693
+ const skillSource = join7(packageRoot, "driftx-plugin");
3694
+ if (!existsSync7(skillSource)) {
2946
3695
  console.error(`driftx-plugin directory not found at ${skillSource}`);
2947
3696
  process.exitCode = 1;
2948
3697
  return;
2949
3698
  }
2950
- mkdirSync4(pluginsDir, { recursive: true });
2951
- if (existsSync6(driftxPluginDir)) {
3699
+ mkdirSync5(pluginsDir, { recursive: true });
3700
+ if (existsSync7(driftxPluginDir)) {
2952
3701
  try {
2953
3702
  unlinkSync3(driftxPluginDir);
2954
3703
  } catch {
@@ -2960,7 +3709,7 @@ function createProgram() {
2960
3709
  symlinkSync(skillSource, driftxPluginDir);
2961
3710
  let registry = { version: 2, plugins: {} };
2962
3711
  try {
2963
- registry = JSON.parse(readFileSync8(registryPath, "utf-8"));
3712
+ registry = JSON.parse(readFileSync10(registryPath, "utf-8"));
2964
3713
  } catch {
2965
3714
  }
2966
3715
  registry.plugins["driftx@local"] = [{
@@ -2970,7 +3719,7 @@ function createProgram() {
2970
3719
  installedAt: (/* @__PURE__ */ new Date()).toISOString(),
2971
3720
  lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
2972
3721
  }];
2973
- writeFileSync4(registryPath, JSON.stringify(registry, null, 2));
3722
+ writeFileSync6(registryPath, JSON.stringify(registry, null, 2));
2974
3723
  console.log("driftx registered as Claude Code plugin.");
2975
3724
  console.log(` Symlink: ${driftxPluginDir} -> ${skillSource}`);
2976
3725
  console.log(` Registry: ${registryPath}`);
@@ -2980,6 +3729,10 @@ function createProgram() {
2980
3729
  }
2981
3730
  function run(argv) {
2982
3731
  const program = createProgram();
3732
+ checkForUpdate(pkg.version).then((msg) => {
3733
+ if (msg) process.stderr.write(msg + "\n");
3734
+ }).catch(() => {
3735
+ });
2983
3736
  program.parseAsync(argv);
2984
3737
  }
2985
3738