driftx 0.1.0 → 0.1.2

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 (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +216 -0
  3. package/dist/bin.js +842 -84
  4. package/dist/bin.js.map +1 -1
  5. package/driftx-plugin/skills/driftx/SKILL.md +514 -0
  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 +19 -4
  58. package/driftx-plugin/skills/driftx.md +0 -299
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"));
@@ -667,6 +670,13 @@ async function withRetry(fn, policy, signal) {
667
670
  import { select } from "@inquirer/prompts";
668
671
  async function pickDevice(booted) {
669
672
  if (booted.length === 1) return booted[0];
673
+ if (!process.stdout.isTTY) {
674
+ const list = booted.map((d) => ` - "${d.name}" (${d.platform}, ${d.id})`).join("\n");
675
+ throw new Error(
676
+ `Multiple booted devices found. Use --device to specify one:
677
+ ${list}`
678
+ );
679
+ }
670
680
  const selected = await select({
671
681
  message: "Select a device",
672
682
  choices: booted.map((d) => ({
@@ -901,8 +911,133 @@ var FIBER_WALK_SCRIPT = `(function() {
901
911
  }
902
912
  }
903
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
+ }
904
1039
  async function discoverTargets(metroPort) {
905
- return new Promise((resolve2, reject) => {
1040
+ return new Promise((resolve3, reject) => {
906
1041
  const req = http.get(`http://localhost:${metroPort}/json/list`, { timeout: 2e3 }, (res) => {
907
1042
  let data = "";
908
1043
  res.on("data", (chunk) => {
@@ -910,23 +1045,21 @@ async function discoverTargets(metroPort) {
910
1045
  });
911
1046
  res.on("end", () => {
912
1047
  try {
913
- resolve2(JSON.parse(data));
1048
+ resolve3(JSON.parse(data));
914
1049
  } catch {
915
- resolve2([]);
1050
+ resolve3([]);
916
1051
  }
917
1052
  });
918
1053
  });
919
- req.on("error", () => resolve2([]));
1054
+ req.on("error", () => resolve3([]));
920
1055
  req.on("timeout", () => {
921
1056
  req.destroy();
922
- resolve2([]);
1057
+ resolve3([]);
923
1058
  });
924
1059
  });
925
1060
  }
926
1061
  function findRuntimeTarget(targets, deviceName) {
927
- const rnTargets = targets.filter(
928
- (t) => t.reactNative && t.description.includes("React Native")
929
- );
1062
+ const rnTargets = targets.filter((t) => !!t.reactNative);
930
1063
  if (deviceName) {
931
1064
  return rnTargets.find(
932
1065
  (t) => t.deviceName?.toLowerCase().includes(deviceName.toLowerCase())
@@ -948,16 +1081,16 @@ var CdpClient = class {
948
1081
  return [];
949
1082
  }
950
1083
  logger.debug(`Found CDP target: ${target.title} (${target.description})`);
951
- return new Promise((resolve2, reject) => {
1084
+ return new Promise((resolve3, reject) => {
952
1085
  const timer = setTimeout(() => {
953
1086
  this.cleanup();
954
- resolve2([]);
1087
+ resolve3([]);
955
1088
  }, timeoutMs);
956
1089
  try {
957
1090
  this.ws = new WebSocket(target.webSocketDebuggerUrl);
958
1091
  } catch {
959
1092
  clearTimeout(timer);
960
- resolve2([]);
1093
+ resolve3([]);
961
1094
  return;
962
1095
  }
963
1096
  this.ws.on("open", async () => {
@@ -965,10 +1098,10 @@ var CdpClient = class {
965
1098
  try {
966
1099
  const result = await this.evaluate(FIBER_WALK_SCRIPT);
967
1100
  clearTimeout(timer);
968
- resolve2(this.parseResult(result));
1101
+ resolve3(this.parseResult(result));
969
1102
  } catch {
970
1103
  clearTimeout(timer);
971
- resolve2([]);
1104
+ resolve3([]);
972
1105
  }
973
1106
  });
974
1107
  this.ws.on("message", (data) => {
@@ -986,7 +1119,7 @@ var CdpClient = class {
986
1119
  });
987
1120
  this.ws.on("error", () => {
988
1121
  clearTimeout(timer);
989
- resolve2([]);
1122
+ resolve3([]);
990
1123
  });
991
1124
  this.ws.on("close", () => {
992
1125
  this.connected = false;
@@ -995,8 +1128,8 @@ var CdpClient = class {
995
1128
  }
996
1129
  evaluate(expression) {
997
1130
  const id = ++this.msgId;
998
- return new Promise((resolve2, reject) => {
999
- this.pending.set(id, { resolve: resolve2, reject });
1131
+ return new Promise((resolve3, reject) => {
1132
+ this.pending.set(id, { resolve: resolve3, reject });
1000
1133
  this.ws.send(JSON.stringify({
1001
1134
  id,
1002
1135
  method: "Runtime.evaluate",
@@ -1163,13 +1296,56 @@ var StrategyCache = class {
1163
1296
  }
1164
1297
  };
1165
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
+
1166
1340
  // src/inspect/tree-inspector.ts
1167
1341
  var TreeInspector = class {
1168
1342
  shell;
1169
1343
  fileCache;
1170
- constructor(shell, projectRoot) {
1344
+ companionLauncher;
1345
+ constructor(shell, projectRoot, companionLauncher) {
1171
1346
  this.shell = shell;
1172
1347
  this.fileCache = projectRoot ? new StrategyCache(projectRoot) : null;
1348
+ this.companionLauncher = companionLauncher ?? null;
1173
1349
  }
1174
1350
  invalidateCache(deviceId) {
1175
1351
  if (!this.fileCache) return;
@@ -1195,6 +1371,9 @@ var TreeInspector = class {
1195
1371
  if (device.platform === "android") {
1196
1372
  return { method: "uiautomator", reason: "Android native inspection" };
1197
1373
  }
1374
+ if (this.companionLauncher) {
1375
+ return { method: "xcuitest", reason: "iOS native inspection via XCUITest companion" };
1376
+ }
1198
1377
  return { method: "idb", reason: "iOS native inspection via idb" };
1199
1378
  }
1200
1379
  async inspect(device, options) {
@@ -1252,13 +1431,30 @@ var TreeInspector = class {
1252
1431
  logger.debug(`UIAutomator failed: ${err instanceof Error ? err.message : err}`);
1253
1432
  }
1254
1433
  }
1255
- 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") {
1256
1452
  try {
1257
1453
  const tree = await dumpIosAccessibility(this.shell, device.id, options.timeoutMs);
1258
1454
  logger.debug(`idb: got ${tree.length} root nodes for ${device.name}`);
1259
1455
  return {
1260
1456
  ...base,
1261
- 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 },
1262
1458
  tree,
1263
1459
  capabilities: { tree: "basic", sourceMapping: "none", styles: "none", protocol: "idb" },
1264
1460
  hints
@@ -2318,12 +2514,12 @@ async function copyToClipboard(text) {
2318
2514
  logger.debug(`Clipboard not supported on ${process.platform}`);
2319
2515
  return;
2320
2516
  }
2321
- return new Promise((resolve2) => {
2517
+ return new Promise((resolve3) => {
2322
2518
  const proc = exec(cmd, (err) => {
2323
2519
  if (err) {
2324
2520
  logger.debug(`Clipboard copy failed: ${err.message}`);
2325
2521
  }
2326
- resolve2();
2522
+ resolve3();
2327
2523
  });
2328
2524
  proc.stdin?.write(text);
2329
2525
  proc.stdin?.end();
@@ -2563,35 +2759,35 @@ var AndroidBackend = class {
2563
2759
 
2564
2760
  // src/interact/ios.ts
2565
2761
  var IosBackend = class {
2566
- constructor(shell) {
2762
+ constructor(shell, companion) {
2567
2763
  this.shell = shell;
2764
+ this.companion = companion;
2568
2765
  }
2569
- async tap(device, point) {
2570
- 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);
2571
2768
  }
2572
- async longPress(device, point, _durationMs) {
2573
- 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);
2574
2771
  }
2575
- async swipe(device, from, to, _durationMs) {
2576
- 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);
2577
2774
  }
2578
- async type(device, text) {
2579
- await this.simctlIo(device, ["type", text]);
2775
+ async type(_device, text) {
2776
+ await this.companion.type(text);
2580
2777
  }
2581
- async keyEvent(device, key) {
2582
- await this.simctlIo(device, ["sendkey", key]);
2778
+ async keyEvent(_device, key) {
2779
+ await this.companion.keyEvent(key);
2583
2780
  }
2584
2781
  async openUrl(device, url) {
2585
2782
  await this.shell.exec("xcrun", ["simctl", "openurl", device.id, url]);
2586
2783
  }
2587
- async simctlIo(device, args) {
2588
- await this.shell.exec("xcrun", ["simctl", "io", device.id, ...args]);
2589
- }
2590
2784
  };
2591
2785
 
2592
2786
  // src/interact/backend.ts
2593
- function createBackend(shell, platform) {
2594
- 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);
2595
2791
  }
2596
2792
 
2597
2793
  // src/interact/resolver.ts
@@ -2603,6 +2799,17 @@ function resolveTarget(tree, query) {
2603
2799
  if (byName) return centerOf(byName, `name:${query}`);
2604
2800
  const byText = nodes.find((n) => n.text === query && hasSize(n));
2605
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}`);
2606
2813
  return null;
2607
2814
  }
2608
2815
  function flattenTree(nodes) {
@@ -2618,6 +2825,13 @@ function flattenTree(nodes) {
2618
2825
  function hasSize(node) {
2619
2826
  return node.bounds.width > 0 && node.bounds.height > 0;
2620
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
+ }
2621
2835
  function centerOf(node, resolvedFrom) {
2622
2836
  return {
2623
2837
  x: Math.round(node.bounds.x + node.bounds.width / 2),
@@ -2667,13 +2881,15 @@ var GestureExecutor = class {
2667
2881
  return { success: false, action: "longPress", durationMs: Date.now() - start, error: String(e) };
2668
2882
  }
2669
2883
  }
2670
- async swipe(device, direction, distance = 600, durationMs = 300) {
2884
+ async swipe(device, direction, distance, durationMs = 300) {
2671
2885
  const start = Date.now();
2672
2886
  try {
2673
- const cx = device.screenSize?.width ? Math.round(device.screenSize.width / 2) : 540;
2674
- 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);
2675
2891
  const from = { x: cx, y: cy };
2676
- const half = Math.round(distance / 2);
2892
+ const half = Math.round(actualDistance / 2);
2677
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 };
2678
2894
  await this.backend.swipe(device, from, to, durationMs);
2679
2895
  return { success: true, action: "swipe", durationMs: Date.now() - start };
@@ -2716,6 +2932,470 @@ var GestureExecutor = class {
2716
2932
  }
2717
2933
  };
2718
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
+
2719
3399
  // src/cli.ts
2720
3400
  var require2 = createRequire(import.meta.url);
2721
3401
  var pkg = require2("../package.json");
@@ -2727,8 +3407,27 @@ function getFormatterContext(opts) {
2727
3407
  };
2728
3408
  }
2729
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
+ }
2730
3429
  const program = new Command();
2731
- 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");
2732
3431
  program.hook("preAction", (_thisCommand, actionCommand) => {
2733
3432
  const opts = actionCommand.optsWithGlobals();
2734
3433
  const level = opts.verbose ? "debug" : opts.quiet ? "silent" : "info";
@@ -2747,13 +3446,13 @@ function createProgram() {
2747
3446
  const files = readdirSync2(cwd);
2748
3447
  let packageJson;
2749
3448
  try {
2750
- packageJson = JSON.parse(readFileSync8(join5(cwd, "package.json"), "utf-8"));
3449
+ packageJson = JSON.parse(readFileSync10(join7(cwd, "package.json"), "utf-8"));
2751
3450
  } catch {
2752
3451
  }
2753
3452
  const framework = detectFramework(files, packageJson);
2754
3453
  const config = generateConfig(framework);
2755
- const configPath = join5(cwd, ".driftxrc.json");
2756
- writeFileSync4(configPath, JSON.stringify(config, null, 2) + "\n");
3454
+ const configPath = join7(cwd, ".driftxrc.json");
3455
+ writeFileSync6(configPath, JSON.stringify(config, null, 2) + "\n");
2757
3456
  console.log(`Created ${configPath} (framework: ${framework})`);
2758
3457
  });
2759
3458
  program.command("devices").description("List connected devices and simulators").action(async function() {
@@ -2809,13 +3508,15 @@ function createProgram() {
2809
3508
  } else {
2810
3509
  device = await pickDevice(booted);
2811
3510
  }
2812
- 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();
2813
3514
  const result = await inspector.inspect(device, {
2814
3515
  metroPort: config.metroPort,
2815
3516
  devToolsPort: config.devToolsPort,
2816
- timeoutMs: config.timeouts.treeInspectionMs
3517
+ timeoutMs: config.timeouts.treeInspectionMs,
3518
+ bundleId: await getBundleId(program, config.metroPort, device.name)
2817
3519
  });
2818
- const globalOpts = this.optsWithGlobals();
2819
3520
  if (opts.json) globalOpts.format = "json";
2820
3521
  const ctx = getFormatterContext(globalOpts);
2821
3522
  await formatOutput(inspectFormatter, result, ctx);
@@ -2834,20 +3535,46 @@ function createProgram() {
2834
3535
  } else {
2835
3536
  device = await pickDevice(booted);
2836
3537
  }
2837
- 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);
2838
3541
  const executor = new GestureExecutor(backend);
2839
3542
  let result;
2840
3543
  if (opts.xy) {
2841
3544
  const [x, y] = target.split(",").map(Number);
2842
3545
  result = await executor.tapXY(device, x, y);
2843
3546
  } else {
2844
- const inspector = new TreeInspector(shell, process.cwd());
3547
+ const inspector = new TreeInspector(shell, process.cwd(), launcher);
2845
3548
  const inspectResult = await inspector.inspect(device, {
2846
3549
  metroPort: config.metroPort,
2847
3550
  devToolsPort: config.devToolsPort,
2848
- timeoutMs: config.timeouts.treeInspectionMs
3551
+ timeoutMs: config.timeouts.treeInspectionMs,
3552
+ bundleId: await getBundleId(program, config.metroPort, device.name)
2849
3553
  });
2850
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
+ }
2851
3578
  }
2852
3579
  console.log(JSON.stringify(result, null, 2));
2853
3580
  });
@@ -2865,19 +3592,38 @@ function createProgram() {
2865
3592
  } else {
2866
3593
  device = await pickDevice(booted);
2867
3594
  }
2868
- 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);
2869
3598
  const inspectResult = await inspector.inspect(device, {
2870
3599
  metroPort: config.metroPort,
2871
3600
  devToolsPort: config.devToolsPort,
2872
- timeoutMs: config.timeouts.treeInspectionMs
3601
+ timeoutMs: config.timeouts.treeInspectionMs,
3602
+ bundleId: await getBundleId(program, config.metroPort, device.name)
2873
3603
  });
2874
- const backend = createBackend(shell, device.platform);
3604
+ const backend = createBackend(shell, device.platform, companion);
2875
3605
  const executor = new GestureExecutor(backend);
2876
- 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
+ }
2877
3622
  console.log(JSON.stringify(result, null, 2));
2878
3623
  });
2879
- 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) => {
2880
3625
  const shell = new RealShell();
3626
+ const config = await loadConfig();
2881
3627
  const discovery = new DeviceDiscovery(shell);
2882
3628
  const devices = await discovery.list();
2883
3629
  const booted = devices.filter((d) => d.state === "booted");
@@ -2889,13 +3635,16 @@ function createProgram() {
2889
3635
  } else {
2890
3636
  device = await pickDevice(booted);
2891
3637
  }
2892
- 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);
2893
3641
  const executor = new GestureExecutor(backend);
2894
- const result = await executor.swipe(device, direction);
3642
+ const result = await executor.swipe(device, direction, opts.distance);
2895
3643
  console.log(JSON.stringify(result, null, 2));
2896
3644
  });
2897
3645
  program.command("go-back").description("Press the back button").option("-d, --device <id>", "device ID or name").action(async (opts) => {
2898
3646
  const shell = new RealShell();
3647
+ const config = await loadConfig();
2899
3648
  const discovery = new DeviceDiscovery(shell);
2900
3649
  const devices = await discovery.list();
2901
3650
  const booted = devices.filter((d) => d.state === "booted");
@@ -2907,13 +3656,16 @@ function createProgram() {
2907
3656
  } else {
2908
3657
  device = await pickDevice(booted);
2909
3658
  }
2910
- 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);
2911
3662
  const executor = new GestureExecutor(backend);
2912
3663
  const result = await executor.goBack(device);
2913
3664
  console.log(JSON.stringify(result, null, 2));
2914
3665
  });
2915
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) => {
2916
3667
  const shell = new RealShell();
3668
+ const config = await loadConfig();
2917
3669
  const discovery = new DeviceDiscovery(shell);
2918
3670
  const devices = await discovery.list();
2919
3671
  const booted = devices.filter((d) => d.state === "booted");
@@ -2925,25 +3677,27 @@ function createProgram() {
2925
3677
  } else {
2926
3678
  device = await pickDevice(booted);
2927
3679
  }
2928
- 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);
2929
3683
  const executor = new GestureExecutor(backend);
2930
3684
  const result = await executor.openUrl(device, url);
2931
3685
  console.log(JSON.stringify(result, null, 2));
2932
3686
  });
2933
3687
  program.command("setup-claude").description("Register driftx as a Claude Code plugin").action(() => {
2934
- const claudeDir = join5(homedir(), ".claude");
2935
- const pluginsDir = join5(claudeDir, "plugins");
2936
- const driftxPluginDir = join5(pluginsDir, "driftx");
2937
- const registryPath = join5(pluginsDir, "installed_plugins.json");
2938
- const packageRoot = resolve(dirname4(fileURLToPath(import.meta.url)), "..");
2939
- const skillSource = join5(packageRoot, "driftx-plugin");
2940
- 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)) {
2941
3695
  console.error(`driftx-plugin directory not found at ${skillSource}`);
2942
3696
  process.exitCode = 1;
2943
3697
  return;
2944
3698
  }
2945
- mkdirSync4(pluginsDir, { recursive: true });
2946
- if (existsSync6(driftxPluginDir)) {
3699
+ mkdirSync5(pluginsDir, { recursive: true });
3700
+ if (existsSync7(driftxPluginDir)) {
2947
3701
  try {
2948
3702
  unlinkSync3(driftxPluginDir);
2949
3703
  } catch {
@@ -2955,7 +3709,7 @@ function createProgram() {
2955
3709
  symlinkSync(skillSource, driftxPluginDir);
2956
3710
  let registry = { version: 2, plugins: {} };
2957
3711
  try {
2958
- registry = JSON.parse(readFileSync8(registryPath, "utf-8"));
3712
+ registry = JSON.parse(readFileSync10(registryPath, "utf-8"));
2959
3713
  } catch {
2960
3714
  }
2961
3715
  registry.plugins["driftx@local"] = [{
@@ -2965,7 +3719,7 @@ function createProgram() {
2965
3719
  installedAt: (/* @__PURE__ */ new Date()).toISOString(),
2966
3720
  lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
2967
3721
  }];
2968
- writeFileSync4(registryPath, JSON.stringify(registry, null, 2));
3722
+ writeFileSync6(registryPath, JSON.stringify(registry, null, 2));
2969
3723
  console.log("driftx registered as Claude Code plugin.");
2970
3724
  console.log(` Symlink: ${driftxPluginDir} -> ${skillSource}`);
2971
3725
  console.log(` Registry: ${registryPath}`);
@@ -2975,6 +3729,10 @@ function createProgram() {
2975
3729
  }
2976
3730
  function run(argv) {
2977
3731
  const program = createProgram();
3732
+ checkForUpdate(pkg.version).then((msg) => {
3733
+ if (msg) process.stderr.write(msg + "\n");
3734
+ }).catch(() => {
3735
+ });
2978
3736
  program.parseAsync(argv);
2979
3737
  }
2980
3738