@ramarivera/chofi 0.1.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.
- package/README.md +257 -0
- package/dist/cli.d.ts +18 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +1326 -0
- package/dist/config.d.ts +10 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +20 -0
- package/dist/discovery.d.ts +44 -0
- package/dist/discovery.d.ts.map +1 -0
- package/dist/discovery.js +151 -0
- package/dist/drivers/apple.d.ts +68 -0
- package/dist/drivers/apple.d.ts.map +1 -0
- package/dist/drivers/apple.js +360 -0
- package/dist/drivers/expo.d.ts +14 -0
- package/dist/drivers/expo.d.ts.map +1 -0
- package/dist/drivers/expo.js +42 -0
- package/dist/drivers/idb.d.ts +38 -0
- package/dist/drivers/idb.d.ts.map +1 -0
- package/dist/drivers/idb.js +52 -0
- package/dist/drivers/maestro.d.ts +37 -0
- package/dist/drivers/maestro.d.ts.map +1 -0
- package/dist/drivers/maestro.js +64 -0
- package/dist/drivers/types.d.ts +23 -0
- package/dist/drivers/types.d.ts.map +1 -0
- package/dist/drivers/types.js +1 -0
- package/dist/errors.d.ts +31 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +59 -0
- package/dist/events.d.ts +33 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +26 -0
- package/dist/executor.d.ts +11 -0
- package/dist/executor.d.ts.map +1 -0
- package/dist/executor.js +17 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +13 -0
- package/dist/planning.d.ts +18 -0
- package/dist/planning.d.ts.map +1 -0
- package/dist/planning.js +75 -0
- package/dist/runtime.d.ts +157 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +650 -0
- package/dist/safety.d.ts +8 -0
- package/dist/safety.d.ts.map +1 -0
- package/dist/safety.js +84 -0
- package/dist/spawn.d.ts +30 -0
- package/dist/spawn.d.ts.map +1 -0
- package/dist/spawn.js +178 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +64 -0
- package/sophy.png +0 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1326 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command, Option } from "commander";
|
|
3
|
+
import { createChofiEvent, NdjsonEventWriter } from "./events.js";
|
|
4
|
+
import { discoverMobileProjectContext } from "./discovery.js";
|
|
5
|
+
import { createCommandRunner, summarizeOutput } from "./executor.js";
|
|
6
|
+
import { createIosRunPlan, createMaestroPlan, doctorChecks } from "./planning.js";
|
|
7
|
+
import { RuntimeController } from "./runtime.js";
|
|
8
|
+
import { isConfirmed, requireConfirmation } from "./safety.js";
|
|
9
|
+
import { readConfig, writeConfig } from "./config.js";
|
|
10
|
+
import { ChofiError, classifyError, ToolOutputError } from "./errors.js";
|
|
11
|
+
class CliError extends Error {
|
|
12
|
+
exitCode;
|
|
13
|
+
phase;
|
|
14
|
+
constructor(message, exitCode, phase) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.exitCode = exitCode;
|
|
17
|
+
this.phase = phase;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function createProgram(ctx, deps) {
|
|
21
|
+
const program = new Command("chofi")
|
|
22
|
+
.description("Sophia's mobile control surface")
|
|
23
|
+
.configureOutput({ writeOut: () => { }, writeErr: () => { } })
|
|
24
|
+
.exitOverride();
|
|
25
|
+
const globalJson = new Option("--json", "Emit NDJSON events (required for all commands)")
|
|
26
|
+
.makeOptionMandatory(true);
|
|
27
|
+
program.addOption(globalJson);
|
|
28
|
+
// === context ===
|
|
29
|
+
program
|
|
30
|
+
.command("context")
|
|
31
|
+
.description("Show project metadata and tool availability")
|
|
32
|
+
.action(async () => {
|
|
33
|
+
const discovered = ctx.context();
|
|
34
|
+
ctx.writer.write(createChofiEvent({
|
|
35
|
+
clock: ctx.clock,
|
|
36
|
+
event: "summary",
|
|
37
|
+
phase: "context",
|
|
38
|
+
status: "passed",
|
|
39
|
+
data: discovered
|
|
40
|
+
}));
|
|
41
|
+
});
|
|
42
|
+
// === doctor ===
|
|
43
|
+
program
|
|
44
|
+
.command("doctor")
|
|
45
|
+
.description("Run safe health checks")
|
|
46
|
+
.action(async () => {
|
|
47
|
+
const code = await runDoctorCommand({
|
|
48
|
+
clock: ctx.clock,
|
|
49
|
+
context: ctx.context(),
|
|
50
|
+
runner: ctx.runner,
|
|
51
|
+
writer: ctx.writer
|
|
52
|
+
});
|
|
53
|
+
if (code !== 0)
|
|
54
|
+
throw new CliError("", code);
|
|
55
|
+
});
|
|
56
|
+
// === plan ===
|
|
57
|
+
const planCmd = program.command("plan").description("Non-executing command plans");
|
|
58
|
+
planCmd
|
|
59
|
+
.command("run ios")
|
|
60
|
+
.description("Show what an iOS run would do")
|
|
61
|
+
.action(async () => {
|
|
62
|
+
const plan = createIosRunPlan(ctx.context());
|
|
63
|
+
emitPlan(ctx, plan);
|
|
64
|
+
});
|
|
65
|
+
planCmd
|
|
66
|
+
.command("maestro")
|
|
67
|
+
.description("Show what Maestro flows would run")
|
|
68
|
+
.action(async () => {
|
|
69
|
+
const plan = createMaestroPlan(ctx.context());
|
|
70
|
+
emitPlan(ctx, plan);
|
|
71
|
+
});
|
|
72
|
+
// === build / test / clean ===
|
|
73
|
+
const buildOpts = [
|
|
74
|
+
new Option("--scheme <scheme>", "Xcode scheme name").default(process.env.CHOFI_SCHEME, "from CHOFI_SCHEME env var"),
|
|
75
|
+
new Option("--workspace <path>", "Xcode workspace path").default(process.env.CHOFI_WORKSPACE, "from CHOFI_WORKSPACE env var"),
|
|
76
|
+
new Option("--project <path>", "Xcode project path").default(process.env.CHOFI_PROJECT, "from CHOFI_PROJECT env var"),
|
|
77
|
+
new Option("--destination <dest>", "xcodebuild destination").default(process.env.CHOFI_DESTINATION, "from CHOFI_DESTINATION env var")
|
|
78
|
+
];
|
|
79
|
+
program
|
|
80
|
+
.command("build")
|
|
81
|
+
.description("Build the project via xcodebuild")
|
|
82
|
+
.addOption(buildOpts[0])
|
|
83
|
+
.addOption(buildOpts[1])
|
|
84
|
+
.addOption(buildOpts[2])
|
|
85
|
+
.addOption(buildOpts[3])
|
|
86
|
+
.option("--progress", "Stream build phases as they happen")
|
|
87
|
+
.action(async (options) => {
|
|
88
|
+
const scheme = options.scheme;
|
|
89
|
+
if (!scheme) {
|
|
90
|
+
ctx.writer.write(createChofiEvent({ clock: ctx.clock, event: "summary", phase: "build", status: "failed", data: { message: "--scheme is required (or set CHOFI_SCHEME)" } }));
|
|
91
|
+
throw new CliError("", 2);
|
|
92
|
+
}
|
|
93
|
+
if (options.progress) {
|
|
94
|
+
ctx.writer.write(createChofiEvent({ clock: ctx.clock, event: "command_started", phase: "build", data: { cwd: ctx.context().mobilePath, scheme } }));
|
|
95
|
+
try {
|
|
96
|
+
const c = ctx.context();
|
|
97
|
+
await ctx.runtime.buildWithProgress(c.mobilePath, scheme, (phase) => {
|
|
98
|
+
ctx.writer.write(createChofiEvent({ clock: ctx.clock, event: "command_completed", phase: "build.progress", status: "passed", data: phase }));
|
|
99
|
+
}, { destination: options.destination, workspace: options.workspace, project: options.project });
|
|
100
|
+
ctx.writer.write(createChofiEvent({ clock: ctx.clock, event: "command_completed", phase: "build", status: "passed", data: {} }));
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
const chofiError = error instanceof ChofiError ? error : new ChofiError(error instanceof Error ? error.message : String(error), "UNKNOWN_ERROR");
|
|
104
|
+
ctx.writer.write(createChofiEvent({ clock: ctx.clock, event: "command_failed", phase: "build", status: "failed", errorType: classifyError(chofiError).code, data: { message: chofiError.message, recoverySuggestion: classifyError(chofiError).recoverySuggestion } }));
|
|
105
|
+
throw new CliError("", 1);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
await runExecution({
|
|
110
|
+
clock: ctx.clock,
|
|
111
|
+
writer: ctx.writer,
|
|
112
|
+
phase: "build",
|
|
113
|
+
action: async () => {
|
|
114
|
+
const c = ctx.context();
|
|
115
|
+
await ctx.runtime.build(c.mobilePath, scheme, { destination: options.destination, workspace: options.workspace, project: options.project });
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
const testCmd = program
|
|
121
|
+
.command("test")
|
|
122
|
+
.description("Run tests via xcodebuild")
|
|
123
|
+
.addOption(buildOpts[0])
|
|
124
|
+
.addOption(buildOpts[1])
|
|
125
|
+
.addOption(buildOpts[2])
|
|
126
|
+
.addOption(buildOpts[3])
|
|
127
|
+
.option("--with-results", "Return structured test results via xcresulttool")
|
|
128
|
+
.option("--only <tests...>", "Run only specific tests (e.g., MyTests/LoginTests)")
|
|
129
|
+
.option("--skip <tests...>", "Skip specific tests")
|
|
130
|
+
.option("--retry", "Retry failed tests once")
|
|
131
|
+
.option("--progress", "Stream test progress in real-time")
|
|
132
|
+
.action(async (options) => {
|
|
133
|
+
const scheme = options.scheme;
|
|
134
|
+
if (!scheme) {
|
|
135
|
+
ctx.writer.write(createChofiEvent({ clock: ctx.clock, event: "summary", phase: "test", status: "failed", data: { message: "--scheme is required (or set CHOFI_SCHEME)" } }));
|
|
136
|
+
throw new CliError("", 2);
|
|
137
|
+
}
|
|
138
|
+
const testOpts = {
|
|
139
|
+
destination: options.destination,
|
|
140
|
+
workspace: options.workspace,
|
|
141
|
+
project: options.project,
|
|
142
|
+
only: options.only,
|
|
143
|
+
skip: options.skip,
|
|
144
|
+
retry: options.retry
|
|
145
|
+
};
|
|
146
|
+
if (options.progress) {
|
|
147
|
+
ctx.writer.write(createChofiEvent({ clock: ctx.clock, event: "command_started", phase: "test", data: { cwd: ctx.context().mobilePath, scheme } }));
|
|
148
|
+
try {
|
|
149
|
+
const c = ctx.context();
|
|
150
|
+
await ctx.runtime.testWithProgress(c.mobilePath, scheme, (progress) => {
|
|
151
|
+
ctx.writer.write(createChofiEvent({ clock: ctx.clock, event: "command_completed", phase: "test.progress", status: "passed", data: progress }));
|
|
152
|
+
}, testOpts);
|
|
153
|
+
ctx.writer.write(createChofiEvent({ clock: ctx.clock, event: "command_completed", phase: "test", status: "passed", data: {} }));
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
const chofiError = error instanceof ChofiError ? error : new ChofiError(error instanceof Error ? error.message : String(error), "UNKNOWN_ERROR");
|
|
157
|
+
ctx.writer.write(createChofiEvent({ clock: ctx.clock, event: "command_failed", phase: "test", status: "failed", errorType: classifyError(chofiError).code, data: { message: chofiError.message, recoverySuggestion: classifyError(chofiError).recoverySuggestion } }));
|
|
158
|
+
throw new CliError("", 1);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
else if (options.withResults) {
|
|
162
|
+
ctx.writer.write(createChofiEvent({ clock: ctx.clock, event: "command_started", phase: "test", data: { cwd: ctx.context().mobilePath, scheme } }));
|
|
163
|
+
try {
|
|
164
|
+
const c = ctx.context();
|
|
165
|
+
const results = await ctx.runtime.testWithResults(c.mobilePath, scheme, testOpts);
|
|
166
|
+
ctx.writer.write(createChofiEvent({ clock: ctx.clock, event: "command_completed", phase: "test", status: "passed", data: { results } }));
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
const chofiError = error instanceof ChofiError ? error : new ChofiError(error instanceof Error ? error.message : String(error), "UNKNOWN_ERROR");
|
|
170
|
+
ctx.writer.write(createChofiEvent({ clock: ctx.clock, event: "command_failed", phase: "test", status: "failed", errorType: classifyError(chofiError).code, data: { message: chofiError.message, recoverySuggestion: classifyError(chofiError).recoverySuggestion } }));
|
|
171
|
+
throw new CliError("", 1);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
await runExecution({
|
|
176
|
+
clock: ctx.clock,
|
|
177
|
+
writer: ctx.writer,
|
|
178
|
+
phase: "test",
|
|
179
|
+
action: async () => {
|
|
180
|
+
const c = ctx.context();
|
|
181
|
+
await ctx.runtime.test(c.mobilePath, scheme, testOpts);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
testCmd
|
|
187
|
+
.command("discover")
|
|
188
|
+
.description("Discover test cases without running them")
|
|
189
|
+
.addOption(buildOpts[0])
|
|
190
|
+
.addOption(buildOpts[1])
|
|
191
|
+
.addOption(buildOpts[2])
|
|
192
|
+
.addOption(buildOpts[3])
|
|
193
|
+
.action(async (options) => {
|
|
194
|
+
const scheme = options.scheme;
|
|
195
|
+
if (!scheme) {
|
|
196
|
+
ctx.writer.write(createChofiEvent({ clock: ctx.clock, event: "summary", phase: "test.discover", status: "failed", data: { message: "--scheme is required (or set CHOFI_SCHEME)" } }));
|
|
197
|
+
throw new CliError("", 2);
|
|
198
|
+
}
|
|
199
|
+
await runExecution({
|
|
200
|
+
clock: ctx.clock,
|
|
201
|
+
writer: ctx.writer,
|
|
202
|
+
phase: "test.discover",
|
|
203
|
+
action: async () => {
|
|
204
|
+
const c = ctx.context();
|
|
205
|
+
const tests = await ctx.runtime.discoverTests(c.mobilePath, scheme, { destination: options.destination, workspace: options.workspace, project: options.project });
|
|
206
|
+
return { tests };
|
|
207
|
+
},
|
|
208
|
+
mapResult: (result) => ({ tests: result.tests })
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
testCmd
|
|
212
|
+
.command("results <path>")
|
|
213
|
+
.description("Parse test results from an .xcresult bundle")
|
|
214
|
+
.action(async (path) => {
|
|
215
|
+
await runExecution({
|
|
216
|
+
clock: ctx.clock,
|
|
217
|
+
writer: ctx.writer,
|
|
218
|
+
phase: "test.results",
|
|
219
|
+
action: async () => {
|
|
220
|
+
const results = await ctx.runtime.getTestResults(path);
|
|
221
|
+
return { results };
|
|
222
|
+
},
|
|
223
|
+
mapResult: (result) => ({ results: result.results })
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
program
|
|
227
|
+
.command("clean")
|
|
228
|
+
.description("Clean build artifacts via xcodebuild")
|
|
229
|
+
.addOption(buildOpts[0])
|
|
230
|
+
.addOption(buildOpts[1])
|
|
231
|
+
.addOption(buildOpts[2])
|
|
232
|
+
.option("--derived-data", "Delete DerivedData directory")
|
|
233
|
+
.option("--xcode-derived-data", "Delete Xcode DerivedData")
|
|
234
|
+
.option("--xcode-cache", "Delete Xcode cache")
|
|
235
|
+
.action(async (options) => {
|
|
236
|
+
const scheme = options.scheme;
|
|
237
|
+
if (!scheme && !options.derivedData && !options.xcodeDerivedData && !options.xcodeCache) {
|
|
238
|
+
ctx.writer.write(createChofiEvent({ clock: ctx.clock, event: "summary", phase: "clean", status: "failed", data: { message: "--scheme is required (or set CHOFI_SCHEME), or use --derived-data/--xcode-cache" } }));
|
|
239
|
+
throw new CliError("", 2);
|
|
240
|
+
}
|
|
241
|
+
await runExecution({
|
|
242
|
+
clock: ctx.clock,
|
|
243
|
+
writer: ctx.writer,
|
|
244
|
+
phase: "clean",
|
|
245
|
+
action: async () => {
|
|
246
|
+
const c = ctx.context();
|
|
247
|
+
await ctx.runtime.clean(c.mobilePath, scheme ?? "", { workspace: options.workspace, project: options.project, derivedData: options.derivedData, xcodeDerivedData: options.xcodeDerivedData, xcodeCache: options.xcodeCache });
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
// === logs ===
|
|
252
|
+
program
|
|
253
|
+
.command("logs <udid>")
|
|
254
|
+
.description("Stream logs from a device or simulator")
|
|
255
|
+
.option("--timeout <ms>", "Timeout in milliseconds", "30000")
|
|
256
|
+
.action(async (udid, options) => {
|
|
257
|
+
const timeoutMs = Number.parseInt(options.timeout, 10);
|
|
258
|
+
await runExecution({
|
|
259
|
+
clock: ctx.clock,
|
|
260
|
+
writer: ctx.writer,
|
|
261
|
+
phase: "logs.stream",
|
|
262
|
+
action: async () => {
|
|
263
|
+
const output = await ctx.runtime.streamLogs(udid, timeoutMs);
|
|
264
|
+
return { output };
|
|
265
|
+
},
|
|
266
|
+
mapResult: (result) => ({ logs: result.output })
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
// === stop ===
|
|
270
|
+
program
|
|
271
|
+
.command("stop <udid> [bundleId]")
|
|
272
|
+
.description("Terminate a running app (omit bundleId with --all to stop all)")
|
|
273
|
+
.option("--all", "Stop all running apps")
|
|
274
|
+
.option("--force", "Force kill (SIGKILL) instead of graceful termination")
|
|
275
|
+
.action(async (udid, bundleId, options) => {
|
|
276
|
+
if (options.all) {
|
|
277
|
+
await runExecution({
|
|
278
|
+
clock: ctx.clock,
|
|
279
|
+
writer: ctx.writer,
|
|
280
|
+
phase: "app.terminate-all",
|
|
281
|
+
action: async () => {
|
|
282
|
+
await ctx.runtime.appTerminateAll(udid, options.force ?? false);
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
if (!bundleId) {
|
|
288
|
+
ctx.writer.write(createChofiEvent({ clock: ctx.clock, event: "summary", phase: "app.terminate", status: "failed", data: { message: "bundleId is required (or use --all)" } }));
|
|
289
|
+
throw new CliError("", 2);
|
|
290
|
+
}
|
|
291
|
+
await runSimAction({
|
|
292
|
+
clock: ctx.clock,
|
|
293
|
+
runtime: ctx.runtime,
|
|
294
|
+
writer: ctx.writer,
|
|
295
|
+
phase: "app.terminate",
|
|
296
|
+
action: () => ctx.runtime.appTerminate(udid, bundleId, options.force ?? false),
|
|
297
|
+
target: udid
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
// === sim ===
|
|
301
|
+
const simCmd = program.command("sim").description("Simulator management");
|
|
302
|
+
simCmd
|
|
303
|
+
.command("list")
|
|
304
|
+
.description("List available simulators")
|
|
305
|
+
.action(async () => {
|
|
306
|
+
await runSimList({ clock: ctx.clock, runtime: ctx.runtime, writer: ctx.writer });
|
|
307
|
+
});
|
|
308
|
+
simCmd
|
|
309
|
+
.command("boot <target>")
|
|
310
|
+
.description("Boot a simulator by name or UDID")
|
|
311
|
+
.action(async (target) => {
|
|
312
|
+
await runSimAction({
|
|
313
|
+
clock: ctx.clock,
|
|
314
|
+
runtime: ctx.runtime,
|
|
315
|
+
writer: ctx.writer,
|
|
316
|
+
phase: "sim.boot",
|
|
317
|
+
action: () => ctx.runtime.simBoot(target),
|
|
318
|
+
target
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
simCmd
|
|
322
|
+
.command("shutdown <target>")
|
|
323
|
+
.description("Shutdown a simulator")
|
|
324
|
+
.action(async (target) => {
|
|
325
|
+
await runSimAction({
|
|
326
|
+
clock: ctx.clock,
|
|
327
|
+
runtime: ctx.runtime,
|
|
328
|
+
writer: ctx.writer,
|
|
329
|
+
phase: "sim.shutdown",
|
|
330
|
+
action: () => ctx.runtime.simShutdown(target),
|
|
331
|
+
target
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
simCmd
|
|
335
|
+
.command("open <target>")
|
|
336
|
+
.description("Open Simulator app for a device")
|
|
337
|
+
.action(async (target) => {
|
|
338
|
+
await runSimAction({
|
|
339
|
+
clock: ctx.clock,
|
|
340
|
+
runtime: ctx.runtime,
|
|
341
|
+
writer: ctx.writer,
|
|
342
|
+
phase: "sim.open",
|
|
343
|
+
action: () => ctx.runtime.simOpen(target),
|
|
344
|
+
target
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
simCmd
|
|
348
|
+
.command("erase <target>")
|
|
349
|
+
.description("Erase simulator contents and settings")
|
|
350
|
+
.option("--confirm", "Explicitly confirm destructive operation")
|
|
351
|
+
.option("--yes", "Alias for --confirm")
|
|
352
|
+
.action(async (target, options) => {
|
|
353
|
+
if (!options.confirm && !options.yes) {
|
|
354
|
+
ctx.writer.write(createChofiEvent({
|
|
355
|
+
clock: ctx.clock,
|
|
356
|
+
event: "summary",
|
|
357
|
+
phase: "sim.erase",
|
|
358
|
+
status: "failed",
|
|
359
|
+
data: { message: requireConfirmation("sim.erase") }
|
|
360
|
+
}));
|
|
361
|
+
throw new CliError("", 1);
|
|
362
|
+
}
|
|
363
|
+
await runSimAction({
|
|
364
|
+
clock: ctx.clock,
|
|
365
|
+
runtime: ctx.runtime,
|
|
366
|
+
writer: ctx.writer,
|
|
367
|
+
phase: "sim.erase",
|
|
368
|
+
action: () => ctx.runtime.simErase(target),
|
|
369
|
+
target
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
simCmd
|
|
373
|
+
.command("screenshot <target>")
|
|
374
|
+
.description("Take a screenshot of a simulator")
|
|
375
|
+
.option("--output <path>", "Output file path", "./screenshot.png")
|
|
376
|
+
.option("--format <format>", "Screenshot format (png/jpeg)", "png")
|
|
377
|
+
.action(async (target, options) => {
|
|
378
|
+
await runExecution({
|
|
379
|
+
clock: ctx.clock,
|
|
380
|
+
writer: ctx.writer,
|
|
381
|
+
phase: "sim.screenshot",
|
|
382
|
+
action: async () => {
|
|
383
|
+
if (options.format === "jpeg" || options.format === "jpg") {
|
|
384
|
+
const result = await ctx.runtime.apple.screenshotToBuffer(target, "jpeg");
|
|
385
|
+
if (result.exitCode !== 0) {
|
|
386
|
+
throw new ToolOutputError("simctl", result.stderr || "Screenshot failed");
|
|
387
|
+
}
|
|
388
|
+
return { outputPath: options.output, data: result.stdout };
|
|
389
|
+
}
|
|
390
|
+
await ctx.runtime.simScreenshot(target, options.output);
|
|
391
|
+
return { outputPath: options.output };
|
|
392
|
+
},
|
|
393
|
+
mapResult: (result) => ({ outputPath: result.outputPath })
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
simCmd
|
|
397
|
+
.command("create <name> <deviceType> <runtime>")
|
|
398
|
+
.description("Create a new simulator")
|
|
399
|
+
.action(async (name, deviceType, runtime) => {
|
|
400
|
+
await runExecution({
|
|
401
|
+
clock: ctx.clock,
|
|
402
|
+
writer: ctx.writer,
|
|
403
|
+
phase: "sim.create",
|
|
404
|
+
action: async () => {
|
|
405
|
+
const udid = await ctx.runtime.simCreate(name, deviceType, runtime);
|
|
406
|
+
return { udid };
|
|
407
|
+
},
|
|
408
|
+
mapResult: (result) => ({ udid: result.udid })
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
simCmd
|
|
412
|
+
.command("delete <target>")
|
|
413
|
+
.description("Delete a simulator")
|
|
414
|
+
.option("--confirm", "Explicitly confirm destructive operation")
|
|
415
|
+
.option("--yes", "Alias for --confirm")
|
|
416
|
+
.action(async (target, options) => {
|
|
417
|
+
if (!options.confirm && !options.yes) {
|
|
418
|
+
ctx.writer.write(createChofiEvent({
|
|
419
|
+
clock: ctx.clock,
|
|
420
|
+
event: "summary",
|
|
421
|
+
phase: "sim.delete",
|
|
422
|
+
status: "failed",
|
|
423
|
+
data: { message: requireConfirmation("sim.delete") }
|
|
424
|
+
}));
|
|
425
|
+
throw new CliError("", 1);
|
|
426
|
+
}
|
|
427
|
+
await runSimAction({
|
|
428
|
+
clock: ctx.clock,
|
|
429
|
+
runtime: ctx.runtime,
|
|
430
|
+
writer: ctx.writer,
|
|
431
|
+
phase: "sim.delete",
|
|
432
|
+
action: () => ctx.runtime.simDelete(target),
|
|
433
|
+
target
|
|
434
|
+
});
|
|
435
|
+
});
|
|
436
|
+
simCmd
|
|
437
|
+
.command("prune")
|
|
438
|
+
.description("Delete unavailable simulators")
|
|
439
|
+
.option("--confirm", "Explicitly confirm destructive operation")
|
|
440
|
+
.option("--yes", "Alias for --confirm")
|
|
441
|
+
.action(async (options) => {
|
|
442
|
+
if (!options.confirm && !options.yes) {
|
|
443
|
+
ctx.writer.write(createChofiEvent({
|
|
444
|
+
clock: ctx.clock,
|
|
445
|
+
event: "summary",
|
|
446
|
+
phase: "sim.prune",
|
|
447
|
+
status: "failed",
|
|
448
|
+
data: { message: requireConfirmation("sim.prune") }
|
|
449
|
+
}));
|
|
450
|
+
throw new CliError("", 1);
|
|
451
|
+
}
|
|
452
|
+
await runSimAction({
|
|
453
|
+
clock: ctx.clock,
|
|
454
|
+
runtime: ctx.runtime,
|
|
455
|
+
writer: ctx.writer,
|
|
456
|
+
phase: "sim.prune",
|
|
457
|
+
action: () => ctx.runtime.simPrune(),
|
|
458
|
+
target: "unavailable"
|
|
459
|
+
});
|
|
460
|
+
});
|
|
461
|
+
simCmd
|
|
462
|
+
.command("runtime")
|
|
463
|
+
.description("List available simulator runtimes")
|
|
464
|
+
.action(async () => {
|
|
465
|
+
await runExecution({
|
|
466
|
+
clock: ctx.clock,
|
|
467
|
+
writer: ctx.writer,
|
|
468
|
+
phase: "sim.runtime",
|
|
469
|
+
action: async () => {
|
|
470
|
+
const runtimes = await ctx.runtime.simRuntimeList();
|
|
471
|
+
return { runtimes };
|
|
472
|
+
},
|
|
473
|
+
mapResult: (result) => ({ runtimes: result.runtimes })
|
|
474
|
+
});
|
|
475
|
+
});
|
|
476
|
+
simCmd
|
|
477
|
+
.command("device-types")
|
|
478
|
+
.description("List available simulator device types")
|
|
479
|
+
.action(async () => {
|
|
480
|
+
await runExecution({
|
|
481
|
+
clock: ctx.clock,
|
|
482
|
+
writer: ctx.writer,
|
|
483
|
+
phase: "sim.device-types",
|
|
484
|
+
action: async () => {
|
|
485
|
+
const types = await ctx.runtime.simDeviceTypeList();
|
|
486
|
+
return { types };
|
|
487
|
+
},
|
|
488
|
+
mapResult: (result) => ({ types: result.types })
|
|
489
|
+
});
|
|
490
|
+
});
|
|
491
|
+
simCmd
|
|
492
|
+
.command("clone <source> <name>")
|
|
493
|
+
.description("Clone a simulator")
|
|
494
|
+
.action(async (source, name) => {
|
|
495
|
+
await runExecution({
|
|
496
|
+
clock: ctx.clock,
|
|
497
|
+
writer: ctx.writer,
|
|
498
|
+
phase: "sim.clone",
|
|
499
|
+
action: async () => {
|
|
500
|
+
const udid = await ctx.runtime.simClone(source, name);
|
|
501
|
+
return { udid };
|
|
502
|
+
},
|
|
503
|
+
mapResult: (result) => ({ udid: result.udid })
|
|
504
|
+
});
|
|
505
|
+
});
|
|
506
|
+
simCmd
|
|
507
|
+
.command("clear-cache <target>")
|
|
508
|
+
.description("Clear simulator caches")
|
|
509
|
+
.action(async (target) => {
|
|
510
|
+
await runSimAction({
|
|
511
|
+
clock: ctx.clock,
|
|
512
|
+
runtime: ctx.runtime,
|
|
513
|
+
writer: ctx.writer,
|
|
514
|
+
phase: "sim.clear-cache",
|
|
515
|
+
action: () => ctx.runtime.simClearCache(target),
|
|
516
|
+
target
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
simCmd
|
|
520
|
+
.command("set-appearance <target> <appearance>")
|
|
521
|
+
.description("Set simulator appearance (dark/light)")
|
|
522
|
+
.action(async (target, appearance) => {
|
|
523
|
+
if (appearance !== "dark" && appearance !== "light") {
|
|
524
|
+
ctx.writer.write(createChofiEvent({ clock: ctx.clock, event: "summary", phase: "sim.set-appearance", status: "failed", data: { message: "appearance must be 'dark' or 'light'" } }));
|
|
525
|
+
throw new CliError("", 2);
|
|
526
|
+
}
|
|
527
|
+
await runSimAction({
|
|
528
|
+
clock: ctx.clock,
|
|
529
|
+
runtime: ctx.runtime,
|
|
530
|
+
writer: ctx.writer,
|
|
531
|
+
phase: "sim.set-appearance",
|
|
532
|
+
action: () => ctx.runtime.simSetAppearance(target, appearance),
|
|
533
|
+
target
|
|
534
|
+
});
|
|
535
|
+
});
|
|
536
|
+
simCmd
|
|
537
|
+
.command("record <udid>")
|
|
538
|
+
.description("Record simulator video")
|
|
539
|
+
.option("--output <path>", "Output file path", "./recording.mp4")
|
|
540
|
+
.option("--duration <seconds>", "Recording duration in seconds", "10")
|
|
541
|
+
.action(async (udid, options) => {
|
|
542
|
+
await runExecution({
|
|
543
|
+
clock: ctx.clock,
|
|
544
|
+
writer: ctx.writer,
|
|
545
|
+
phase: "sim.record",
|
|
546
|
+
action: async () => {
|
|
547
|
+
await ctx.runtime.simStartRecording(udid, options.output);
|
|
548
|
+
const durationMs = Number.parseInt(options.duration, 10) * 1000;
|
|
549
|
+
await new Promise((resolve) => setTimeout(resolve, durationMs));
|
|
550
|
+
await ctx.runtime.simStopRecording(udid);
|
|
551
|
+
return { outputPath: options.output };
|
|
552
|
+
},
|
|
553
|
+
mapResult: (result) => ({ outputPath: result.outputPath })
|
|
554
|
+
});
|
|
555
|
+
});
|
|
556
|
+
simCmd
|
|
557
|
+
.command("add-media <udid> <paths...>")
|
|
558
|
+
.description("Add photos/videos to simulator Photos app")
|
|
559
|
+
.action(async (udid, paths) => {
|
|
560
|
+
await runSimAction({
|
|
561
|
+
clock: ctx.clock,
|
|
562
|
+
runtime: ctx.runtime,
|
|
563
|
+
writer: ctx.writer,
|
|
564
|
+
phase: "sim.add-media",
|
|
565
|
+
action: () => ctx.runtime.simAddMedia(udid, paths),
|
|
566
|
+
target: udid
|
|
567
|
+
});
|
|
568
|
+
});
|
|
569
|
+
// === device ===
|
|
570
|
+
const deviceCmd = program.command("device").description("Physical device management");
|
|
571
|
+
deviceCmd
|
|
572
|
+
.command("list")
|
|
573
|
+
.description("List physical devices")
|
|
574
|
+
.option("--platform <platform>", "Filter by platform (iOS, watchOS)")
|
|
575
|
+
.action(async (options) => {
|
|
576
|
+
await runExecution({
|
|
577
|
+
clock: ctx.clock,
|
|
578
|
+
writer: ctx.writer,
|
|
579
|
+
phase: "device.list",
|
|
580
|
+
action: async () => {
|
|
581
|
+
const devices = await ctx.runtime.deviceList(options.platform);
|
|
582
|
+
return { devices };
|
|
583
|
+
},
|
|
584
|
+
mapResult: (result) => ({ devices: result.devices })
|
|
585
|
+
});
|
|
586
|
+
});
|
|
587
|
+
// === apps ===
|
|
588
|
+
const appsCmd = program.command("apps").description("App management");
|
|
589
|
+
appsCmd
|
|
590
|
+
.command("list <udid>")
|
|
591
|
+
.description("List running apps on a simulator/device")
|
|
592
|
+
.action(async (udid) => {
|
|
593
|
+
await runExecution({
|
|
594
|
+
clock: ctx.clock,
|
|
595
|
+
writer: ctx.writer,
|
|
596
|
+
phase: "apps.list",
|
|
597
|
+
action: async () => {
|
|
598
|
+
const apps = await ctx.runtime.appListRunning(udid);
|
|
599
|
+
return { apps };
|
|
600
|
+
},
|
|
601
|
+
mapResult: (result) => ({ apps: result.apps })
|
|
602
|
+
});
|
|
603
|
+
});
|
|
604
|
+
appsCmd
|
|
605
|
+
.command("prune <udid>")
|
|
606
|
+
.description("Remove stale/uninstalled app entries from registry")
|
|
607
|
+
.action(async (udid) => {
|
|
608
|
+
await runExecution({
|
|
609
|
+
clock: ctx.clock,
|
|
610
|
+
writer: ctx.writer,
|
|
611
|
+
phase: "apps.prune",
|
|
612
|
+
action: async () => {
|
|
613
|
+
const apps = await ctx.runtime.appListRunning(udid);
|
|
614
|
+
const pruned = apps.filter((a) => a.pid > 0);
|
|
615
|
+
return { pruned: pruned.length };
|
|
616
|
+
},
|
|
617
|
+
mapResult: (result) => ({ pruned: result.pruned })
|
|
618
|
+
});
|
|
619
|
+
});
|
|
620
|
+
// === app ===
|
|
621
|
+
const appCmd = program.command("app").description("App lifecycle on simulator/device");
|
|
622
|
+
appCmd
|
|
623
|
+
.command("install <udid> <appPath>")
|
|
624
|
+
.description("Install app on simulator/device")
|
|
625
|
+
.action(async (udid, appPath) => {
|
|
626
|
+
await runSimAction({
|
|
627
|
+
clock: ctx.clock,
|
|
628
|
+
runtime: ctx.runtime,
|
|
629
|
+
writer: ctx.writer,
|
|
630
|
+
phase: "app.install",
|
|
631
|
+
action: () => ctx.runtime.appInstall(udid, appPath),
|
|
632
|
+
target: udid
|
|
633
|
+
});
|
|
634
|
+
});
|
|
635
|
+
appCmd
|
|
636
|
+
.command("launch <udid> <bundleId>")
|
|
637
|
+
.description("Launch app on simulator/device")
|
|
638
|
+
.action(async (udid, bundleId) => {
|
|
639
|
+
await runSimAction({
|
|
640
|
+
clock: ctx.clock,
|
|
641
|
+
runtime: ctx.runtime,
|
|
642
|
+
writer: ctx.writer,
|
|
643
|
+
phase: "app.launch",
|
|
644
|
+
action: () => ctx.runtime.appLaunch(udid, bundleId),
|
|
645
|
+
target: udid
|
|
646
|
+
});
|
|
647
|
+
});
|
|
648
|
+
appCmd
|
|
649
|
+
.command("terminate <udid> <bundleId>")
|
|
650
|
+
.description("Terminate app on simulator/device")
|
|
651
|
+
.option("--force", "Force kill (SIGKILL)")
|
|
652
|
+
.action(async (udid, bundleId, options) => {
|
|
653
|
+
await runSimAction({
|
|
654
|
+
clock: ctx.clock,
|
|
655
|
+
runtime: ctx.runtime,
|
|
656
|
+
writer: ctx.writer,
|
|
657
|
+
phase: "app.terminate",
|
|
658
|
+
action: () => ctx.runtime.appTerminate(udid, bundleId, options.force ?? false),
|
|
659
|
+
target: udid
|
|
660
|
+
});
|
|
661
|
+
});
|
|
662
|
+
appCmd
|
|
663
|
+
.command("uninstall <udid> <bundleId>")
|
|
664
|
+
.description("Uninstall app from simulator/device")
|
|
665
|
+
.action(async (udid, bundleId) => {
|
|
666
|
+
await runSimAction({
|
|
667
|
+
clock: ctx.clock,
|
|
668
|
+
runtime: ctx.runtime,
|
|
669
|
+
writer: ctx.writer,
|
|
670
|
+
phase: "app.uninstall",
|
|
671
|
+
action: () => ctx.runtime.appUninstall(udid, bundleId),
|
|
672
|
+
target: udid
|
|
673
|
+
});
|
|
674
|
+
});
|
|
675
|
+
// === project ===
|
|
676
|
+
const projectCmd = program.command("project").description("Project introspection");
|
|
677
|
+
projectCmd
|
|
678
|
+
.command("build-config")
|
|
679
|
+
.description("List available build configurations")
|
|
680
|
+
.addOption(buildOpts[1])
|
|
681
|
+
.addOption(buildOpts[2])
|
|
682
|
+
.action(async (options) => {
|
|
683
|
+
await runExecution({
|
|
684
|
+
clock: ctx.clock,
|
|
685
|
+
writer: ctx.writer,
|
|
686
|
+
phase: "project.build-config",
|
|
687
|
+
action: async () => {
|
|
688
|
+
const c = ctx.context();
|
|
689
|
+
const configs = await ctx.runtime.projectBuildConfigs(c.mobilePath, { workspace: options.workspace, project: options.project });
|
|
690
|
+
return { configs };
|
|
691
|
+
},
|
|
692
|
+
mapResult: (result) => ({ configs: result.configs })
|
|
693
|
+
});
|
|
694
|
+
});
|
|
695
|
+
projectCmd
|
|
696
|
+
.command("schemes")
|
|
697
|
+
.description("Auto-detect available schemes")
|
|
698
|
+
.addOption(buildOpts[1])
|
|
699
|
+
.addOption(buildOpts[2])
|
|
700
|
+
.action(async (options) => {
|
|
701
|
+
await runExecution({
|
|
702
|
+
clock: ctx.clock,
|
|
703
|
+
writer: ctx.writer,
|
|
704
|
+
phase: "project.schemes",
|
|
705
|
+
action: async () => {
|
|
706
|
+
const c = ctx.context();
|
|
707
|
+
const schemes = await ctx.runtime.detectSchemes(c.mobilePath, { workspace: options.workspace, project: options.project });
|
|
708
|
+
return { schemes };
|
|
709
|
+
},
|
|
710
|
+
mapResult: (result) => ({ schemes: result.schemes })
|
|
711
|
+
});
|
|
712
|
+
});
|
|
713
|
+
// === run ===
|
|
714
|
+
const runCmd = program.command("run").description("Run the app on a platform");
|
|
715
|
+
runCmd
|
|
716
|
+
.command("ios")
|
|
717
|
+
.description("Run iOS app (requires --confirm)")
|
|
718
|
+
.option("--confirm", "Explicitly confirm")
|
|
719
|
+
.option("--yes", "Alias for --confirm")
|
|
720
|
+
.option("--no-build", "Skip build, launch existing app")
|
|
721
|
+
.option("--launch-env <env...>", "Environment variables for launch (KEY=VALUE)")
|
|
722
|
+
.action(async (options) => {
|
|
723
|
+
if (!options.confirm && !options.yes) {
|
|
724
|
+
ctx.writer.write(createChofiEvent({
|
|
725
|
+
clock: ctx.clock,
|
|
726
|
+
event: "summary",
|
|
727
|
+
phase: "run.ios",
|
|
728
|
+
status: "failed",
|
|
729
|
+
data: { message: requireConfirmation("run.ios") }
|
|
730
|
+
}));
|
|
731
|
+
throw new CliError("", 1);
|
|
732
|
+
}
|
|
733
|
+
await runExecution({
|
|
734
|
+
clock: ctx.clock,
|
|
735
|
+
writer: ctx.writer,
|
|
736
|
+
phase: "run.ios",
|
|
737
|
+
action: async () => {
|
|
738
|
+
const c = ctx.context();
|
|
739
|
+
if (!options.noBuild) {
|
|
740
|
+
await ctx.runtime.prebuild(c.repoRoot, "ios");
|
|
741
|
+
}
|
|
742
|
+
await ctx.runtime.runExpoIos(c.mobilePath);
|
|
743
|
+
}
|
|
744
|
+
});
|
|
745
|
+
});
|
|
746
|
+
runCmd
|
|
747
|
+
.command("android")
|
|
748
|
+
.description("Run Android app (requires --confirm)")
|
|
749
|
+
.option("--confirm", "Explicitly confirm")
|
|
750
|
+
.option("--yes", "Alias for --confirm")
|
|
751
|
+
.action(async (options) => {
|
|
752
|
+
if (!options.confirm && !options.yes) {
|
|
753
|
+
ctx.writer.write(createChofiEvent({
|
|
754
|
+
clock: ctx.clock,
|
|
755
|
+
event: "summary",
|
|
756
|
+
phase: "run.android",
|
|
757
|
+
status: "failed",
|
|
758
|
+
data: { message: requireConfirmation("run.android") }
|
|
759
|
+
}));
|
|
760
|
+
throw new CliError("", 1);
|
|
761
|
+
}
|
|
762
|
+
await runExecution({
|
|
763
|
+
clock: ctx.clock,
|
|
764
|
+
writer: ctx.writer,
|
|
765
|
+
phase: "run.android",
|
|
766
|
+
action: async () => {
|
|
767
|
+
const c = ctx.context();
|
|
768
|
+
await ctx.runtime.prebuild(c.repoRoot, "android");
|
|
769
|
+
await ctx.runtime.runExpoAndroid(c.mobilePath);
|
|
770
|
+
}
|
|
771
|
+
});
|
|
772
|
+
});
|
|
773
|
+
// === maestro ===
|
|
774
|
+
const maestroCmd = program.command("maestro").description("Maestro UI automation");
|
|
775
|
+
maestroCmd
|
|
776
|
+
.command("check")
|
|
777
|
+
.description("Check Maestro installation")
|
|
778
|
+
.action(async () => {
|
|
779
|
+
const availability = ctx.context().maestro;
|
|
780
|
+
writeToolCheck(ctx.writer, ctx.clock, availability);
|
|
781
|
+
if (availability.status !== "available") {
|
|
782
|
+
throw new CliError("", 1);
|
|
783
|
+
}
|
|
784
|
+
});
|
|
785
|
+
maestroCmd
|
|
786
|
+
.command("run <flow>")
|
|
787
|
+
.description("Run a Maestro flow")
|
|
788
|
+
.action(async (flow) => {
|
|
789
|
+
await runExecution({
|
|
790
|
+
clock: ctx.clock,
|
|
791
|
+
writer: ctx.writer,
|
|
792
|
+
phase: "maestro.run",
|
|
793
|
+
action: async () => {
|
|
794
|
+
const c = ctx.context();
|
|
795
|
+
await ctx.runtime.runMaestroTest(flow, { cwd: c.repoRoot });
|
|
796
|
+
}
|
|
797
|
+
});
|
|
798
|
+
});
|
|
799
|
+
maestroCmd
|
|
800
|
+
.command("continuous <flow>")
|
|
801
|
+
.description("Run a Maestro flow in continuous mode")
|
|
802
|
+
.action(async (flow) => {
|
|
803
|
+
await runExecution({
|
|
804
|
+
clock: ctx.clock,
|
|
805
|
+
writer: ctx.writer,
|
|
806
|
+
phase: "maestro.continuous",
|
|
807
|
+
action: async () => {
|
|
808
|
+
const c = ctx.context();
|
|
809
|
+
await ctx.runtime.runMaestroContinuous(flow, { cwd: c.repoRoot });
|
|
810
|
+
}
|
|
811
|
+
});
|
|
812
|
+
});
|
|
813
|
+
maestroCmd
|
|
814
|
+
.command("hierarchy")
|
|
815
|
+
.description("Get UI hierarchy")
|
|
816
|
+
.action(async () => {
|
|
817
|
+
await runExecution({
|
|
818
|
+
clock: ctx.clock,
|
|
819
|
+
writer: ctx.writer,
|
|
820
|
+
phase: "maestro.hierarchy",
|
|
821
|
+
action: async () => {
|
|
822
|
+
const c = ctx.context();
|
|
823
|
+
const output = await ctx.runtime.runMaestroHierarchy({ cwd: c.repoRoot });
|
|
824
|
+
return { output };
|
|
825
|
+
},
|
|
826
|
+
mapResult: (result) => ({ hierarchy: result.output })
|
|
827
|
+
});
|
|
828
|
+
});
|
|
829
|
+
maestroCmd
|
|
830
|
+
.command("record <flow>")
|
|
831
|
+
.description("Record a Maestro flow")
|
|
832
|
+
.action(async (flow) => {
|
|
833
|
+
await runExecution({
|
|
834
|
+
clock: ctx.clock,
|
|
835
|
+
writer: ctx.writer,
|
|
836
|
+
phase: "maestro.record",
|
|
837
|
+
action: async () => {
|
|
838
|
+
const c = ctx.context();
|
|
839
|
+
await ctx.runtime.runMaestroRecord(flow, { cwd: c.repoRoot });
|
|
840
|
+
}
|
|
841
|
+
});
|
|
842
|
+
});
|
|
843
|
+
maestroCmd
|
|
844
|
+
.command("driver-setup")
|
|
845
|
+
.description("Setup Maestro driver")
|
|
846
|
+
.action(async () => {
|
|
847
|
+
await runExecution({
|
|
848
|
+
clock: ctx.clock,
|
|
849
|
+
writer: ctx.writer,
|
|
850
|
+
phase: "maestro.driver-setup",
|
|
851
|
+
action: async () => {
|
|
852
|
+
const c = ctx.context();
|
|
853
|
+
await ctx.runtime.maestroDriverSetup(c.repoRoot);
|
|
854
|
+
}
|
|
855
|
+
});
|
|
856
|
+
});
|
|
857
|
+
maestroCmd
|
|
858
|
+
.command("start-device")
|
|
859
|
+
.description("Start a simulator or emulator for Maestro")
|
|
860
|
+
.option("--platform <platform>", "Platform: ios or android")
|
|
861
|
+
.option("--device <device>", "Device identifier")
|
|
862
|
+
.action(async (options) => {
|
|
863
|
+
await runExecution({
|
|
864
|
+
clock: ctx.clock,
|
|
865
|
+
writer: ctx.writer,
|
|
866
|
+
phase: "maestro.start-device",
|
|
867
|
+
action: async () => {
|
|
868
|
+
const c = ctx.context();
|
|
869
|
+
await ctx.runtime.maestroStartDevice({
|
|
870
|
+
cwd: c.repoRoot,
|
|
871
|
+
platform: options.platform,
|
|
872
|
+
device: options.device
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
});
|
|
876
|
+
});
|
|
877
|
+
// === idb ===
|
|
878
|
+
const idbCmd = program.command("idb").description("idb integration — accessibility, UI, crashes, permissions, location");
|
|
879
|
+
idbCmd
|
|
880
|
+
.command("accessibility <udid>")
|
|
881
|
+
.description("Dump accessibility tree via idb")
|
|
882
|
+
.option("--point <x,y>", "Describe element at point (format: x,y)")
|
|
883
|
+
.action(async (udid, options) => {
|
|
884
|
+
await runExecution({
|
|
885
|
+
clock: ctx.clock,
|
|
886
|
+
writer: ctx.writer,
|
|
887
|
+
phase: "idb.accessibility",
|
|
888
|
+
action: async () => {
|
|
889
|
+
if (options.point) {
|
|
890
|
+
const [x, y] = options.point.split(",").map(Number);
|
|
891
|
+
const element = await ctx.runtime.idbDescribePoint(udid, x, y);
|
|
892
|
+
return { element };
|
|
893
|
+
}
|
|
894
|
+
const tree = await ctx.runtime.idbDescribeAll(udid);
|
|
895
|
+
return { tree };
|
|
896
|
+
},
|
|
897
|
+
mapResult: (result) => (options.point ? result.element : result.tree)
|
|
898
|
+
});
|
|
899
|
+
});
|
|
900
|
+
idbCmd
|
|
901
|
+
.command("tap <udid> <x> <y>")
|
|
902
|
+
.description("Tap at screen coordinates via idb")
|
|
903
|
+
.action(async (udid, x, y) => {
|
|
904
|
+
await runExecution({
|
|
905
|
+
clock: ctx.clock,
|
|
906
|
+
writer: ctx.writer,
|
|
907
|
+
phase: "idb.tap",
|
|
908
|
+
action: async () => {
|
|
909
|
+
await ctx.runtime.idbTap(udid, Number(x), Number(y));
|
|
910
|
+
}
|
|
911
|
+
});
|
|
912
|
+
});
|
|
913
|
+
idbCmd
|
|
914
|
+
.command("swipe <udid> <x1> <y1> <x2> <y2>")
|
|
915
|
+
.description("Swipe from point to point via idb")
|
|
916
|
+
.action(async (udid, x1, y1, x2, y2) => {
|
|
917
|
+
await runExecution({
|
|
918
|
+
clock: ctx.clock,
|
|
919
|
+
writer: ctx.writer,
|
|
920
|
+
phase: "idb.swipe",
|
|
921
|
+
action: async () => {
|
|
922
|
+
await ctx.runtime.idbSwipe(udid, Number(x1), Number(y1), Number(x2), Number(y2));
|
|
923
|
+
}
|
|
924
|
+
});
|
|
925
|
+
});
|
|
926
|
+
idbCmd
|
|
927
|
+
.command("button <udid> <button>")
|
|
928
|
+
.description("Press a hardware button via idb (home, volume_up, volume_down, lock)")
|
|
929
|
+
.action(async (udid, button) => {
|
|
930
|
+
await runExecution({
|
|
931
|
+
clock: ctx.clock,
|
|
932
|
+
writer: ctx.writer,
|
|
933
|
+
phase: "idb.button",
|
|
934
|
+
action: async () => {
|
|
935
|
+
await ctx.runtime.idbPressButton(udid, button);
|
|
936
|
+
}
|
|
937
|
+
});
|
|
938
|
+
});
|
|
939
|
+
idbCmd
|
|
940
|
+
.command("text <udid> <text>")
|
|
941
|
+
.description("Input text via idb")
|
|
942
|
+
.action(async (udid, text) => {
|
|
943
|
+
await runExecution({
|
|
944
|
+
clock: ctx.clock,
|
|
945
|
+
writer: ctx.writer,
|
|
946
|
+
phase: "idb.text",
|
|
947
|
+
action: async () => {
|
|
948
|
+
await ctx.runtime.idbInputText(udid, text);
|
|
949
|
+
}
|
|
950
|
+
});
|
|
951
|
+
});
|
|
952
|
+
idbCmd
|
|
953
|
+
.command("crash-list <udid>")
|
|
954
|
+
.description("List crash logs via idb")
|
|
955
|
+
.action(async (udid) => {
|
|
956
|
+
await runExecution({
|
|
957
|
+
clock: ctx.clock,
|
|
958
|
+
writer: ctx.writer,
|
|
959
|
+
phase: "idb.crash-list",
|
|
960
|
+
action: async () => {
|
|
961
|
+
const crashes = await ctx.runtime.idbListCrashes(udid);
|
|
962
|
+
return { crashes };
|
|
963
|
+
},
|
|
964
|
+
mapResult: (result) => ({ crashes: result.crashes })
|
|
965
|
+
});
|
|
966
|
+
});
|
|
967
|
+
idbCmd
|
|
968
|
+
.command("crash-show <udid> <name>")
|
|
969
|
+
.description("Show crash log contents via idb")
|
|
970
|
+
.action(async (udid, name) => {
|
|
971
|
+
await runExecution({
|
|
972
|
+
clock: ctx.clock,
|
|
973
|
+
writer: ctx.writer,
|
|
974
|
+
phase: "idb.crash-show",
|
|
975
|
+
action: async () => {
|
|
976
|
+
const content = await ctx.runtime.idbShowCrash(udid, name);
|
|
977
|
+
return { content };
|
|
978
|
+
},
|
|
979
|
+
mapResult: (result) => ({ content: result.content })
|
|
980
|
+
});
|
|
981
|
+
});
|
|
982
|
+
idbCmd
|
|
983
|
+
.command("approve <udid> <bundleId> <permissions...>")
|
|
984
|
+
.description("Approve permissions via idb (photos, camera, contacts, url, location, notification)")
|
|
985
|
+
.action(async (udid, bundleId, permissions) => {
|
|
986
|
+
await runExecution({
|
|
987
|
+
clock: ctx.clock,
|
|
988
|
+
writer: ctx.writer,
|
|
989
|
+
phase: "idb.approve",
|
|
990
|
+
action: async () => {
|
|
991
|
+
await ctx.runtime.idbApprove(udid, bundleId, permissions);
|
|
992
|
+
}
|
|
993
|
+
});
|
|
994
|
+
});
|
|
995
|
+
idbCmd
|
|
996
|
+
.command("location <udid> <latitude> <longitude>")
|
|
997
|
+
.description("Set simulator location via idb")
|
|
998
|
+
.action(async (udid, latitude, longitude) => {
|
|
999
|
+
await runExecution({
|
|
1000
|
+
clock: ctx.clock,
|
|
1001
|
+
writer: ctx.writer,
|
|
1002
|
+
phase: "idb.location",
|
|
1003
|
+
action: async () => {
|
|
1004
|
+
await ctx.runtime.idbSetLocation(udid, Number(latitude), Number(longitude));
|
|
1005
|
+
}
|
|
1006
|
+
});
|
|
1007
|
+
});
|
|
1008
|
+
idbCmd
|
|
1009
|
+
.command("focus <udid>")
|
|
1010
|
+
.description("Bring simulator window to front via idb")
|
|
1011
|
+
.action(async (udid) => {
|
|
1012
|
+
await runExecution({
|
|
1013
|
+
clock: ctx.clock,
|
|
1014
|
+
writer: ctx.writer,
|
|
1015
|
+
phase: "idb.focus",
|
|
1016
|
+
action: async () => {
|
|
1017
|
+
await ctx.runtime.idbFocus(udid);
|
|
1018
|
+
}
|
|
1019
|
+
});
|
|
1020
|
+
});
|
|
1021
|
+
// === config ===
|
|
1022
|
+
const configCmd = program.command("config").description("Configuration management");
|
|
1023
|
+
configCmd
|
|
1024
|
+
.command("get <key>")
|
|
1025
|
+
.description("Get a config value")
|
|
1026
|
+
.action(async (key) => {
|
|
1027
|
+
const c = ctx.context();
|
|
1028
|
+
const config = readConfig(c.repoRoot);
|
|
1029
|
+
const value = config[key];
|
|
1030
|
+
ctx.writer.write(createChofiEvent({
|
|
1031
|
+
clock: ctx.clock,
|
|
1032
|
+
event: "summary",
|
|
1033
|
+
phase: "config.get",
|
|
1034
|
+
status: "passed",
|
|
1035
|
+
data: { key, value }
|
|
1036
|
+
}));
|
|
1037
|
+
});
|
|
1038
|
+
configCmd
|
|
1039
|
+
.command("set <key> <value>")
|
|
1040
|
+
.description("Set a config value")
|
|
1041
|
+
.action(async (key, value) => {
|
|
1042
|
+
const c = ctx.context();
|
|
1043
|
+
const config = readConfig(c.repoRoot);
|
|
1044
|
+
const updated = { ...config, [key]: value };
|
|
1045
|
+
writeConfig(c.repoRoot, updated);
|
|
1046
|
+
ctx.writer.write(createChofiEvent({
|
|
1047
|
+
clock: ctx.clock,
|
|
1048
|
+
event: "summary",
|
|
1049
|
+
phase: "config.set",
|
|
1050
|
+
status: "passed",
|
|
1051
|
+
data: { key, value }
|
|
1052
|
+
}));
|
|
1053
|
+
});
|
|
1054
|
+
configCmd
|
|
1055
|
+
.command("reset")
|
|
1056
|
+
.description("Reset config to defaults")
|
|
1057
|
+
.option("--confirm", "Explicitly confirm destructive operation")
|
|
1058
|
+
.option("--yes", "Alias for --confirm")
|
|
1059
|
+
.action(async (options) => {
|
|
1060
|
+
if (!options.confirm && !options.yes) {
|
|
1061
|
+
ctx.writer.write(createChofiEvent({
|
|
1062
|
+
clock: ctx.clock,
|
|
1063
|
+
event: "summary",
|
|
1064
|
+
phase: "config.reset",
|
|
1065
|
+
status: "failed",
|
|
1066
|
+
data: { message: requireConfirmation("config.reset") }
|
|
1067
|
+
}));
|
|
1068
|
+
throw new CliError("", 1);
|
|
1069
|
+
}
|
|
1070
|
+
const c = ctx.context();
|
|
1071
|
+
writeConfig(c.repoRoot, {});
|
|
1072
|
+
ctx.writer.write(createChofiEvent({
|
|
1073
|
+
clock: ctx.clock,
|
|
1074
|
+
event: "summary",
|
|
1075
|
+
phase: "config.reset",
|
|
1076
|
+
status: "passed",
|
|
1077
|
+
data: {}
|
|
1078
|
+
}));
|
|
1079
|
+
});
|
|
1080
|
+
return program;
|
|
1081
|
+
}
|
|
1082
|
+
export async function runChofiCli(argv, dependencies = {}) {
|
|
1083
|
+
const writer = dependencies.writer ?? new NdjsonEventWriter();
|
|
1084
|
+
const clock = dependencies.clock;
|
|
1085
|
+
const runtime = dependencies.runtime ??
|
|
1086
|
+
new RuntimeController({ spawner: dependencies.spawner });
|
|
1087
|
+
const context = () => discoverMobileProjectContext({
|
|
1088
|
+
cwd: dependencies.cwd,
|
|
1089
|
+
pathEnv: dependencies.pathEnv,
|
|
1090
|
+
toolChecker: dependencies.toolChecker
|
|
1091
|
+
});
|
|
1092
|
+
const ctx = {
|
|
1093
|
+
clock,
|
|
1094
|
+
runtime,
|
|
1095
|
+
writer,
|
|
1096
|
+
context,
|
|
1097
|
+
runner: dependencies.runner ?? createCommandRunner(dependencies.spawner)
|
|
1098
|
+
};
|
|
1099
|
+
const program = createProgram(ctx, dependencies);
|
|
1100
|
+
try {
|
|
1101
|
+
await program.parseAsync(argv, { from: "user" });
|
|
1102
|
+
return 0;
|
|
1103
|
+
}
|
|
1104
|
+
catch (error) {
|
|
1105
|
+
if (error instanceof CliError) {
|
|
1106
|
+
return error.exitCode;
|
|
1107
|
+
}
|
|
1108
|
+
if (error instanceof Error && "exitCode" in error && typeof error.exitCode === "number") {
|
|
1109
|
+
const isMissingJson = error.message.includes("required option '--json'");
|
|
1110
|
+
const code = isMissingJson ? 2 : error.exitCode === 1 && error.message.includes("unknown command") ? 2 : error.exitCode;
|
|
1111
|
+
writer.write(createChofiEvent({
|
|
1112
|
+
clock,
|
|
1113
|
+
event: "summary",
|
|
1114
|
+
phase: "usage",
|
|
1115
|
+
status: "failed",
|
|
1116
|
+
data: {
|
|
1117
|
+
message: isMissingJson
|
|
1118
|
+
? "chofi requires --json for every command. Example: chofi context --json"
|
|
1119
|
+
: error.message
|
|
1120
|
+
}
|
|
1121
|
+
}));
|
|
1122
|
+
return code;
|
|
1123
|
+
}
|
|
1124
|
+
writer.write(createChofiEvent({
|
|
1125
|
+
clock,
|
|
1126
|
+
event: "summary",
|
|
1127
|
+
phase: "usage",
|
|
1128
|
+
status: "failed",
|
|
1129
|
+
data: { message: String(error) }
|
|
1130
|
+
}));
|
|
1131
|
+
return 2;
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
function emitPlan(ctx, plan) {
|
|
1135
|
+
ctx.writer.write(createChofiEvent({
|
|
1136
|
+
clock: ctx.clock,
|
|
1137
|
+
event: "plan",
|
|
1138
|
+
phase: plan.phase,
|
|
1139
|
+
status: "planned",
|
|
1140
|
+
data: plan
|
|
1141
|
+
}));
|
|
1142
|
+
ctx.writer.write(createChofiEvent({
|
|
1143
|
+
clock: ctx.clock,
|
|
1144
|
+
event: "summary",
|
|
1145
|
+
phase: plan.phase,
|
|
1146
|
+
status: "planned",
|
|
1147
|
+
data: {
|
|
1148
|
+
commandCount: plan.commands.length,
|
|
1149
|
+
notes: plan.notes
|
|
1150
|
+
}
|
|
1151
|
+
}));
|
|
1152
|
+
}
|
|
1153
|
+
async function runDoctorCommand(input) {
|
|
1154
|
+
const checks = [];
|
|
1155
|
+
for (const command of doctorChecks(input.context)) {
|
|
1156
|
+
input.writer.write(createChofiEvent({
|
|
1157
|
+
clock: input.clock,
|
|
1158
|
+
event: "command_started",
|
|
1159
|
+
phase: "doctor",
|
|
1160
|
+
data: commandStartedData(command)
|
|
1161
|
+
}));
|
|
1162
|
+
const result = await input.runner(command);
|
|
1163
|
+
const status = result.exitCode === 0 ? "passed" : "failed";
|
|
1164
|
+
checks.push({
|
|
1165
|
+
name: command.name,
|
|
1166
|
+
status,
|
|
1167
|
+
exitCode: result.exitCode,
|
|
1168
|
+
durationMs: result.durationMs
|
|
1169
|
+
});
|
|
1170
|
+
input.writer.write(createChofiEvent({
|
|
1171
|
+
clock: input.clock,
|
|
1172
|
+
event: result.exitCode === 0 ? "command_completed" : "command_failed",
|
|
1173
|
+
phase: "doctor",
|
|
1174
|
+
status,
|
|
1175
|
+
data: {
|
|
1176
|
+
...commandStartedData(command),
|
|
1177
|
+
durationMs: result.durationMs,
|
|
1178
|
+
exitCode: result.exitCode,
|
|
1179
|
+
stderr: summarizeOutput(result.stderr),
|
|
1180
|
+
stdout: summarizeOutput(result.stdout)
|
|
1181
|
+
}
|
|
1182
|
+
}));
|
|
1183
|
+
}
|
|
1184
|
+
const status = checks.every((check) => check.exitCode === 0)
|
|
1185
|
+
? "passed"
|
|
1186
|
+
: "failed";
|
|
1187
|
+
input.writer.write(createChofiEvent({
|
|
1188
|
+
clock: input.clock,
|
|
1189
|
+
event: "summary",
|
|
1190
|
+
phase: "doctor",
|
|
1191
|
+
status,
|
|
1192
|
+
data: { checks }
|
|
1193
|
+
}));
|
|
1194
|
+
return status === "passed" ? 0 : 1;
|
|
1195
|
+
}
|
|
1196
|
+
async function runSimList(input) {
|
|
1197
|
+
try {
|
|
1198
|
+
const devices = await input.runtime.simList();
|
|
1199
|
+
input.writer.write(createChofiEvent({
|
|
1200
|
+
clock: input.clock,
|
|
1201
|
+
event: "summary",
|
|
1202
|
+
phase: "sim.list",
|
|
1203
|
+
status: "passed",
|
|
1204
|
+
data: { devices }
|
|
1205
|
+
}));
|
|
1206
|
+
return 0;
|
|
1207
|
+
}
|
|
1208
|
+
catch (error) {
|
|
1209
|
+
input.writer.write(createChofiEvent({
|
|
1210
|
+
clock: input.clock,
|
|
1211
|
+
event: "summary",
|
|
1212
|
+
phase: "sim.list",
|
|
1213
|
+
status: "failed",
|
|
1214
|
+
data: { message: String(error) }
|
|
1215
|
+
}));
|
|
1216
|
+
return 1;
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
async function runSimAction(input) {
|
|
1220
|
+
input.writer.write(createChofiEvent({
|
|
1221
|
+
clock: input.clock,
|
|
1222
|
+
event: "command_started",
|
|
1223
|
+
phase: input.phase,
|
|
1224
|
+
data: { target: input.target }
|
|
1225
|
+
}));
|
|
1226
|
+
try {
|
|
1227
|
+
await input.action();
|
|
1228
|
+
input.writer.write(createChofiEvent({
|
|
1229
|
+
clock: input.clock,
|
|
1230
|
+
event: "command_completed",
|
|
1231
|
+
phase: input.phase,
|
|
1232
|
+
status: "passed",
|
|
1233
|
+
data: { target: input.target }
|
|
1234
|
+
}));
|
|
1235
|
+
return 0;
|
|
1236
|
+
}
|
|
1237
|
+
catch (error) {
|
|
1238
|
+
input.writer.write(createChofiEvent({
|
|
1239
|
+
clock: input.clock,
|
|
1240
|
+
event: "command_failed",
|
|
1241
|
+
phase: input.phase,
|
|
1242
|
+
status: "failed",
|
|
1243
|
+
data: { target: input.target, message: String(error) }
|
|
1244
|
+
}));
|
|
1245
|
+
return 1;
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
async function runExecution(input) {
|
|
1249
|
+
input.writer.write(createChofiEvent({
|
|
1250
|
+
clock: input.clock,
|
|
1251
|
+
event: "command_started",
|
|
1252
|
+
phase: input.phase,
|
|
1253
|
+
data: {}
|
|
1254
|
+
}));
|
|
1255
|
+
try {
|
|
1256
|
+
const result = await input.action();
|
|
1257
|
+
const data = input.mapResult ? input.mapResult(result) : {};
|
|
1258
|
+
input.writer.write(createChofiEvent({
|
|
1259
|
+
clock: input.clock,
|
|
1260
|
+
event: "command_completed",
|
|
1261
|
+
phase: input.phase,
|
|
1262
|
+
status: "passed",
|
|
1263
|
+
data
|
|
1264
|
+
}));
|
|
1265
|
+
input.writer.write(createChofiEvent({
|
|
1266
|
+
clock: input.clock,
|
|
1267
|
+
event: "summary",
|
|
1268
|
+
phase: input.phase,
|
|
1269
|
+
status: "passed",
|
|
1270
|
+
data
|
|
1271
|
+
}));
|
|
1272
|
+
return 0;
|
|
1273
|
+
}
|
|
1274
|
+
catch (error) {
|
|
1275
|
+
const chofiError = classifyError(error);
|
|
1276
|
+
const errorData = {
|
|
1277
|
+
message: chofiError.message,
|
|
1278
|
+
recoverySuggestion: chofiError.recoverySuggestion
|
|
1279
|
+
};
|
|
1280
|
+
input.writer.write(createChofiEvent({
|
|
1281
|
+
clock: input.clock,
|
|
1282
|
+
event: "command_failed",
|
|
1283
|
+
phase: input.phase,
|
|
1284
|
+
status: "failed",
|
|
1285
|
+
errorType: chofiError.code,
|
|
1286
|
+
data: errorData
|
|
1287
|
+
}));
|
|
1288
|
+
input.writer.write(createChofiEvent({
|
|
1289
|
+
clock: input.clock,
|
|
1290
|
+
event: "summary",
|
|
1291
|
+
phase: input.phase,
|
|
1292
|
+
status: "failed",
|
|
1293
|
+
errorType: chofiError.code,
|
|
1294
|
+
data: errorData
|
|
1295
|
+
}));
|
|
1296
|
+
throw new CliError(chofiError.message, 1);
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
function commandStartedData(command) {
|
|
1300
|
+
return {
|
|
1301
|
+
name: command.name,
|
|
1302
|
+
command: command.command,
|
|
1303
|
+
args: command.args,
|
|
1304
|
+
cwd: command.cwd
|
|
1305
|
+
};
|
|
1306
|
+
}
|
|
1307
|
+
function writeToolCheck(writer, clock, availability) {
|
|
1308
|
+
const status = availability.status === "available" ? "available" : "missing";
|
|
1309
|
+
writer.write(createChofiEvent({
|
|
1310
|
+
clock,
|
|
1311
|
+
event: "tool_checked",
|
|
1312
|
+
phase: "maestro.check",
|
|
1313
|
+
status,
|
|
1314
|
+
data: availability
|
|
1315
|
+
}));
|
|
1316
|
+
writer.write(createChofiEvent({
|
|
1317
|
+
clock,
|
|
1318
|
+
event: "summary",
|
|
1319
|
+
phase: "maestro.check",
|
|
1320
|
+
status,
|
|
1321
|
+
data: { maestro: availability }
|
|
1322
|
+
}));
|
|
1323
|
+
}
|
|
1324
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
1325
|
+
process.exitCode = await runChofiCli(process.argv.slice(2));
|
|
1326
|
+
}
|