@debugswift/ios-simulator-cli 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +5 -2
  2. package/build/index.js +372 -33
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -10,14 +10,17 @@ A command-line tool for interacting with iOS simulators. Control UI, capture scr
10
10
 
11
11
  ### Homebrew (recommended)
12
12
 
13
+ The formula lives in this repo, so tap it explicitly (Homebrew otherwise looks for a separate `homebrew-ios-simulator-cli` repo that does not exist):
14
+
13
15
  ```bash
14
- brew install DebugSwift/ios-simulator-cli/ios-simulator-cli
16
+ brew tap DebugSwift/ios-simulator-cli https://github.com/DebugSwift/ios-simulator-cli
17
+ brew install ios-simulator-cli
15
18
  ```
16
19
 
17
20
  To install the latest from `main`:
18
21
 
19
22
  ```bash
20
- brew install --HEAD DebugSwift/ios-simulator-cli/ios-simulator-cli
23
+ brew install --HEAD ios-simulator-cli
21
24
  ```
22
25
 
23
26
  ### npm
package/build/index.js CHANGED
@@ -62,8 +62,46 @@ function getIdbPath() {
62
62
  }
63
63
  return expandedPath;
64
64
  }
65
+ const pythonBins = [];
66
+ const pythonRoot = path_1.default.join(os_1.default.homedir(), "Library/Python");
67
+ if (fs_1.default.existsSync(pythonRoot)) {
68
+ for (const entry of fs_1.default.readdirSync(pythonRoot, { withFileTypes: true })) {
69
+ if (!entry.isDirectory())
70
+ continue;
71
+ pythonBins.push(path_1.default.join(pythonRoot, entry.name, "bin/idb"));
72
+ }
73
+ }
74
+ const candidates = preferWorkingIdb([
75
+ path_1.default.join(os_1.default.homedir(), ".local/bin/idb"),
76
+ ...pythonBins,
77
+ "/opt/homebrew/bin/idb",
78
+ "/usr/local/bin/idb",
79
+ ].filter((candidate) => fs_1.default.existsSync(candidate)));
80
+ if (candidates.length > 0) {
81
+ return candidates[0];
82
+ }
65
83
  return "idb";
66
84
  }
85
+ /** Prefer arm64-friendly idb installs (Xcode/system Python) over Intel Homebrew Python. */
86
+ function preferWorkingIdb(candidates) {
87
+ const score = (idbPath) => {
88
+ try {
89
+ const shebang = fs_1.default.readFileSync(idbPath, "utf8").split("\n", 1)[0] ?? "";
90
+ if (shebang.includes("/usr/local/opt/python"))
91
+ return 2;
92
+ if (shebang.includes("Xcode.app") ||
93
+ shebang.includes("/usr/bin/python") ||
94
+ shebang.includes("/opt/homebrew/")) {
95
+ return 0;
96
+ }
97
+ return 1;
98
+ }
99
+ catch {
100
+ return 1;
101
+ }
102
+ };
103
+ return [...candidates].sort((a, b) => score(a) - score(b));
104
+ }
67
105
  async function idb(...args) {
68
106
  return run(getIdbPath(), args);
69
107
  }
@@ -145,6 +183,61 @@ function parseEnvPairs(values) {
145
183
  }
146
184
  return env;
147
185
  }
