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.
- package/CHANGELOG.md +84 -1
- package/README.md +7 -27
- package/dist/src/commands/settings.d.ts +6 -0
- package/dist/src/commands/settings.js +1 -1
- package/dist/src/commands/settings.js.map +1 -1
- package/dist/src/commands/sim.d.ts +20 -2
- package/dist/src/commands/sim.js +886 -11
- package/dist/src/commands/sim.js.map +1 -1
- package/dist/src/simctl.d.ts +29 -0
- package/dist/src/simctl.js +76 -0
- package/dist/src/simctl.js.map +1 -1
- package/dist/src/surface.js +13 -53
- package/dist/src/surface.js.map +1 -1
- package/dist/src/xcodebuild.d.ts +8 -0
- package/dist/src/xcodebuild.js +12 -0
- package/dist/src/xcodebuild.js.map +1 -1
- package/package.json +1 -1
package/dist/src/commands/sim.js
CHANGED
|
@@ -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 {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
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[
|
|
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
|
-
|
|
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
|
|
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 = [
|
|
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,
|
|
25
|
-
const [subcommand, target] = positionals(args,
|
|
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", [
|
|
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
|
}
|