xcodebuild-axi 0.1.11 → 0.1.12

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.
@@ -1,28 +1,102 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
3
+ import { dirname, resolve } from "node:path";
1
4
  import { AxiError } from "../errors.js";
2
- import { findSimulator, listSimulators, simctl, } from "../simctl.js";
3
- import { renderFields, renderHelp, renderList, renderOutput } from "../toon.js";
4
- import { getFlag, hasFlag, positionals, rejectUnknownFlags } from "../args.js";
5
- export const SIM_HELP = `usage: xcodebuild-axi sim [list|boot|shutdown|erase] [name|udid] [flags]
5
+ import { resolveProject } from "../context.js";
6
+ import { requireScheme } from "../scheme.js";
7
+ import { capturePath, runMetadata } from "../xcodebuild.js";
8
+ import { parseSettings } from "./settings.js";
9
+ import { findDeviceType, findSimulator, listApps, listDeviceTypes, listSimulators, simctl, } from "../simctl.js";
10
+ import { byteSize, duration, renderFields, renderHelp, renderList, renderOutput, tildePath, } from "../toon.js";
11
+ import { getFlag, getIntFlag, hasFlag, positionals, rejectUnknownFlags, } from "../args.js";
12
+ export const SIM_HELP = `usage: xcodebuild-axi sim [subcommand] [name|udid] [app] [flags]
6
13
  Inspects and drives simulators. With no subcommand, lists the booted ones.
7
- subcommands[4]:
14
+ subcommands[18]:
8
15
  list every available simulator
9
16
  boot <name|udid> boot one, or no-op if it is already booted
10
17
  shutdown <name|udid> shut one down, or no-op if it already is; --all for every booted one
11
18
  erase <name|udid> erase one back to factory state; --all for every shut-down one
12
- flags[3]:
19
+ apps <name|udid> the apps installed on it; --system to include Apple's
20
+ install <name|udid> [path.app] install an app; without a path, the one
21
+ this project builds for that simulator
22
+ launch <name|udid> [bundle-id] launch it; without an id, this project's
23
+ terminate <name|udid> [bundle-id] stop it, or no-op if it is not running
24
+ uninstall <name|udid> [bundle-id] remove it
25
+ create <name> <model> create a device; --runtime picks the OS
26
+ delete <name|udid> delete one; --unavailable for every orphaned device
27
+ screenshot <name|udid> [path] save a PNG of its screen
28
+ video <name|udid> [path] record its screen; --seconds sets how long
29
+ open <name|udid> <url> open a URL on it, deep links included
30
+ privacy <name|udid> <grant|revoke|reset> <service> [bundle-id]
31
+ answer a permission prompt before it appears
32
+ push <name|udid> [bundle-id] [payload.json]
33
+ send a push; --message writes the payload for you
34
+ status-bar <name|udid> [pin|clear] freeze the status bar for a screenshot
35
+ ui <name|udid> [light|dark|<setting> <value>] read or set appearance,
36
+ contrast and content size
37
+ flags[14]:
13
38
  --runtime <name> filter the list, e.g. "iOS 26.5"
14
39
  --booted list only booted simulators
15
40
  --all apply shutdown or erase to every eligible simulator
41
+ --system include Apple's own apps in \`apps\`
42
+ --scheme <name> which scheme's app to install or launch
43
+ --relaunch with launch: stop a running copy first
44
+ --unavailable with delete: every device whose runtime is gone
45
+ --seconds <n> with video: how long to record (default: 10)
46
+ --yes required to delete every simulator at once
47
+ --message <text> with push: the alert body to send
48
+ --title <text> with push: the alert title
49
+ --time <string> with status-bar: the clock, e.g. "9:41"
50
+ --battery <0-100> with status-bar: the battery level
51
+ --bars <0-4> with status-bar: wifi and cellular signal strength
52
+ note:
53
+ install, launch, terminate and uninstall take the app as a path or a bundle
54
+ id, and work it out from the project in the current directory when it is
55
+ left off — the point of them is the loop from a build to a running app, and
56
+ a DerivedData path is not something worth typing.
16
57
  examples:
17
58
  xcodebuild-axi sim
18
59
  xcodebuild-axi sim list --runtime "iOS 26.5"
19
60
  xcodebuild-axi sim boot "iPhone 17 Pro"