186
+ function matchesSearch(value, term, mode, sensitive) {
187
+ if (value == null)
188
+ return false;
189
+ const v = sensitive ? value : value.toLowerCase();
190
+ const t = sensitive ? term : term.toLowerCase();
191
+ return mode === "exact" ? v === t : v.includes(t);
192
+ }
193
+ function findUiElements(elements, options) {
194
+ const results = [];
195
+ for (const element of elements) {
196
+ const label = element.AXLabel;
197
+ const uniqueId = element.AXUniqueId;
198
+ const elementType = element.type;
199
+ const matchesAnySearch = options.search.some((term) => matchesSearch(label, term, options.matchMode, options.caseSensitive) ||
200
+ matchesSearch(uniqueId, term, options.matchMode, options.caseSensitive));
201
+ const matchesType = options.type == null ||
202
+ (elementType != null &&
203
+ elementType.toLowerCase() === options.type.toLowerCase());
204
+ if (matchesAnySearch && matchesType) {
205
+ results.push(element);
206
+ }
207
+ const children = element.children;
208
+ if (children && children.length > 0) {
209
+ results.push(...findUiElements(children, options));
210
+ }
211
+ }
212
+ return results;
213
+ }
214
+ async function fetchUiTree(udid) {
215
+ const actualUdid = await getBootedDeviceId(udidSchema.parse(udid));
216
+ const { stdout } = await idb("ui", "describe-all", "--udid", actualUdid, "--json", "--nested");
217
+ return JSON.parse(stdout);
218
+ }
219
+ function elementCenter(element) {
220
+ const frame = element.frame;
221
+ if (!frame ||
222
+ typeof frame.x !== "number" ||
223
+ typeof frame.y !== "number" ||
224
+ typeof frame.width !== "number" ||
225
+ typeof frame.height !== "number") {
226
+ throw new Error("Element has no valid frame for tapping");
227
+ }
228
+ return {
229
+ x: Math.round(frame.x + frame.width / 2),
230
+ y: Math.round(frame.y + frame.height / 2),
231
+ };
232
+ }
233
+ function sleep(ms) {
234
+ return new Promise((resolve) => setTimeout(resolve, ms));
235
+ }
236
+ const matchModeSchema = zod_1.z.enum(["substring", "exact"]);
237
+ const workflowSchema = zod_1.z.object({
238
+ udid: udidSchema,
239
+ steps: zod_1.z.array(zod_1.z.record(zod_1.z.unknown())).min(1),
240
+ });
148
241
  async function cmdGetBootedSimId() {
149
242
  const { id, name } = await getBootedDevice();
150
243
  console.log(`Booted Simulator: "${name}". UUID: "${id}"`);
@@ -188,38 +281,8 @@ async function cmdUiDescribePoint(options) {
188
281
  console.log(stdout);
189
282
  }
190
283
  async function cmdUiFindElement(options) {
191
- const actualUdid = await getBootedDeviceId(udidSchema.parse(options.udid));
192
- const { stdout } = await idb("ui", "describe-all", "--udid", actualUdid, "--json", "--nested");
193
- const uiData = JSON.parse(stdout);
194
- function matchesSearch(value, term, mode, sensitive) {
195
- if (value == null)
196
- return false;
197
- const v = sensitive ? value : value.toLowerCase();
198
- const t = sensitive ? term : term.toLowerCase();
199
- return mode === "exact" ? v === t : v.includes(t);
200
- }
201
- function findElements(elements) {
202
- const results = [];
203
- for (const element of elements) {
204
- const label = element.AXLabel;
205
- const uniqueId = element.AXUniqueId;
206
- const elementType = element.type;
207
- const matchesAnySearch = options.search.some((term) => matchesSearch(label, term, options.matchMode, options.caseSensitive) ||
208
- matchesSearch(uniqueId, term, options.matchMode, options.caseSensitive));
209
- const matchesType = options.type == null ||
210
- (elementType != null &&
211
- elementType.toLowerCase() === options.type.toLowerCase());
212
- if (matchesAnySearch && matchesType) {
213
- results.push(element);
214
- }
215
- const children = element.children;
216
- if (children && children.length > 0) {
217
- results.push(...findElements(children));
218
- }
219
- }
220
- return results;
221
- }
222
- console.log(JSON.stringify(findElements(uiData)));
284
+ const uiData = await fetchUiTree(options.udid);
285
+ console.log(JSON.stringify(findUiElements(uiData, options)));
223
286
  }
224
287
  async function cmdUiView(options) {
225
288
  const actualUdid = await getBootedDeviceId(udidSchema.parse(options.udid));
@@ -383,6 +446,240 @@ async function cmdLaunchApp(options) {
383
446
  ? `App ${options.bundleId} launched successfully with PID: ${pid}`
384
447
  : `App ${options.bundleId} launched successfully`);
385
448
  }
449
+ function stepUdid(globalUdid, step) {
450
+ const value = step.udid;
451
+ if (value == null)
452
+ return globalUdid;
453
+ return udidSchema.parse(value);
454
+ }
455
+ function parseStepObject(step, index) {
456
+ if (typeof step !== "object" || step == null || Array.isArray(step)) {
457
+ throw new Error(`Step ${index + 1}: expected an object`);
458
+ }
459
+ const keys = Object.keys(step);
460
+ if (keys.length !== 1) {
461
+ throw new Error(`Step ${index + 1}: expected exactly one action, got: ${keys.join(", ") || "(empty)"}`);
462
+ }
463
+ return step;
464
+ }
465
+ async function runWorkflowStep(action, payload, globalUdid, index) {
466
+ const stepLabel = `Step ${index + 1}: ${action}`;
467
+ console.log(stepLabel);
468
+ switch (action) {
469
+ case "wait": {
470
+ const ms = zod_1.z.number().int().nonnegative().parse(payload);
471
+ await sleep(ms);
472
+ return;
473
+ }
474
+ case "open": {
475
+ if (payload != null && payload !== true) {
476
+ throw new Error(`${stepLabel} expects true or no value`);
477
+ }
478
+ await cmdOpenSimulator();
479
+ return;
480
+ }
481
+ case "launch-app": {
482
+ const step = zod_1.z
483
+ .object({
484
+ bundleId: zod_1.z.string().min(1),
485
+ terminateRunning: zod_1.z.boolean().optional(),
486
+ env: zod_1.z.record(zod_1.z.string()).optional(),
487
+ udid: udidSchema,
488
+ })
489
+ .parse(payload);
490
+ await cmdLaunchApp({
491
+ udid: stepUdid(globalUdid, step),
492
+ bundleId: step.bundleId,
493
+ terminateRunning: step.terminateRunning,
494
+ env: step.env,
495
+ });
496
+ return;
497
+ }
498
+ case "install-app": {
499
+ const step = zod_1.z
500
+ .object({
501
+ appPath: zod_1.z.string().min(1),
502
+ udid: udidSchema,
503
+ })
504
+ .parse(payload);
505
+ await cmdInstallApp(step.appPath, stepUdid(globalUdid, step));
506
+ return;
507
+ }
508
+ case "tap": {
509
+ const step = zod_1.z
510
+ .object({
511
+ x: zod_1.z.number().optional(),
512
+ y: zod_1.z.number().optional(),
513
+ search: zod_1.z.union([zod_1.z.string(), zod_1.z.array(zod_1.z.string())]).optional(),
514
+ type: zod_1.z.string().optional(),
515
+ matchMode: matchModeSchema.optional(),
516
+ caseSensitive: zod_1.z.boolean().optional(),
517
+ index: zod_1.z.number().int().nonnegative().optional(),
518
+ duration: zod_1.z.string().regex(/^\d+(\.\d+)?$/).optional(),
519
+ udid: udidSchema,
520
+ })
521
+ .parse(payload);
522
+ const udid = stepUdid(globalUdid, step);
523
+ if (step.x != null && step.y != null) {
524
+ await cmdUiTap({
525
+ udid,
526
+ x: step.x,
527
+ y: step.y,
528
+ duration: step.duration,
529
+ });
530
+ return;
531
+ }
532
+ if (step.search == null) {
533
+ throw new Error(`${stepLabel} requires x/y or search`);
534
+ }
535
+ const search = Array.isArray(step.search) ? step.search : [step.search];
536
+ const matches = findUiElements(await fetchUiTree(udid), {
537
+ search,
538
+ type: step.type,
539
+ matchMode: step.matchMode ?? "substring",
540
+ caseSensitive: step.caseSensitive ?? false,
541
+ });
542
+ const matchIndex = step.index ?? 0;
543
+ if (matches.length === 0) {
544
+ throw new Error(`${stepLabel} found no element matching: ${search.join(", ")}`);
545
+ }
546
+ if (matchIndex >= matches.length) {
547
+ throw new Error(`${stepLabel} index ${matchIndex} out of range (${matches.length} matches)`);
548
+ }
549
+ const { x, y } = elementCenter(matches[matchIndex]);
550
+ await cmdUiTap({ udid, x, y, duration: step.duration });
551
+ return;
552
+ }
553
+ case "type": {
554
+ const step = zod_1.z
555
+ .object({
556
+ text: zod_1.z.string().min(1).max(500).regex(/^[\x20-\x7E]+$/),
557
+ udid: udidSchema,
558
+ })
559
+ .parse(payload);
560
+ await cmdUiType(step.text, stepUdid(globalUdid, step));
561
+ return;
562
+ }
563
+ case "swipe": {
564
+ const step = zod_1.z
565
+ .object({
566
+ xStart: zod_1.z.number(),
567
+ yStart: zod_1.z.number(),
568
+ xEnd: zod_1.z.number(),
569
+ yEnd: zod_1.z.number(),
570
+ duration: zod_1.z.string().regex(/^\d+(\.\d+)?$/).optional(),
571
+ delta: zod_1.z.number().optional(),
572
+ udid: udidSchema,
573
+ })
574
+ .parse(payload);
575
+ await cmdUiSwipe({
576
+ udid: stepUdid(globalUdid, step),
577
+ xStart: step.xStart,
578
+ yStart: step.yStart,
579
+ xEnd: step.xEnd,
580
+ yEnd: step.yEnd,
581
+ duration: step.duration,
582
+ delta: step.delta,
583
+ });
584
+ return;
585
+ }
586
+ case "screenshot": {
587
+ const step = zod_1.z
588
+ .object({
589
+ output: zod_1.z.string().min(1),
590
+ type: zod_1.z.enum(["png", "tiff", "bmp", "gif", "jpeg"]).optional(),
591
+ display: zod_1.z.enum(["internal", "external"]).optional(),
592
+ mask: zod_1.z.enum(["ignored", "alpha", "black"]).optional(),
593
+ udid: udidSchema,
594
+ })
595
+ .parse(payload);
596
+ await cmdScreenshot({
597
+ udid: stepUdid(globalUdid, step),
598
+ outputPath: step.output,
599
+ type: step.type,
600
+ display: step.display,
601
+ mask: step.mask,
602
+ });
603
+ return;
604
+ }
605
+ case "ui-view": {
606
+ const step = zod_1.z
607
+ .object({
608
+ output: zod_1.z.string().optional(),
609
+ udid: udidSchema,
610
+ })
611
+ .parse(payload);
612
+ await cmdUiView({
613
+ udid: stepUdid(globalUdid, step),
614
+ output: step.output,
615
+ });
616
+ return;
617
+ }
618
+ default:
619
+ throw new Error(`${stepLabel} uses unknown action "${action}". Run ios-simulator-cli run --help for supported actions.`);
620
+ }
621
+ }
622
+ async function cmdRun(configPath) {
623
+ const absolutePath = path_1.default.isAbsolute(configPath)
624
+ ? configPath
625
+ : path_1.default.resolve(configPath);
626
+ if (!fs_1.default.existsSync(absolutePath)) {
627
+ throw new Error(`Config file not found: ${absolutePath}`);
628
+ }
629
+ let parsed;
630
+ try {
631
+ parsed = JSON.parse(fs_1.default.readFileSync(absolutePath, "utf8"));
632
+ }
633
+ catch {
634
+ throw new Error(`Invalid JSON in config file: ${absolutePath}`);
635
+ }
636
+ const workflow = workflowSchema.parse(parsed);
637
+ for (let i = 0; i < workflow.steps.length; i++) {
638
+ const step = parseStepObject(workflow.steps[i], i);
639
+ const [action, payload] = Object.entries(step)[0];
640
+ await runWorkflowStep(action, payload, workflow.udid, i);
641
+ }
642
+ console.log(`Completed ${workflow.steps.length} step(s)`);
643
+ }
644
+ function runHelpBody() {
645
+ return `Run a JSON workflow file against the booted simulator.
646
+
647
+ Config format:
648
+ {
649
+ "udid": "<optional-simulator-uuid>",
650
+ "steps": [
651
+ { "open": true },
652
+ { "wait": 1000 },
653
+ { "launch-app": { "bundleId": "com.example.app", "terminateRunning": true } },
654
+ { "tap": { "search": "Sign In", "type": "Button" } },
655
+ { "tap": { "x": 200, "y": 400 } },
656
+ { "type": { "text": "hello@world.com" } },
657
+ { "swipe": { "xStart": 200, "yStart": 600, "xEnd": 200, "yEnd": 200 } },
658
+ { "screenshot": { "output": "result.png" } }
659
+ ]
660
+ }
661
+
662
+ Supported step actions:
663
+ wait Milliseconds to pause (number)
664
+ open Open Simulator.app (true)
665
+ launch-app Launch app by bundle ID
666
+ install-app Install .app or .ipa
667
+ tap Tap by x/y or by accessibility search label
668
+ type Type ASCII text
669
+ swipe Swipe gesture
670
+ screenshot Save screenshot to file
671
+ ui-view Capture compressed JPEG view
672
+
673
+ Each step object must contain exactly one action key.
674
+ Step payloads may include an optional "udid" to override the config-level udid.`;
675
+ }
676
+ function printRunHelp() {
677
+ console.log(`ios-simulator-cli run --config <path>
678
+ ios-simulator-cli --config <path>
679
+
680
+ ${runHelpBody()}
681
+ `);
682
+ }
386
683
  function printHelp() {
387
684
  console.log(`ios-simulator-cli v${VERSION}
388
685
 
@@ -406,6 +703,8 @@ Commands:
406
703
  stop-recording Stop an active simulator recording
407
704
  install-app --app-path <path> [--udid <uuid>]
408
705
  launch-app --bundle-id <id> [--terminate-running] [--env KEY=VALUE] [--udid <uuid>]
706
+ run --config <path> Run a JSON workflow file
707
+ --config <path> Shorthand for run --config
409
708
 
410
709
  Global options:
411
710
  -h, --help Show this help
@@ -420,6 +719,10 @@ Examples:
420
719
  ios-simulator-cli ui tap --x 200 --y 400
421
720
  ios-simulator-cli screenshot --output home.png
422
721
  ios-simulator-cli launch-app --bundle-id com.apple.mobilesafari
722
+ ios-simulator-cli run --config flow.json
723
+ ios-simulator-cli --config flow.json
724
+
725
+ ${runHelpBody()}
423
726
  `);
424
727
  }
425
728
  async function handleUiCommand(args) {
@@ -564,7 +867,15 @@ async function handleUiCommand(args) {
564
867
  }
565
868
  async function main() {
566
869
  const args = process.argv.slice(2);
567
- if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
870
+ if (args.length === 0) {
871
+ printHelp();
872
+ return;
873
+ }
874
+ if (args.includes("--help") || args.includes("-h")) {
875
+ if (args[0] === "run") {
876
+ printRunHelp();
877
+ return;
878
+ }
568
879
  printHelp();
569
880
  return;
570
881
  }
@@ -674,6 +985,34 @@ async function main() {
674
985
  });
675
986
  return;
676
987
  }
988
+ case "run": {
989
+ if (rest.includes("--help") || rest.includes("-h")) {
990
+ printRunHelp();
991
+ return;
992
+ }
993
+ const { values } = (0, node_util_1.parseArgs)({
994
+ args: rest,
995
+ options: {
996
+ config: { type: "string" },
997
+ },
998
+ allowPositionals: false,
999
+ });
1000
+ if (!values.config) {
1001
+ printRunHelp();
1002
+ return;
1003
+ }
1004
+ await cmdRun(values.config);
1005
+ return;
1006
+ }
1007
+ case "--config": {
1008
+ const configPath = rest[0];
1009
+ if (!configPath || configPath.startsWith("-")) {
1010
+ printRunHelp();
1011
+ return;
1012
+ }
1013
+ await cmdRun(configPath);
1014
+ return;
1015
+ }
677
1016
  default:
678
1017
  throw new Error(`Unknown command: ${command}. Run ios-simulator-cli --help for usage.`);
679
1018
  }
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "1.6.0",
6
+ "version": "1.7.0",
7
7
  "description": "CLI for interacting with the iOS simulator",
8
8
  "bin": {
9
9
  "ios-simulator-cli": "build/index.js"