@autifyhq/aximo-cli 0.9.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +1 -1
  2. package/dist/commands/device/connect.js +19 -1
  3. package/dist/commands/device/doctor.d.ts +2 -0
  4. package/dist/commands/device/doctor.js +37 -10
  5. package/dist/commands/device/doctor.test.js +26 -1
  6. package/dist/commands/mcp/server.test.js +3 -1
  7. package/dist/commands/mcp/tools.js +28 -0
  8. package/dist/commands/mcp/tools.test.js +90 -0
  9. package/dist/commands/mobile/connect-core.d.ts +2 -2
  10. package/dist/commands/mobile/connect-core.js +35 -2
  11. package/dist/commands/mobile/signing-consent.js +5 -11
  12. package/dist/desktop-server/preflight.d.ts +43 -0
  13. package/dist/desktop-server/preflight.js +78 -0
  14. package/dist/desktop-server/preflight.test.d.ts +1 -0
  15. package/dist/desktop-server/preflight.test.js +115 -0
  16. package/dist/mobile-control/appium-controller.d.ts +24 -0
  17. package/dist/mobile-control/appium-controller.js +221 -127
  18. package/dist/mobile-control/appium-controller.test.js +105 -0
  19. package/dist/mobile-control/frame-sender.d.ts +34 -0
  20. package/dist/mobile-control/frame-sender.js +96 -0
  21. package/dist/mobile-control/frame-sender.test.d.ts +1 -0
  22. package/dist/mobile-control/frame-sender.test.js +108 -0
  23. package/dist/mobile-control/mjpeg-stream.d.ts +43 -0
  24. package/dist/mobile-control/mjpeg-stream.js +160 -0
  25. package/dist/mobile-control/mjpeg-stream.test.d.ts +1 -0
  26. package/dist/mobile-control/mjpeg-stream.test.js +93 -0
  27. package/dist/mobile-control/tunnel.d.ts +10 -1
  28. package/dist/mobile-control/tunnel.js +27 -3
  29. package/dist/mobile-control/tunnel.test.js +50 -0
  30. package/dist/mobile-control/webdriver.d.ts +2 -0
  31. package/dist/mobile-control/webdriver.js +6 -0
  32. package/dist/prompt.d.ts +11 -0
  33. package/dist/prompt.js +19 -0
  34. package/dist/types.d.ts +17 -0
  35. package/package.json +1 -1
package/README.md CHANGED
@@ -623,7 +623,7 @@ claude mcp add aximo -- aximo mcp
623
623
 
624
624
  VS Code (Copilot agent mode) uses the same entry under a `servers` key in `.vscode/mcp.json`.
625
625
 
626
- The server exposes 16 tools: 10 read-only ones for inspecting sessions and cases (`aximo_get_session_timeline` is the load-bearing one for Playwright export) plus 6 that author content (create and delete cases, create plans, save a session as a case). See the [MCP reference](./skills/aximo-cli/references/mcp.md) for per-client config paths, the full tool table, the Playwright export prompt, and a no-client smoke test.
626
+ The server exposes 18 tools: 12 read-only ones for inspecting sessions, cases, and plan runs (`aximo_get_session_timeline` is the load-bearing one for Playwright export; `aximo_list_plan_runs` and `aximo_get_plan_run` cover run-status polling) plus 6 that author content (create and delete cases, create plans, save a session as a case). See the [MCP reference](./skills/aximo-cli/references/mcp.md) for per-client config paths, the full tool table, the Playwright export prompt, and a no-client smoke test.
627
627
 
628
628
  ## Agent Skills
629
629
 
@@ -1,5 +1,5 @@
1
- import { defineCommand } from "citty";
2
1
  import { existsSync, readFileSync } from "node:fs";
2
+ import { defineCommand } from "citty";
3
3
  import { getBaseUrl } from "../../client.js";
4
4
  import { formatError, printResult } from "../../output.js";
5
5
  import { getDeviceConfigPath } from "./paths.js";
@@ -29,10 +29,28 @@ function getRelayUrl() {
29
29
  }