20
- xcodebuild-axi sim shutdown --all
61
+ xcodebuild-axi sim install "iPhone 17 Pro"
62
+ xcodebuild-axi sim launch "iPhone 17 Pro" --relaunch
63
+ xcodebuild-axi sim apps "iPhone 17 Pro"
64
+ xcodebuild-axi sim screenshot "iPhone 17 Pro"
65
+ xcodebuild-axi sim create "Test iPhone" "iPhone 17 Pro" --runtime "iOS 26.5"
66
+ xcodebuild-axi sim open "iPhone 17 Pro" myapp://checkout
67
+ xcodebuild-axi sim privacy "iPhone 17 Pro" grant photos
68
+ xcodebuild-axi sim status-bar "iPhone 17 Pro" pin
69
+ xcodebuild-axi sim ui "iPhone 17 Pro" dark
21
70
  `;
22
- export const SIM_FLAGS = ["--runtime", "--booted", "--all"];
71
+ export const SIM_FLAGS = [
72
+ "--runtime",
73
+ "--booted",
74
+ "--all",
75
+ "--system",
76
+ "--scheme",
77
+ "--relaunch",
78
+ "--unavailable",
79
+ "--seconds",
80
+ "--yes",
81
+ "--message",
82
+ "--title",
83
+ "--time",
84
+ "--battery",
85
+ "--bars",
86
+ ];
87
+ const VALUE_FLAGS = [
88
+ "--runtime",
89
+ "--scheme",
90
+ "--seconds",
91
+ "--message",
92
+ "--title",
93
+ "--time",
94
+ "--battery",
95
+ "--bars",
96
+ ];
23
97
  export async function simCommand(args) {
24
- rejectUnknownFlags(args, "sim", SIM_FLAGS, ["--runtime"]);
25
- const [subcommand, target] = positionals(args, ["--runtime"]);
98
+ rejectUnknownFlags(args, "sim", SIM_FLAGS, VALUE_FLAGS);
99
+ const [subcommand, target, app, extra] = positionals(args, VALUE_FLAGS);
26
100
  switch (subcommand ?? "booted") {
27
101
  case "booted":
28
102
  return listBooted();
@@ -34,8 +108,38 @@ export async function simCommand(args) {
34
108
  return shutdown(args, target);
35
109
  case "erase":
36
110
  return erase(args, target);
111
+ case "apps":
112
+ return apps(args, target);
113
+ case "install":
114
+ return install(args, target, app);
115
+ case "launch":
116
+ return launch(args, target, app);
117
+ case "terminate":
118
+ return terminate(args, target, app);
119
+ case "uninstall":
120
+ return uninstall(args, target, app);
121
+ case "create":
122
+ return create(args, target, app);
123
+ case "delete":
124
+ return remove(args, target);
125
+ case "screenshot":
126
+ return screenshot(target, app);
127
+ case "video":
128
+ return video(args, target, app);
129
+ case "open":
130
+ return openUrl(target, app);
131
+ case "privacy":
132
+ return privacy(args, target, app, extra);
133
+ case "push":
134
+ return push(args, target, app, extra);
135
+ case "status-bar":
136
+ return statusBar(args, target, app);
137
+ case "ui":
138
+ return ui(target, app, extra);
37
139
  default:
38
- throw new AxiError(`Unknown sim subcommand '${subcommand}'`, "VALIDATION_ERROR", ["valid subcommands are list, boot, shutdown, erase"]);
140
+ throw new AxiError(`Unknown sim subcommand '${subcommand}'`, "VALIDATION_ERROR", [
141
+ "valid subcommands are list, boot, shutdown, erase, apps, install, launch, terminate, uninstall, create, delete, screenshot, video, open, privacy, push, status-bar, ui",
142
+ ]);
39
143
  }
40
144
  }
41
145
  function toRows(simulators) {
@@ -181,6 +285,777 @@ async function erase(args, target) {
181
285
  }
182
286
  return renderFields({ sim: `${simulator.name} erased` });
183
287
  }
288
+ /** What is installed, minus the four dozen apps Apple ships. */
289
+ async function apps(args, target) {
290
+ const simulator = await resolveTarget(target, "apps");
291
+ const installed = await listApps(simulator.udid);
292
+ const wanted = hasFlag(args, "--system")
293
+ ? installed
294
+ : installed.filter((app) => app.type === "user");
295
+ if (wanted.length === 0) {
296
+ return renderOutput([
297
+ renderFields({
298
+ apps: `no apps installed on ${simulator.name}`,
299
+ ...(installed.length > 0 ? { system_apps: installed.length } : {}),
300
+ }),
301
+ renderHelp([
302
+ `Run \`xcodebuild-axi sim install "${simulator.name}"\` to install what this project builds`,
303
+ ...(installed.length > 0
304
+ ? [
305
+ `Run \`xcodebuild-axi sim apps "${simulator.name}" --system\` to include Apple's own`,
306
+ ]
307
+ : []),
308
+ ]),
309
+ ]);
310
+ }
311
+ return renderOutput([
312
+ renderFields({ sim: simulator.name, apps: wanted.length }),
313
+ renderList("apps", wanted.map(appRow)),
314
+ ]);
315
+ }
316
+ export function appRow(app) {
317
+ // Version and build as one field: TOON quotes `"1.0"` and `"1"` because
318
+ // they look like numbers, and two quoted columns cost more than the one
319
+ // readable string they add up to.
320
+ const version = [app.version, app.build && `(${app.build})`]
321
+ .filter((part) => part)
322
+ .join(" ");
323
+ return {
324
+ app: app.name,
325
+ bundle_id: app.bundleId,
326
+ version: version || "unknown",
327
+ };
328
+ }
329
+ async function install(args, target, app) {
330
+ const simulator = await requireBooted(target, "install");
331
+ const path = app
332
+ ? resolve(app)
333
+ : (await productOf(args, simulator, "install")).appPath;
334
+ if (!existsSync(path)) {
335
+ throw new AxiError(`No app bundle at ${tildePath(path)}`, "NOT_FOUND", [
336
+ "Run `xcodebuild-axi build` first — the app has to exist before it can be installed",
337
+ ]);
338
+ }
339
+ const { exitCode, stderr } = await simctl(["install", simulator.udid, path]);
340
+ if (exitCode !== 0) {
341
+ throw new AxiError(`Could not install ${tildePath(path)} on ${simulator.name}`, "UNKNOWN", [firstLine(stderr)]);
342
+ }
343
+ return renderOutput([
344
+ renderFields({
345
+ installed: tildePath(path),
346
+ sim: simulator.name,
347
+ }),
348
+ renderHelp([
349
+ `Run \`xcodebuild-axi sim launch "${simulator.name}"\` to start it`,
350
+ ]),
351
+ ]);
352
+ }
353
+ async function launch(args, target, app) {
354
+ const simulator = await requireBooted(target, "launch");
355
+ const bundleId = app ?? (await productOf(args, simulator, "launch")).bundleId;
356
+ const { stdout, stderr, exitCode } = await simctl([
357
+ "launch",
358
+ ...(hasFlag(args, "--relaunch") ? ["--terminate-running-process"] : []),
359
+ simulator.udid,
360
+ bundleId,
361
+ ]);
362
+ if (exitCode !== 0) {
363
+ // The one failure worth translating: the app is simply not there yet,
364
+ // and the command that fixes it is the one right above this in the loop.
365
+ // simctl's own words are four lines of
366
+ // `FBSOpenApplicationServiceErrorDomain, code=4` that never say "not
367
+ // installed", so the test is whether the app is there rather than what
368
+ // the message said.
369
+ const installed = await listApps(simulator.udid);
370
+ if (!installed.some((entry) => entry.bundleId === bundleId)) {
371
+ throw new AxiError(`'${bundleId}' is not installed on ${simulator.name}`, "NOT_FOUND", [
372
+ `Run \`xcodebuild-axi sim install "${simulator.name}"\` first`,
373
+ `Run \`xcodebuild-axi sim apps "${simulator.name}"\` to see what is installed`,
374
+ ]);
375
+ }
376
+ throw new AxiError(`Could not launch '${bundleId}'`, "UNKNOWN", [
377
+ firstLine(stderr),
378
+ ]);
379
+ }
380
+ // simctl answers "com.example.MyApp: 41234", and the pid is the half worth
381
+ // keeping -- it is what `sim terminate` and a debugger both take.
382
+ const pid = Number(stdout.trim().split(":").pop()?.trim());
383
+ return renderOutput([
384
+ renderFields({
385
+ launched: bundleId,
386
+ sim: simulator.name,
387
+ ...(Number.isFinite(pid) ? { pid } : {}),
388
+ }),
389
+ renderHelp([
390
+ `Run \`xcodebuild-axi sim terminate "${simulator.name}" ${bundleId}\` to stop it`,
391
+ ]),
392
+ ]);
393
+ }
394
+ async function terminate(args, target, app) {
395
+ const simulator = await requireBooted(target, "terminate");
396
+ const bundleId = app ?? (await productOf(args, simulator, "terminate")).bundleId;
397
+ const { exitCode, stderr } = await simctl([
398
+ "terminate",
399
+ simulator.udid,
400
+ bundleId,
401
+ ]);
402
+ // Not running is the state the caller asked for, so it is a no-op rather
403
+ // than a failure (AXI principle 6).
404
+ if (exitCode !== 0) {
405
+ if (/found nothing to terminate/i.test(stderr)) {
406
+ return renderFields({
407
+ sim: `'${bundleId}' was not running on ${simulator.name} (no-op)`,
408
+ });
409
+ }
410
+ throw new AxiError(`Could not terminate '${bundleId}'`, "UNKNOWN", [
411
+ firstLine(stderr),
412
+ ]);
413
+ }
414
+ return renderFields({ terminated: bundleId, sim: simulator.name });
415
+ }
416
+ async function uninstall(args, target, app) {
417
+ const simulator = await requireBooted(target, "uninstall");
418
+ const bundleId = app ?? (await productOf(args, simulator, "uninstall")).bundleId;
419
+ const installed = await listApps(simulator.udid);
420
+ if (!installed.some((entry) => entry.bundleId === bundleId)) {
421
+ return renderFields({
422
+ sim: `'${bundleId}' was not installed on ${simulator.name} (no-op)`,
423
+ });
424
+ }
425
+ const { exitCode, stderr } = await simctl([
426
+ "uninstall",
427
+ simulator.udid,
428
+ bundleId,
429
+ ]);
430
+ if (exitCode !== 0) {
431
+ throw new AxiError(`Could not uninstall '${bundleId}'`, "UNKNOWN", [
432
+ firstLine(stderr),
433
+ ]);
434
+ }
435
+ return renderFields({ uninstalled: bundleId, sim: simulator.name });
436
+ }
437
+ /**
438
+ * A new device.
439
+ *
440
+ * The model is matched by name rather than by the
441
+ * `com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro` identifier simctl
442
+ * documents, because the name is what `sim list` prints and what anyone
443
+ * actually knows.
444
+ */
445
+ async function create(args, name, model) {
446
+ if (name === undefined || model === undefined) {
447
+ throw new AxiError("sim create needs a name and a model", "VALIDATION_ERROR", [
448
+ 'xcodebuild-axi sim create "Test iPhone" "iPhone 17 Pro"',
449
+ "Run `xcodebuild-axi sim list` to see the models already created",
450
+ ]);
451
+ }
452
+ const types = await listDeviceTypes();
453
+ const type = findDeviceType(types, model);
454
+ if (!type) {
455
+ throw new AxiError(`No simulator model named '${model}'`, "NOT_FOUND", [
456
+ `models: ${nearbyModels(types, model).join(", ")}`,
457
+ ]);
458
+ }
459
+ const runtime = getFlag(args, "--runtime");
460
+ const { stdout, stderr, exitCode } = await simctl([
461
+ "create",
462
+ name,
463
+ type.identifier,
464
+ ...(runtime ? [runtime] : []),
465
+ ]);
466
+ if (exitCode !== 0) {
467
+ throw new AxiError(`Could not create '${name}'`, "UNKNOWN", [
468
+ firstLine(stderr),
469
+ ...(runtime
470
+ ? ["Run `xcodebuild-axi platforms` to see the runtimes installed"]
471
+ : []),
472
+ ]);
473
+ }
474
+ const udid = stdout.trim();
475
+ return renderOutput([
476
+ renderFields({ created: name, model: type.name, udid }),
477
+ renderHelp([`Run \`xcodebuild-axi sim boot "${name}"\` to start it`]),
478
+ ]);
479
+ }
480
+ /** The models closest to what was asked for, for a refusal worth reading. */
481
+ function nearbyModels(types, query) {
482
+ const wanted = query.toLowerCase().split(/\s+/)[0] ?? "";
483
+ const near = types.filter((type) => type.name.toLowerCase().includes(wanted));
484
+ return (near.length > 0 ? near : types).slice(0, 12).map((type) => type.name);
485
+ }
486
+ /**
487
+ * Deleting is the one simulator operation that cannot be undone -- a device
488
+ * is gigabytes of state, and recreating it is not the same device. So the
489
+ * blanket form needs `--yes` on top of `--all`, the way `migrate` does.
490
+ */
491
+ async function remove(args, target) {
492
+ if (hasFlag(args, "--unavailable")) {
493
+ const { exitCode, stderr } = await simctl(["delete", "unavailable"]);
494
+ if (exitCode !== 0) {
495
+ throw new AxiError("Could not delete unavailable devices", "UNKNOWN", [
496
+ firstLine(stderr),
497
+ ]);
498
+ }
499
+ return renderFields({
500
+ sim: "every device whose runtime is gone has been deleted",
501
+ });
502
+ }
503
+ if (hasFlag(args, "--all")) {
504
+ // Refused before anything is looked up, let alone deleted: a refusal that
505
+ // first spends a subprocess counting what it is about to refuse to touch
506
+ // is slower than the answer and no more useful.
507
+ if (!hasFlag(args, "--yes")) {
508
+ throw new AxiError("--all deletes every simulator on this machine, which cannot be undone", "VALIDATION_ERROR", [
509
+ "Pass --yes as well if that is what you meant",
510
+ "`xcodebuild-axi sim delete --unavailable` removes only the orphaned ones",
511
+ ]);
512
+ }
513
+ const all = await listSimulators();
514
+ const { exitCode, stderr } = await simctl(["delete", "all"]);
515
+ if (exitCode !== 0) {
516
+ throw new AxiError("Could not delete the simulators", "UNKNOWN", [
517
+ firstLine(stderr),
518
+ ]);
519
+ }
520
+ return renderFields({ deleted: all.length });
521
+ }
522
+ const simulator = await resolveTarget(target, "delete");
523
+ const { exitCode, stderr } = await simctl(["delete", simulator.udid]);
524
+ if (exitCode !== 0) {
525
+ throw new AxiError(`Could not delete ${simulator.name}`, "UNKNOWN", [
526
+ firstLine(stderr),
527
+ ]);
528
+ }
529
+ return renderFields({ deleted: simulator.name, udid: simulator.udid });
530
+ }
531
+ /** A PNG of what the screen looks like right now. */
532
+ async function screenshot(target, path) {
533
+ const simulator = await requireBooted(target, "screenshot");
534
+ const file = path ? resolve(path) : capturePath(simulator.name, "png");
535
+ mkdirSync(dirname(file), { recursive: true });
536
+ const { exitCode, stderr } = await simctl([
537
+ "io",
538
+ simulator.udid,
539
+ "screenshot",
540
+ file,
541
+ ]);
542
+ if (exitCode !== 0) {
543
+ throw new AxiError(`Could not photograph ${simulator.name}`, "UNKNOWN", [
544
+ firstLine(stderr),
545
+ ]);
546
+ }
547
+ return renderFields({
548
+ screenshot: tildePath(file),
549
+ sim: simulator.name,
550
+ size: byteSize(sizeOf(file)),
551
+ });
552
+ }
553
+ /**
554
+ * A recording of the screen, for a fixed length.
555
+ *
556
+ * `simctl io recordVideo` records until it is sent SIGINT, which is a
557
+ * contract for a person at a terminal rather than for a caller: an agent has
558
+ * no way to press Control-C halfway through its own subprocess. So the
559
+ * duration is a flag, and the interrupt is this command's job.
560
+ */
561
+ async function video(args, target, path) {
562
+ const simulator = await requireBooted(target, "video");
563
+ const seconds = getIntFlag(args, "--seconds") ?? 10;
564
+ if (seconds <= 0) {
565
+ throw new AxiError("--seconds has to be a positive number of seconds", "VALIDATION_ERROR", ["xcodebuild-axi sim video <name|udid> --seconds 10"]);
566
+ }
567
+ const file = path ? resolve(path) : capturePath(simulator.name, "mov");
568
+ mkdirSync(dirname(file), { recursive: true });
569
+ const recorded = await record(simulator.udid, file, seconds);
570
+ if (!recorded) {
571
+ throw new AxiError(`Could not record ${simulator.name}`, "UNKNOWN", [
572
+ "Check that the simulator window is not minimized or asleep",
573
+ ]);
574
+ }
575
+ return renderFields({
576
+ video: tildePath(file),
577
+ sim: simulator.name,
578
+ duration: duration(seconds),
579
+ size: byteSize(sizeOf(file)),
580
+ });
581
+ }
582
+ function record(udid, file, seconds) {
583
+ return new Promise((resolvePromise) => {
584
+ const child = spawn("xcrun", ["simctl", "io", udid, "recordVideo", "--force", file], { stdio: ["ignore", "ignore", "pipe"] });
585
+ // simctl writes "Recording started" once the first frame is in, so the
586
+ // clock starts there rather than at spawn -- otherwise a slow start eats
587
+ // the seconds that were asked for.
588
+ let started = false;
589
+ const stop = () => {
590
+ child.kill("SIGINT");
591
+ };
592
+ child.stderr?.on("data", (chunk) => {
593
+ if (started || !/Recording started/i.test(chunk.toString()))
594
+ return;
595
+ started = true;
596
+ setTimeout(stop, seconds * 1000);
597
+ });
598
+ // A recording that never starts still has to end, so the wait is capped.
599
+ const failsafe = setTimeout(stop, (seconds + 10) * 1000);
600
+ child.on("close", () => {
601
+ clearTimeout(failsafe);
602
+ resolvePromise(existsSync(file) && sizeOf(file) > 0);
603
+ });
604
+ child.on("error", () => {
605
+ clearTimeout(failsafe);
606
+ resolvePromise(false);
607
+ });
608
+ });
609
+ }
610
+ /** Open a URL on the device, which is how a deep link gets tested. */
611
+ async function openUrl(target, url) {
612
+ const simulator = await requireBooted(target, "open");
613
+ if (url === undefined) {
614
+ throw new AxiError("sim open needs a URL", "VALIDATION_ERROR", [
615
+ `xcodebuild-axi sim open "${simulator.name}" myapp://checkout`,
616
+ ]);
617
+ }
618
+ const { exitCode, stderr } = await simctl(["openurl", simulator.udid, url]);
619
+ if (exitCode !== 0) {
620
+ throw new AxiError(`Could not open ${url}`, "UNKNOWN", [
621
+ firstLine(stderr),
622
+ "A scheme no installed app claims is refused by the device, not by this tool",
623
+ ]);
624
+ }
625
+ return renderFields({ opened: url, sim: simulator.name });
626
+ }
627
+ /** The services simctl will grant, revoke or reset. */
628
+ const PRIVACY_SERVICES = [
629
+ "all",
630
+ "calendar",
631
+ "contacts-limited",
632
+ "contacts",
633
+ "location",
634
+ "location-always",
635
+ "photos-add",
636
+ "photos",
637
+ "media-library",
638
+ "microphone",
639
+ "motion",
640
+ "reminders",
641
+ "siri",
642
+ ];
643
+ const PRIVACY_ACTIONS = ["grant", "revoke", "reset"];
644
+ /**
645
+ * Permissions, granted ahead of a UI test rather than tapped through.
646
+ *
647
+ * The bundle id is optional for the same reason it is on `launch`: the
648
+ * project in the current directory already knows it, and a permission grant
649
+ * for the wrong app looks exactly like a test that still fails.
650
+ */
651
+ async function privacy(args, target, action, service) {
652
+ const simulator = await requireBooted(target, "privacy");
653
+ if (action === undefined ||
654
+ !PRIVACY_ACTIONS.includes(action)) {
655
+ throw new AxiError(action === undefined
656
+ ? "sim privacy needs an action"
657
+ : `'${action}' is not something that can be done to a permission`, "VALIDATION_ERROR", [`actions: ${PRIVACY_ACTIONS.join(", ")}`]);
658
+ }
659
+ if (service === undefined ||
660
+ !PRIVACY_SERVICES.includes(service)) {
661
+ throw new AxiError(service === undefined
662
+ ? "sim privacy needs a service"
663
+ : `simctl has no permission called '${service}'`, "VALIDATION_ERROR", [`services: ${PRIVACY_SERVICES.join(", ")}`]);
664
+ }
665
+ // `reset` is the one action simctl takes without an app, and resetting
666
+ // every app's permissions is a different thing from resetting one app's.
667
+ const bundleId = positionals(args, VALUE_FLAGS)[4] ??
668
+ (action === "reset"
669
+ ? undefined
670
+ : (await productOf(args, simulator, "privacy")).bundleId);
671
+ const { exitCode, stderr } = await simctl([
672
+ "privacy",
673
+ simulator.udid,
674
+ action,
675
+ service,
676
+ ...(bundleId ? [bundleId] : []),
677
+ ]);
678
+ if (exitCode !== 0) {
679
+ throw new AxiError(`Could not ${action} ${service} on ${simulator.name}`, "UNKNOWN", [firstLine(stderr)]);
680
+ }
681
+ const past = { grant: "granted", revoke: "revoked", reset: "reset" };
682
+ return renderFields({
683
+ privacy: `${service} ${past[action]}`,
684
+ ...(bundleId ? { app: bundleId } : { apps: "all" }),
685
+ sim: simulator.name,
686
+ });
687
+ }
688
+ /**
689
+ * A push notification, without hand-writing an APNs payload.
690
+ *
691
+ * simctl takes a JSON file holding a valid `aps` dictionary, which is three
692
+ * lines of boilerplate around the one line anyone is testing. `--message`
693
+ * writes that file; a payload path is still accepted for the cases that need
694
+ * the real thing.
695
+ */
696
+ async function push(args, target, first, second) {
697
+ const simulator = await requireBooted(target, "push");
698
+ const message = getFlag(args, "--message");
699
+ const title = getFlag(args, "--title");
700
+ // `sim push <device> [bundle-id] [payload.json]`, in either order: one of
701
+ // the two has a path in it and the other does not, so there is nothing to
702
+ // ask the caller to remember.
703
+ const given = [first, second].filter((v) => v !== undefined);
704
+ const payload = given.find(isPayloadPath);
705
+ const named = given.find((value) => value !== payload);
706
+ if (payload === undefined && message === undefined) {
707
+ throw new AxiError("sim push needs something to send", "VALIDATION_ERROR", [
708
+ `xcodebuild-axi sim push "${simulator.name}" --message "Your order shipped"`,
709
+ `xcodebuild-axi sim push "${simulator.name}" payload.json`,
710
+ ]);
711
+ }
712
+ if (payload !== undefined && message !== undefined) {
713
+ throw new AxiError("A payload file and --message are two different notifications", "VALIDATION_ERROR", ["Drop one — a payload file already carries its own alert text"]);
714
+ }
715
+ const bundleId = named ?? (await productOf(args, simulator, "push")).bundleId;
716
+ const file = payload
717
+ ? resolve(payload)
718
+ : writePayload(simulator.name, title, message ?? "");
719
+ if (!existsSync(file)) {
720
+ throw new AxiError(`No payload at ${tildePath(file)}`, "NOT_FOUND", [
721
+ "A push payload is a JSON file with an `aps` dictionary in it",
722
+ ]);
723
+ }
724
+ // simctl will happily deliver a push addressed to an app that is not
725
+ // installed and exit 0, which looks exactly like a notification the app
726
+ // ignored. Checking first turns that silence into a sentence.
727
+ const installed = await listApps(simulator.udid);
728
+ if (!installed.some((entry) => entry.bundleId === bundleId)) {
729
+ throw new AxiError(`'${bundleId}' is not installed on ${simulator.name}`, "NOT_FOUND", [
730
+ `Run \`xcodebuild-axi sim install "${simulator.name}"\` first`,
731
+ `Run \`xcodebuild-axi sim apps "${simulator.name}"\` to see what is installed`,
732
+ ]);
733
+ }
734
+ const { exitCode, stderr } = await simctl([
735
+ "push",
736
+ simulator.udid,
737
+ bundleId,
738
+ file,
739
+ ]);
740
+ if (exitCode !== 0) {
741
+ throw new AxiError(`Could not send the push to '${bundleId}'`, "UNKNOWN", [
742
+ firstLine(stderr),
743
+ "A payload has to parse as JSON, carry an `aps` dictionary, and stay under 4096 bytes",
744
+ ]);
745
+ }
746
+ return renderFields({
747
+ pushed: bundleId,
748
+ sim: simulator.name,
749
+ ...(message ? { message } : { payload: tildePath(file) }),
750
+ });
751
+ }
752
+ /** A payload is the argument with a path in it; a bundle id is the other one. */
753
+ export function isPayloadPath(value) {
754
+ return value.includes("/") || value.toLowerCase().endsWith(".json");
755
+ }
756
+ function writePayload(device, title, body) {
757
+ const file = capturePath(device, "json");
758
+ mkdirSync(dirname(file), { recursive: true });
759
+ writeFileSync(file, JSON.stringify({ aps: { alert: title ? { title, body } : body, sound: "default" } }, null, 2));
760
+ return file;
761
+ }
762
+ /**
763
+ * `sim ui <device> dark` rather than `sim ui <device> appearance dark`: the
764
+ * setting is unambiguous from the value, and the short form is the one anyone
765
+ * types. Everything else names the setting first, the way simctl does.
766
+ */
767
+ export function uiSetting(setting, value) {
768
+ if (setting === "light" || setting === "dark")
769
+ return ["appearance", setting];
770
+ const option = UI_SETTINGS[setting];
771
+ if (option === undefined) {
772
+ throw new AxiError(`simctl has no ui setting called '${setting}'`, "VALIDATION_ERROR", [
773
+ "settings: appearance, contrast, size",
774
+ "`xcodebuild-axi sim ui <name|udid> dark` is shorthand for appearance",
775
+ ]);
776
+ }
777
+ return [option, value];
778
+ }
779
+ /** The screenshot-test status bar: 9:41, full bars, full battery. */
780
+ const PINNED_STATUS_BAR = [
781
+ "--time",
782
+ "9:41",
783
+ "--dataNetwork",
784
+ "wifi",
785
+ "--wifiMode",
786
+ "active",
787
+ "--wifiBars",
788
+ "3",
789
+ "--cellularMode",
790
+ "active",
791
+ "--cellularBars",
792
+ "4",
793
+ "--batteryState",
794
+ "charged",
795
+ "--batteryLevel",
796
+ "100",
797
+ ];
798
+ /**
799
+ * A status bar that does not change between runs, which is what makes two
800
+ * screenshots comparable. `pin` is the set everyone means: 9:41 and
801
+ * everything full, the way Apple's own marketing shots are.
802
+ */
803
+ async function statusBar(args, target, action) {
804
+ const simulator = await requireBooted(target, "status-bar");
805
+ const overrides = statusOverrides(args);
806
+ if (action === "clear") {
807
+ const { exitCode, stderr } = await simctl([
808
+ "status_bar",
809
+ simulator.udid,
810
+ "clear",
811
+ ]);
812
+ if (exitCode !== 0) {
813
+ throw new AxiError("Could not clear the status bar", "UNKNOWN", [
814
+ firstLine(stderr),
815
+ ]);
816
+ }
817
+ return renderFields({ status_bar: "cleared", sim: simulator.name });
818
+ }
819
+ if (action === undefined && overrides.length === 0) {
820
+ const { stdout } = await simctl(["status_bar", simulator.udid, "list"]);
821
+ const set = statusRows(stdout);
822
+ const count = Object.keys(set).length;
823
+ return renderOutput([
824
+ renderFields({
825
+ status_bar: count > 0 ? `${count} overrides` : "not overridden",
826
+ sim: simulator.name,
827
+ ...set,
828
+ }),
829
+ renderHelp([
830
+ count > 0
831
+ ? `Run \`xcodebuild-axi sim status-bar "${simulator.name}" clear\` to hand the status bar back to the simulator`
832
+ : `Run \`xcodebuild-axi sim status-bar "${simulator.name}" pin\` to freeze it for a screenshot`,
833
+ ]),
834
+ ]);
835
+ }
836
+ if (action !== undefined && action !== "pin") {
837
+ throw new AxiError(`'${action}' is not something sim status-bar does`, "VALIDATION_ERROR", ["pin freezes it, clear undoes that, and no argument reports it"]);
838
+ }
839
+ const applied = action === "pin" ? [...PINNED_STATUS_BAR, ...overrides] : overrides;
840
+ const { exitCode, stderr } = await simctl([
841
+ "status_bar",
842
+ simulator.udid,
843
+ "override",
844
+ ...applied,
845
+ ]);
846
+ if (exitCode !== 0) {
847
+ throw new AxiError("Could not override the status bar", "UNKNOWN", [
848
+ firstLine(stderr),
849
+ ]);
850
+ }
851
+ return renderOutput([
852
+ renderFields({
853
+ status_bar: action === "pin" ? "pinned" : "overridden",
854
+ sim: simulator.name,
855
+ }),
856
+ renderHelp(action === "pin"
857
+ ? [
858
+ "9:41 with full signal and a full battery, so two screenshots differ only where the app does",
859
+ ]
860
+ : []),
861
+ ]);
862
+ }
863
+ /**
864
+ * simctl reports its overrides as integers — `Battery State: 2` — and prints
865
+ * the words only in its own `--help`. Verified against every value simctl
866
+ * accepts, so the report reads back in the vocabulary the flags are written
867
+ * in rather than in enum ordinals.
868
+ */
869
+ const STATUS_WORDS = {
870
+ data_network: {
871
+ "0": "wifi",
872
+ "6": "3g",
873
+ "7": "4g",
874
+ "8": "lte",
875
+ "9": "lte-a",
876
+ "10": "lte+",
877
+ "11": "5g",
878
+ "12": "5g+",
879
+ "13": "5g-uwb",
880
+ "14": "5g-uc",
881
+ },
882
+ wifi_mode: {
883
+ "0": "not-supported",
884
+ "1": "searching",
885
+ "2": "failed",
886
+ "3": "active",
887
+ },
888
+ cell_mode: {
889
+ "0": "not-supported",
890
+ "1": "searching",
891
+ "2": "failed",
892
+ "3": "active",
893
+ },
894
+ battery_state: { "0": "discharging", "1": "charging", "2": "charged" },
895
+ };
896
+ /**
897
+ * simctl reports its overrides as prose — one line per group, several
898
+ * `Key: Value` pairs to a line. Flattening them into fields is what makes the
899
+ * answer readable next to the flags that set them.
900
+ */
901
+ export function statusRows(stdout) {
902
+ const rows = {};
903
+ for (const line of stdout.split("\n")) {
904
+ if (!line.includes(":") || line.trimStart().startsWith("Current"))
905
+ continue;
906
+ for (const pair of line.split(",")) {
907
+ const [name, ...rest] = pair.split(":");
908
+ const value = rest.join(":").trim();
909
+ if (name === undefined || value === "")
910
+ continue;
911
+ const key = name
912
+ .trim()
913
+ .replace(/([a-z])([A-Z])/g, "$1_$2")
914
+ .replace(/ /g, "_")
915
+ .toLowerCase()
916
+ .replace("wi_fi", "wifi")
917
+ .replace("data_network_type", "data_network");
918
+ const word = STATUS_WORDS[key]?.[value];
919
+ // Numbers stay numbers: TOON quotes a numeric-looking string, and the
920
+ // quotes cost more than the digits.
921
+ rows[key] = word ?? (/^\d+$/.test(value) ? Number(value) : value);
922
+ }
923
+ }
924
+ return rows;
925
+ }
926
+ /** The individual overrides, for the cases `pin` does not cover. */
927
+ export function statusOverrides(args) {
928
+ const time = getFlag(args, "--time");
929
+ const battery = getIntFlag(args, "--battery");
930
+ const bars = getIntFlag(args, "--bars");
931
+ if (battery !== undefined && (battery < 0 || battery > 100)) {
932
+ throw new AxiError("A battery level is a percentage from 0 to 100", "VALIDATION_ERROR", ["xcodebuild-axi sim status-bar <name|udid> --battery 100"]);
933
+ }
934
+ if (bars !== undefined && (bars < 0 || bars > 4)) {
935
+ throw new AxiError("Signal strength runs from 0 to 4 bars", "VALIDATION_ERROR", [
936
+ "wifi tops out at 3 bars and cellular at 4, which is simctl's own range",
937
+ ]);
938
+ }
939
+ return [
940
+ ...(time ? ["--time", time] : []),
941
+ ...(battery !== undefined
942
+ ? ["--batteryState", "charged", "--batteryLevel", String(battery)]
943
+ : []),
944
+ ...(bars !== undefined
945
+ ? [
946
+ "--wifiMode",
947
+ "active",
948
+ "--wifiBars",
949
+ String(Math.min(bars, 3)),
950
+ "--cellularMode",
951
+ "active",
952
+ "--cellularBars",
953
+ String(bars),
954
+ ]
955
+ : []),
956
+ ];
957
+ }
958
+ /** simctl's own names for the three settings `ui` reads and writes. */
959
+ const UI_SETTINGS = {
960
+ appearance: "appearance",
961
+ contrast: "increase_contrast",
962
+ increase_contrast: "increase_contrast",
963
+ size: "content_size",
964
+ content_size: "content_size",
965
+ };
966
+ /**
967
+ * Appearance, contrast and content size — the three things a screenshot test
968
+ * varies. `sim ui <device> dark` is the shorthand for the one that is asked
969
+ * for ten times as often as the others.
970
+ */
971
+ async function ui(target, setting, value) {
972
+ const simulator = await requireBooted(target, "ui");
973
+ if (setting === undefined) {
974
+ const read = async (option) => (await simctl(["ui", simulator.udid, option])).stdout.trim();
975
+ return renderFields({
976
+ sim: simulator.name,
977
+ appearance: await read("appearance"),
978
+ contrast: await read("increase_contrast"),
979
+ content_size: await read("content_size"),
980
+ });
981
+ }
982
+ const [option, wanted] = uiSetting(setting, value);
983
+ const { stdout, stderr, exitCode } = await simctl([
984
+ "ui",
985
+ simulator.udid,
986
+ option,
987
+ ...(wanted ? [wanted] : []),
988
+ ]);
989
+ if (exitCode !== 0) {
990
+ throw new AxiError(`Could not set ${option}`, "VALIDATION_ERROR", [
991
+ firstLine(stderr),
992
+ ]);
993
+ }
994
+ return renderFields({
995
+ sim: simulator.name,
996
+ [option]: wanted ?? stdout.trim(),
997
+ });
998
+ }
999
+ function sizeOf(path) {
1000
+ try {
1001
+ return statSync(path).size;
1002
+ }
1003
+ catch {
1004
+ return 0;
1005
+ }
1006
+ }
1007
+ /**
1008
+ * simctl talks to a running device only: every app operation against a
1009
+ * shut-down one fails with "Invalid device state", which reads like a bug in
1010
+ * the caller rather than a missing step.
1011
+ */
1012
+ async function requireBooted(target, verb) {
1013
+ const simulator = await resolveTarget(target, verb);
1014
+ if (simulator.state !== "booted") {
1015
+ throw new AxiError(`${simulator.name} is not booted, so nothing can be ${verb}ed on it`, "VALIDATION_ERROR", [`Run \`xcodebuild-axi sim boot "${simulator.name}"\` first`]);
1016
+ }
1017
+ return simulator;
1018
+ }
1019
+ /**
1020
+ * The app this project builds for this simulator.
1021
+ *
1022
+ * The alternative is making the caller paste a DerivedData path or a bundle
1023
+ * id it would have to go and look up, which is most of the friction that
1024
+ * keeps `install` and `launch` out of an agent's loop. `-showBuildSettings`
1025
+ * against the simulator's own destination answers both at once, and answers
1026
+ * them for the right platform -- `BUILT_PRODUCTS_DIR` is
1027
+ * `Debug-iphonesimulator` here and `Debug-iphoneos` without a destination.
1028
+ */
1029
+ async function productOf(args, simulator, verb) {
1030
+ const project = resolveProject();
1031
+ if (!project) {
1032
+ throw new AxiError(`sim ${verb} needs an app, and there is no project here to work one out from`, "NO_PROJECT", [
1033
+ `xcodebuild-axi sim ${verb} "${simulator.name}" <${verb === "install" ? "path.app" : "bundle-id"}>`,
1034
+ "cd to the directory holding the workspace or project, then re-run",
1035
+ ]);
1036
+ }
1037
+ const scheme = await requireScheme(project, getFlag(args, "--scheme"), `sim ${verb}`);
1038
+ const { stdout, exitCode, stderr } = await runMetadata([
1039
+ ...project.flags,
1040
+ "-scheme",
1041
+ scheme,
1042
+ "-destination",
1043
+ `id=${simulator.udid}`,
1044
+ "-showBuildSettings",
1045
+ "-json",
1046
+ ]);
1047
+ const settings = exitCode === 0 ? parseSettings(stdout) : {};
1048
+ const dir = settings["BUILT_PRODUCTS_DIR"];
1049
+ const product = settings["FULL_PRODUCT_NAME"];
1050
+ const bundleId = settings["PRODUCT_BUNDLE_IDENTIFIER"];
1051
+ if (!dir || !product || !bundleId) {
1052
+ throw new AxiError(`Could not work out which app '${scheme}' builds for ${simulator.name}`, "VALIDATION_ERROR", [
1053
+ `xcodebuild-axi sim ${verb} "${simulator.name}" <${verb === "install" ? "path.app" : "bundle-id"}>`,
1054
+ ...(exitCode !== 0 ? [firstLine(stderr)] : []),
1055
+ ]);
1056
+ }
1057
+ return { appPath: `${dir}/${product}`, bundleId };
1058
+ }
184
1059
  function firstLine(text) {
185
1060
  return text.trim().split("\n")[0] ?? "";
186
1061
  }