30
30
  async function connectToRelay(config) {
31
31
  const { ensurePythonReady, startDesktopRemote } = await import("../../desktop-server/setup.js");
32
+ const { confirmProceed, formatPreflightLines, runLocalPreflight } = await import("../../desktop-server/preflight.js");
32
33
  console.log("Preparing Python environment...");
33
34
  const { pythonPath, scriptPath, agentVersion } = await ensurePythonReady();
34
35
  console.log(`Python ready: ${pythonPath}`);
35
36
  console.log(`Agent version: ${agentVersion}`);
37
+ // Last point where a human is present: sessions are triggered remotely, so this
38
+ // is the only place a failed check can offer to stop before it wastes a run.
39
+ const preflight = runLocalPreflight({ pythonPath, scriptPath });
40
+ if (preflight.kind === "ok") {
41
+ for (const line of formatPreflightLines(preflight.report))
42
+ console.log(line);
43
+ if (!(await confirmProceed(preflight.report))) {
44
+ console.log("Not connecting. Fix the checks above and run 'aximo device connect' again.");
45
+ return;
46
+ }
47
+ }
48
+ else if (preflight.kind === "error") {
49
+ console.log(`Desktop environment: could not be checked (${preflight.detail}).`);
50
+ }
51
+ else {
52
+ console.log("Desktop environment: checks unavailable for this agent version.");
53
+ }
36
54
  const relayUrl = getRelayUrl();
37
55
  console.log(`Starting desktop agent, relay: ${relayUrl}`);
38
56
  let remoteProcess = startDesktopRemote(pythonPath, scriptPath, {
@@ -1,4 +1,5 @@
1
1
  import { type ClientDeps } from "../../client.js";
2
+ import { type LocalPreflight } from "../../desktop-server/preflight.js";
2
3
  import type { Envelope } from "../../types.js";
3
4
  export type CheckStatus = "pass" | "warn" | "fail" | "info";
4
5
  export interface Check {
@@ -30,6 +31,7 @@ export interface AgentTlsDeps {
30
31
  probe?: (host: string, port: number, ca: Buffer) => Promise<TlsProbeResult>;
31
32
  }
32
33
  export declare function checkAgentTlsTrust(deps?: AgentTlsDeps): Promise<Check>;
34
+ export declare function checkDesktopEnvironment(runPreflight?: () => LocalPreflight): Check[];
33
35
  export interface RunDoctorOptions {
34
36
  skipRelay?: boolean;
35
37
  skipApi?: boolean;
@@ -7,6 +7,7 @@ import { WebSocket } from "ws";
7
7
  import { getBaseUrl, request } from "../../client.js";
8
8
  import { loadConfig } from "../../config.js";
9
9
  import { readCachedManifest } from "../../desktop-server/agent-sync.js";
10
+ import { runLocalPreflight } from "../../desktop-server/preflight.js";
10
11
  import { installedPythonTag, PYTHON_RELEASE, PYTHON_VERSION } from "../../desktop-server/setup.js";
11
12
  import { formatSuccess, printResult } from "../../output.js";
12
13
  import { readDeviceConfig } from "./device-config.js";
@@ -450,6 +451,41 @@ async function checkCurrentStatus(deviceId, deps) {
450
451
  };
451
452
  }
452
453
  }
454
+ // The desktop agent is the only thing that can see the tester's screen, input
455
+ // source and permissions, so doctor reports what it reports rather than
456
+ // reimplementing the probes here.
457
+ const MANUAL_DESKTOP_HINT = {
458
+ darwin: "Grant Accessibility + Screen Recording in System Settings → Privacy & Security",
459
+ win32: "Run from an elevated terminal if the test touches admin windows",
460
+ linux: "Use an X11/Xorg session; Wayland blocks capture and synthetic input",
461
+ };
462
+ export function checkDesktopEnvironment(runPreflight = runLocalPreflight) {
463
+ const result = runPreflight();
464
+ if (result.kind === "ok") {
465
+ return result.report.checks.map((check) => ({ ...check, name: `desktop: ${check.name}` }));
466
+ }
467
+ const hint = MANUAL_DESKTOP_HINT[platform()];
468
+ if (result.kind === "error") {
469
+ return [
470
+ {
471
+ name: "desktop environment",
472
+ status: "fail",
473
+ message: `the agent could not check this machine: ${result.detail}`,
474
+ hint: "Run: aximo device reset --deps && aximo device setup",
475
+ },
476
+ ];
477
+ }
478
+ return [
479
+ {
480
+ name: "desktop environment",
481
+ status: "info",
482
+ message: "not checked — the agent is not installed yet",
483
+ ...(hint
484
+ ? { hint: `Run: aximo device setup. ${hint}` }
485
+ : { hint: "Run: aximo device setup" }),
486
+ },
487
+ ];
488
+ }
453
489
  function checkPlatformExtras() {
454
490
  const p = platform();
455
491
  if (p === "linux") {
@@ -468,16 +504,6 @@ function checkPlatformExtras() {
468
504
  },
469
505
  ];
470
506
  }
471
- if (p === "darwin") {
472
- return [
473
- {
474
- name: "macos permissions",
475
- status: "info",
476
- message: "Accessibility + Screen Recording must be granted manually",
477
- hint: "System Settings → Privacy & Security → Accessibility / Screen Recording",
478
- },
479
- ];
480
- }
481
507
  return [];
482
508
  }
483
509
  function logDirUsage() {
@@ -509,6 +535,7 @@ export async function runDeviceDoctor(options = {}, deps) {
509
535
  checks.push(checkPythonDeps());
510
536
  checks.push(checkAgentFiles());
511
537
  checks.push(...checkPlatformExtras());
538
+ checks.push(...checkDesktopEnvironment());
512
539
  checks.push(logDirUsage());
513
540
  if (!options.skipApi && checks[0]?.status !== "fail") {
514
541
  checks.push(await checkAgentUpToDate(deps));
@@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
5
5
  import { HttpResponse, http, server, TEST_BASE_URL } from "../../test-utils.js";
6
- import { checkAgentTlsTrust, relayProbeUrl, runDeviceDoctor } from "./doctor.js";
6
+ import { checkAgentTlsTrust, checkDesktopEnvironment, relayProbeUrl, runDeviceDoctor, } from "./doctor.js";
7
7
  let tempHome;
8
8
  beforeAll(() => {
9
9
  tempHome = mkdtempSync(join(tmpdir(), "aximo-doctor-"));
@@ -153,3 +153,28 @@ describe("checkAgentTlsTrust", () => {
153
153
  expect(c.message).toContain("missing file");
154
154
  });
155
155
  });
156
+ describe("checkDesktopEnvironment", () => {
157
+ const REPORT = {
158
+ agent: { platform: "Darwin" },
159
+ checks: [
160
+ { name: "accessibility permission", status: "fail", message: "not granted" },
161
+ { name: "capture target", status: "info", message: "capturing the primary monitor" },
162
+ ],
163
+ };
164
+ it("reports what the agent found, namespaced so its checks are distinguishable", () => {
165
+ const checks = checkDesktopEnvironment(() => ({ kind: "ok", report: REPORT }));
166
+ expect(checks.map((c) => c.name)).toEqual([
167
+ "desktop: accessibility permission",
168
+ "desktop: capture target",
169
+ ]);
170
+ expect(checks[0]?.status).toBe("fail");
171
+ });
172
+ // Before `aximo device setup` there is no agent to ask, so doctor still has to
173
+ // say something useful rather than claim the desktop is fine.
174
+ it("falls back to a manual note when the agent cannot be run", () => {
175
+ const checks = checkDesktopEnvironment(() => ({ kind: "not-installed" }));
176
+ expect(checks).toHaveLength(1);
177
+ expect(checks[0]?.status).toBe("info");
178
+ expect(checks[0]?.hint ?? checks[0]?.message).toContain(process.platform === "darwin" ? "System Settings" : "aximo device setup");
179
+ });
180
+ });
@@ -24,7 +24,7 @@ async function connect() {
24
24
  };
25
25
  }
26
26
  describe("createMcpServer wiring", () => {
27
- it("exposes all 16 tools through tools/list with valid JSON Schema", async () => {
27
+ it("exposes all 18 tools through tools/list with valid JSON Schema", async () => {
28
28
  const { client, cleanup } = await connect();
29
29
  try {
30
30
  const { tools } = await client.listTools();
@@ -44,6 +44,8 @@ describe("createMcpServer wiring", () => {
44
44
  "aximo_create_plan",
45
45
  "aximo_add_case_to_plan",
46
46
  "aximo_remove_case_from_plan",
47
+ "aximo_list_plan_runs",
48
+ "aximo_get_plan_run",
47
49
  "aximo_save_session_as_case",
48
50
  ].sort());
49
51
  const timeline = tools.find((t) => t.name === "aximo_get_session_timeline");
@@ -260,6 +260,34 @@ export function defineTools(deps = {}) {
260
260
  return await request(`/api/plans/${encodeURIComponent(planId)}/cases/${encodeURIComponent(caseId)}`, { method: "DELETE" }, deps);
261
261
  }),
262
262
  },
263
+ {
264
+ name: "aximo_list_plan_runs",
265
+ title: "List plan runs",
266
+ description: "List a plan's runs, newest first, without per-case detail. Use aximo_get_plan_run for one run's per-case breakdown. Paginate by passing the previous page's nextCursor as cursor. A run's status reflects execution, not the test verdict: read passedCases/failedCases to judge outcomes.",
267
+ inputSchema: {
268
+ planId: z.string().uuid(),
269
+ limit: z.number().int().positive().max(50).optional(),
270
+ cursor: z
271
+ .string()
272
+ .optional()
273
+ .describe("nextCursor from the previous page (ISO 8601 createdAt of its last run)."),
274
+ },
275
+ handler: async ({ planId, ...rest }) => call(async () => {
276
+ return await request(`/api/triggers/plans/${encodeURIComponent(planId)}/runs`, { query: buildQuery(rest) }, deps);
277
+ }),
278
+ },
279
+ {
280
+ name: "aximo_get_plan_run",
281
+ title: "Get plan run status",
282
+ description: "Get the overall status and per-case breakdown of a plan run (from aximo_list_plan_runs, or the planRunId returned when a run was triggered). Poll this to observe a run in progress; wait at least 5 seconds between polls. A run's status reflects execution, not the test verdict: a run can complete with failedCases > 0, so read passedCases/failedCases and the per-case statuses to judge outcomes.",
283
+ inputSchema: {
284
+ planId: z.string().uuid(),
285
+ planRunId: z.string().uuid(),
286
+ },
287
+ handler: async ({ planId, planRunId }) => call(async () => {
288
+ return await request(`/api/triggers/plans/${encodeURIComponent(planId)}/runs/${encodeURIComponent(planRunId)}`, {}, deps);
289
+ }),
290
+ },
263
291
  {
264
292
  name: "aximo_save_session_as_case",
265
293
  title: "Save session as test case",
@@ -36,6 +36,8 @@ describe("defineTools", () => {
36
36
  "aximo_create_plan",
37
37
  "aximo_add_case_to_plan",
38
38
  "aximo_remove_case_from_plan",
39
+ "aximo_list_plan_runs",
40
+ "aximo_get_plan_run",
39
41
  "aximo_save_session_as_case",
40
42
  ]);
41
43
  });
@@ -141,6 +143,94 @@ describe("aximo_list_projects", () => {
141
143
  expect(new URL(captured.url).pathname).toBe("/api/organizations/projects");
142
144
  });
143
145
  });
146
+ describe("aximo_list_plan_runs", () => {
147
+ const planId = "77777777-7777-4777-9777-777777777777";
148
+ it("calls /api/triggers/plans/:id/runs, passing limit and cursor through", async () => {
149
+ let url = null;
150
+ const payload = {
151
+ items: [
152
+ {
153
+ id: "88888888-8888-4888-9888-888888888888",
154
+ status: "succeeded",
155
+ triggerSource: "api",
156
+ executionMode: "parallel",
157
+ totalCases: 3,
158
+ completedCases: 3,
159
+ passedCases: 2,
160
+ failedCases: 1,
161
+ startedAt: "2026-07-01T00:00:00.000Z",
162
+ completedAt: "2026-07-01T00:10:00.000Z",
163
+ createdAt: "2026-07-01T00:00:00.000Z",
164
+ },
165
+ ],
166
+ nextCursor: "2026-07-01T00:00:00.000Z",
167
+ };
168
+ server.use(http.get(`${TEST_BASE_URL}/api/triggers/plans/${planId}/runs`, ({ request: req }) => {
169
+ url = new URL(req.url);
170
+ return HttpResponse.json(payload);
171
+ }));
172
+ const tool = byName(defineTools(), "aximo_list_plan_runs");
173
+ const result = await tool.handler({
174
+ planId,
175
+ limit: 5,
176
+ cursor: "2026-07-02T00:00:00.000Z",
177
+ });
178
+ expect(result.isError).toBeUndefined();
179
+ expect(parsePayload(result)).toEqual(payload);
180
+ expect(url).not.toBeNull();
181
+ expect(url.searchParams.get("limit")).toBe("5");
182
+ expect(url.searchParams.get("cursor")).toBe("2026-07-02T00:00:00.000Z");
183
+ });
184
+ it("preserves the backend's 404 for an unknown plan", async () => {
185
+ server.use(http.get(`${TEST_BASE_URL}/api/triggers/plans/${planId}/runs`, () => HttpResponse.json({ error: "Test plan not found" }, { status: 404 })));
186
+ const tool = byName(defineTools(), "aximo_list_plan_runs");
187
+ const result = await tool.handler({ planId });
188
+ expect(result.isError).toBe(true);
189
+ expect(parsePayload(result).error).toBe("Test plan not found");
190
+ });
191
+ });
192
+ describe("aximo_get_plan_run", () => {
193
+ const planId = "77777777-7777-4777-9777-777777777777";
194
+ const planRunId = "99999999-9999-4999-9999-999999999999";
195
+ it("calls /api/triggers/plans/:id/runs/:runId and returns the run with cases", async () => {
196
+ const payload = {
197
+ id: planRunId,
198
+ status: "succeeded",
199
+ triggerSource: "api",
200
+ executionMode: "sequential",
201
+ totalCases: 1,
202
+ completedCases: 1,
203
+ passedCases: 0,
204
+ failedCases: 1,
205
+ startedAt: "2026-07-01T00:00:00.000Z",
206
+ completedAt: "2026-07-01T00:05:00.000Z",
207
+ createdAt: "2026-07-01T00:00:00.000Z",
208
+ cases: [
209
+ {
210
+ id: "aaaaaaaa-aaaa-4aaa-9aaa-aaaaaaaaaaaa",
211
+ caseId: "bbbbbbbb-bbbb-4bbb-9bbb-bbbbbbbbbbbb",
212
+ title: "Login flow",
213
+ type: "web",
214
+ status: "failed",
215
+ startedAt: "2026-07-01T00:00:10.000Z",
216
+ completedAt: "2026-07-01T00:04:40.000Z",
217
+ },
218
+ ],
219
+ };
220
+ server.use(http.get(`${TEST_BASE_URL}/api/triggers/plans/${planId}/runs/${planRunId}`, () => HttpResponse.json(payload)));
221
+ const tool = byName(defineTools(), "aximo_get_plan_run");
222
+ const result = await tool.handler({ planId, planRunId });
223
+ expect(result.isError).toBeUndefined();
224
+ expect(parsePayload(result)).toEqual(payload);
225
+ });
226
+ it("preserves the backend's 404 for an unknown run id", async () => {
227
+ server.use(http.get(`${TEST_BASE_URL}/api/triggers/plans/${planId}/runs/${planRunId}`, () => HttpResponse.json({ error: "Plan run not found" }, { status: 404 })));
228
+ const tool = byName(defineTools(), "aximo_get_plan_run");
229
+ const result = await tool.handler({ planId, planRunId });
230
+ expect(result.isError).toBe(true);
231
+ expect(parsePayload(result).error).toBe("Plan run not found");
232
+ });
233
+ });
144
234
  describe("call() error mapping", () => {
145
235
  const sessionId = "66666666-6666-4666-9666-666666666666";
146
236
  it("maps 403 with the original message and a hint", async () => {
@@ -1,5 +1,5 @@
1
1
  import type { ErrorEnvelope } from "../../types.js";
2
- import type { MobileController } from "../../mobile-control/controller.js";
2
+ import { AppiumController } from "../../mobile-control/appium-controller.js";
3
3
  import { type IosSigningConfig } from "../../mobile-control/ios-signing.js";
4
4
  import { type MobileDeviceConfig } from "./paths.js";
5
5
  export declare function getTunnelOrigin(): string;
@@ -8,7 +8,7 @@ interface ConfigOk {
8
8
  config: MobileDeviceConfig;
9
9
  }
10
10
  export declare function resolveDeviceConfig(deviceId: string | undefined): ConfigOk | ErrorEnvelope;
11
- export declare function buildController(config: MobileDeviceConfig, endpoint: string, iosSigning?: IosSigningConfig, wdaLocalPort?: number | (() => Promise<number>)): MobileController;
11
+ export declare function buildController(config: MobileDeviceConfig, endpoint: string, iosSigning?: IosSigningConfig, wdaLocalPort?: number | (() => Promise<number>), mjpegServerPort?: number | (() => Promise<number>)): AppiumController;
12
12
  export declare function connectWithConfig(config: MobileDeviceConfig, options?: {
13
13
  assumeYes?: boolean;
14
14
  }): Promise<void>;
@@ -4,6 +4,8 @@ import { AppiumController } from "../../mobile-control/appium-controller.js";
4
4
  import { installUploadedApp } from "../../mobile-control/app-install.js";
5
5
  import { pickFreePort, startAppiumServer } from "../../mobile-control/appium-server.js";
6
6
  import { withBundleIdTranslation } from "../../mobile-control/bundle-id-map.js";
7
+ import { FrameSender } from "../../mobile-control/frame-sender.js";
8
+ import { MjpegStreamClient } from "../../mobile-control/mjpeg-stream.js";
7
9
  import { getIosDeviceInfo, iosMajorVersion } from "../../mobile-control/ios-devicectl.js";
8
10
  import { ensureIosTunnel, TUNNEL_STOP_COMMAND } from "../../mobile-control/ios-tunnel.js";
9
11
  import { resolveIosSigning, resolveIosSigningAuto, } from "../../mobile-control/ios-signing.js";
@@ -38,11 +40,12 @@ export function resolveDeviceConfig(deviceId) {
38
40
  }
39
41
  return { ok: true, config: only };
40
42
  }
41
- export function buildController(config, endpoint, iosSigning, wdaLocalPort) {
43
+ export function buildController(config, endpoint, iosSigning, wdaLocalPort, mjpegServerPort) {
42
44
  return new AppiumController({
43
45
  endpoint,
44
46
  platform: config.platform,
45
47
  target: config.target,
48
+ ...(mjpegServerPort ? { mjpegServerPort } : {}),
46
49
  ...(config.platform === "ios"
47
50
  ? { iosSigning: iosSigning ?? resolveIosSigning(), ...(wdaLocalPort ? { wdaLocalPort } : {}) }
48
51
  : {}),
@@ -160,10 +163,35 @@ export async function connectWithConfig(config, options) {
160
163
  }
161
164
  // Re-signing can remap bundle ids; launches from the backend use the original.
162
165
  const installedBundleIds = new Map();
163
- const rawController = buildController(config, server.endpoint, iosSigning, wdaLocalPort);
166
+ const rawController = buildController(config, server.endpoint, iosSigning, wdaLocalPort, pickFreePort);
164
167
  const controller = config.platform === "ios"
165
168
  ? withBundleIdTranslation(rawController, installedBundleIds)
166
169
  : rawController;
170
+ // Live-view pipeline: capture (device MJPEG server -> local socket) feeds the
171
+ // sender (dedicated tunnel connection -> gateway). Started/stopped on demand
172
+ // by the gateway; frame acks reporting "no viewers" also stop it.
173
+ const frameSender = new FrameSender({
174
+ wsUrl: getTunnelOrigin(),
175
+ deviceToken: config.deviceToken,
176
+ onInactive: () => stream.stop(),
177
+ log: (message) => console.error(message),
178
+ });
179
+ const streamClient = new MjpegStreamClient({
180
+ ensureReady: () => rawController.ensureReady(),
181
+ getPort: () => rawController.getMjpegPort(),
182
+ onFrame: (frame) => frameSender.push(frame),
183
+ log: (message) => console.error(message),
184
+ });
185
+ const stream = {
186
+ start: () => {
187
+ frameSender.start();
188
+ streamClient.start();
189
+ },
190
+ stop: () => {
191
+ void streamClient.stop();
192
+ frameSender.stop();
193
+ },
194
+ };
167
195
  try {
168
196
  // Warm up the device session before connecting to Aximo so WDA build/signing/
169
197
  // trust problems surface here — with a clear, actionable message — instead of
@@ -178,6 +206,7 @@ export async function connectWithConfig(config, options) {
178
206
  deviceToken: config.deviceToken,
179
207
  deviceName: config.name,
180
208
  controller,
209
+ stream,
181
210
  installApp: async ({ uploadId }) => {
182
211
  const result = await installUploadedApp({
183
212
  uploadId,
@@ -196,6 +225,10 @@ export async function connectWithConfig(config, options) {
196
225
  });
197
226
  }
198
227
  finally {
228
+ // Await the capture loop before tearing down the controller/server so a
229
+ // mid-flight ensureReady/stream read can't race the Appium session close.
230
+ frameSender.stop();
231
+ await streamClient.stop();
199
232
  await controller.close();
200
233
  await server.stop();
201
234
  if (tunnelRunning) {
@@ -1,8 +1,4 @@
1
- import { createInterface } from "node:readline/promises";
2
- function defaultAsk(question) {
3
- const rl = createInterface({ input: process.stdin, output: process.stderr });
4
- return rl.question(question).finally(() => rl.close());
5
- }
1
+ import { confirmYesNo } from "../../prompt.js";
6
2
  /**
7
3
  * Informed consent before using the user's Apple signing identity: connecting
8
4
  * can re-sign apps/WDA with it, register the device to the team's Apple
@@ -19,10 +15,8 @@ export async function confirmIosSigning(options) {
19
15
  "Set AXIMO_IOS_TEAM_ID to sign with a different team.");
20
16
  if (options.assumeYes)
21
17
  return true;
22
- const interactive = options.interactive ?? process.stdin.isTTY === true;
23
- if (!interactive)
24
- return true;
25
- const ask = options.ask ?? defaultAsk;
26
- const answer = (await ask("Proceed with this signing identity? [Y/n] ")).trim().toLowerCase();
27
- return answer !== "n" && answer !== "no";
18
+ return confirmYesNo("Proceed with this signing identity?", {
19
+ ...(options.interactive === undefined ? {} : { interactive: options.interactive }),
20
+ ...(options.ask ? { ask: options.ask } : {}),
21
+ });
28
22
  }
@@ -0,0 +1,43 @@
1
+ import { type SpawnSyncReturns } from "node:child_process";
2
+ import type { Check } from "../commands/device/doctor.js";
3
+ export interface PreflightReport {
4
+ agent?: {
5
+ platform?: string;
6
+ };
7
+ checks: Check[];
8
+ }
9
+ /**
10
+ * Either the agent's report, "there is no agent to ask yet", or "the agent is
11
+ * installed but could not answer". Keeping the last two apart matters: doctor
12
+ * must not tell a tester their machine is fine, or unconfigured, when the probe
13
+ * actually crashed.
14
+ */
15
+ export type LocalPreflight = {
16
+ kind: "ok";
17
+ report: PreflightReport;
18
+ } | {
19
+ kind: "not-installed";
20
+ } | {
21
+ kind: "error";
22
+ detail: string;
23
+ };
24
+ export type SpawnSyncFn = (command: string, args: string[], options: {
25
+ encoding: "utf-8";
26
+ timeout: number;
27
+ env: NodeJS.ProcessEnv;
28
+ }) => SpawnSyncReturns<string>;
29
+ export interface LocalPreflightOptions {
30
+ pythonPath?: string;
31
+ scriptPath?: string;
32
+ spawn?: SpawnSyncFn;
33
+ }
34
+ /** Run the desktop agent's own environment checks without connecting to a gateway. */
35
+ export declare function runLocalPreflight(options?: LocalPreflightOptions): LocalPreflight;
36
+ export declare function formatPreflightLines(report: PreflightReport): string[];
37
+ export declare function hasBlockingChecks(report: PreflightReport): boolean;
38
+ export interface ConfirmProceedOptions {
39
+ interactive?: boolean;
40
+ ask?: (question: string) => Promise<string>;
41
+ }
42
+ /** Ask whether to continue past a failed check, but only when there is one. */
43
+ export declare function confirmProceed(report: PreflightReport, options?: ConfirmProceedOptions): Promise<boolean>;
@@ -0,0 +1,78 @@
1
+ import { spawnSync as nodeSpawnSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { getAgentScriptPath, getAximoDir, getEmbeddedPythonBinary, } from "../commands/device/paths.js";
4
+ import { confirmYesNo } from "../prompt.js";
5
+ const PREFLIGHT_TIMEOUT_MS = 20_000;
6
+ const STATUSES = ["pass", "warn", "fail", "info"];
7
+ function isCheck(value) {
8
+ if (!value || typeof value !== "object")
9
+ return false;
10
+ const check = value;
11
+ return (typeof check["name"] === "string" &&
12
+ typeof check["message"] === "string" &&
13
+ STATUSES.includes(check["status"]));
14
+ }
15
+ function parseReport(stdout) {
16
+ let parsed;
17
+ try {
18
+ parsed = JSON.parse(stdout);
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ if (!parsed || typeof parsed !== "object")
24
+ return null;
25
+ const checks = parsed["checks"];
26
+ if (!Array.isArray(checks) || !checks.every(isCheck))
27
+ return null;
28
+ return parsed;
29
+ }
30
+ function firstLine(text) {
31
+ return text.trim().split("\n")[0]?.trim() ?? "";
32
+ }
33
+ /** Run the desktop agent's own environment checks without connecting to a gateway. */
34
+ export function runLocalPreflight(options = {}) {
35
+ const pythonPath = options.pythonPath ?? getEmbeddedPythonBinary();
36
+ const scriptPath = options.scriptPath ?? getAgentScriptPath();
37
+ if (!existsSync(pythonPath) || !existsSync(scriptPath))
38
+ return { kind: "not-installed" };
39
+ const spawn = options.spawn ?? nodeSpawnSync;
40
+ const result = spawn(pythonPath, [scriptPath, "--preflight"], {
41
+ encoding: "utf-8",
42
+ timeout: PREFLIGHT_TIMEOUT_MS,
43
+ env: { ...process.env, AXIMO_HOME: getAximoDir() },
44
+ });
45
+ if (result.status !== 0) {
46
+ // An agent script older than --preflight exits on the missing GATEWAY_URL.
47
+ const stderr = firstLine(result.stderr ?? "");
48
+ if (stderr.includes("GATEWAY_URL"))
49
+ return { kind: "not-installed" };
50
+ return { kind: "error", detail: stderr || `exited with status ${result.status}` };
51
+ }
52
+ const report = parseReport(result.stdout ?? "");
53
+ if (!report)
54
+ return { kind: "error", detail: "the agent did not return a readable report" };
55
+ return { kind: "ok", report };
56
+ }
57
+ const STATUS_MARK = {
58
+ pass: "✔",
59
+ warn: "!",
60
+ fail: "✘",
61
+ info: "·",
62
+ };
63
+ export function formatPreflightLines(report) {
64
+ const lines = report.checks.map((check) => {
65
+ const hint = check.hint ? `\n → ${check.hint}` : "";
66
+ return ` ${STATUS_MARK[check.status]} ${check.name}: ${check.message}${hint}`;
67
+ });
68
+ return ["Desktop environment:", ...lines];
69
+ }
70
+ export function hasBlockingChecks(report) {
71
+ return report.checks.some((check) => check.status === "warn" || check.status === "fail");
72
+ }
73
+ /** Ask whether to continue past a failed check, but only when there is one. */
74
+ export async function confirmProceed(report, options = {}) {
75
+ if (!hasBlockingChecks(report))
76
+ return true;
77
+ return confirmYesNo("Proceed anyway?", options);
78
+ }
@@ -0,0 +1 @@
1
+ export {};