@autifyhq/aximo-cli 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +19 -7
  2. package/dist/client.d.ts +7 -1
  3. package/dist/client.js +21 -2
  4. package/dist/client.test.js +36 -0
  5. package/dist/commands/case/case.test.js +62 -0
  6. package/dist/commands/case/create.d.ts +1 -1
  7. package/dist/commands/case/create.js +4 -4
  8. package/dist/commands/case/index.js +1 -0
  9. package/dist/commands/case/update.d.ts +50 -0
  10. package/dist/commands/case/update.js +78 -0
  11. package/dist/commands/device/connect.js +19 -1
  12. package/dist/commands/device/doctor.d.ts +2 -0
  13. package/dist/commands/device/doctor.js +37 -10
  14. package/dist/commands/device/doctor.test.js +26 -1
  15. package/dist/commands/health/check.js +15 -3
  16. package/dist/commands/health/check.test.js +27 -3
  17. package/dist/commands/mcp/server.test.js +3 -1
  18. package/dist/commands/mcp/tools.js +28 -0
  19. package/dist/commands/mcp/tools.test.js +90 -0
  20. package/dist/commands/mobile/connect-core.d.ts +5 -3
  21. package/dist/commands/mobile/connect-core.js +60 -12
  22. package/dist/commands/mobile/connect.d.ts +5 -0
  23. package/dist/commands/mobile/connect.js +6 -1
  24. package/dist/commands/mobile/signing-consent.d.ts +18 -0
  25. package/dist/commands/mobile/signing-consent.js +22 -0
  26. package/dist/commands/mobile/signing-consent.test.d.ts +1 -0
  27. package/dist/commands/mobile/signing-consent.test.js +50 -0
  28. package/dist/commands/plan/index.js +1 -0
  29. package/dist/commands/plan/plan.test.js +61 -0
  30. package/dist/commands/plan/update.d.ts +42 -0
  31. package/dist/commands/plan/update.js +78 -0
  32. package/dist/commands/schema.js +1 -1
  33. package/dist/commands/session/save-as-case.js +3 -3
  34. package/dist/commands/trigger/plan/run.d.ts +6 -1
  35. package/dist/commands/trigger/plan/run.js +9 -3
  36. package/dist/commands/trigger/trigger.test.js +22 -3
  37. package/dist/commands/upload/put.d.ts +5 -0
  38. package/dist/commands/upload/put.js +44 -11
  39. package/dist/commands/upload/upload.test.js +82 -0
  40. package/dist/desktop-server/preflight.d.ts +43 -0
  41. package/dist/desktop-server/preflight.js +78 -0
  42. package/dist/desktop-server/preflight.test.d.ts +1 -0
  43. package/dist/desktop-server/preflight.test.js +115 -0
  44. package/dist/mobile-control/appium-controller.d.ts +28 -0
  45. package/dist/mobile-control/appium-controller.js +271 -92
  46. package/dist/mobile-control/appium-controller.test.js +258 -8
  47. package/dist/mobile-control/bundle-id-map.js +3 -0
  48. package/dist/mobile-control/controller.d.ts +3 -0
  49. package/dist/mobile-control/frame-sender.d.ts +34 -0
  50. package/dist/mobile-control/frame-sender.js +96 -0
  51. package/dist/mobile-control/frame-sender.test.d.ts +1 -0
  52. package/dist/mobile-control/frame-sender.test.js +108 -0
  53. package/dist/mobile-control/mjpeg-stream.d.ts +43 -0
  54. package/dist/mobile-control/mjpeg-stream.js +160 -0
  55. package/dist/mobile-control/mjpeg-stream.test.d.ts +1 -0
  56. package/dist/mobile-control/mjpeg-stream.test.js +93 -0
  57. package/dist/mobile-control/tunnel.d.ts +10 -1
  58. package/dist/mobile-control/tunnel.js +43 -2
  59. package/dist/mobile-control/tunnel.test.js +108 -0
  60. package/dist/mobile-control/webdriver.d.ts +9 -1
  61. package/dist/mobile-control/webdriver.js +17 -5
  62. package/dist/mutate.d.ts +1 -1
  63. package/dist/mutate.js +4 -2
  64. package/dist/prompt.d.ts +11 -0
  65. package/dist/prompt.js +19 -0
  66. package/dist/types.d.ts +66 -4
  67. package/dist/validate.d.ts +6 -1
  68. package/dist/validate.js +46 -0
  69. package/dist/validate.test.js +39 -1
  70. package/package.json +1 -1
@@ -0,0 +1,78 @@
1
+ import { defineCommand } from "citty";
2
+ import { request } from "../../client.js";
3
+ import { handleApiError } from "../../errors.js";
4
+ import { dryRunEnvelope, resolveJsonOverride } from "../../mutate.js";
5
+ import { formatSuccess, printResult, validationError } from "../../output.js";
6
+ import { optionalString, parseExecutionMode, parseOptionalBoolean, requireId, } from "../../validate.js";
7
+ export async function runPlanUpdate(options, deps) {
8
+ let planId;
9
+ let body;
10
+ const override = resolveJsonOverride(options.json);
11
+ if (override && "error" in override)
12
+ return override.error;
13
+ try {
14
+ planId = requireId("planId", options.planId);
15
+ }
16
+ catch (err) {
17
+ return validationError(err);
18
+ }
19
+ if (override) {
20
+ body = override.body;
21
+ }
22
+ else {
23
+ try {
24
+ body = {
25
+ title: optionalString("title", options.title),
26
+ description: optionalString("description", options.description),
27
+ executionMode: parseExecutionMode(options.executionMode),
28
+ stopOnFailure: parseOptionalBoolean("stopOnFailure", options.stopOnFailure),
29
+ };
30
+ }
31
+ catch (err) {
32
+ return validationError(err);
33
+ }
34
+ if (Object.values(body).every((v) => v === undefined)) {
35
+ return validationError(new Error("at least one field to update is required"));
36
+ }
37
+ }
38
+ const path = `/api/plans/${encodeURIComponent(planId)}`;
39
+ if (options.dryRun) {
40
+ return dryRunEnvelope("PATCH", path, body);
41
+ }
42
+ try {
43
+ const res = await request(path, { method: "PATCH", body }, deps ?? {});
44
+ return formatSuccess(res);
45
+ }
46
+ catch (err) {
47
+ return handleApiError(err);
48
+ }
49
+ }
50
+ export default defineCommand({
51
+ meta: { name: "update", description: "Update a test plan" },
52
+ args: {
53
+ planId: { type: "positional", required: true },
54
+ title: { type: "string" },
55
+ description: { type: "string" },
56
+ "execution-mode": {
57
+ type: "string",
58
+ description: "parallel or sequential",
59
+ },
60
+ "stop-on-failure": {
61
+ type: "string",
62
+ description: "true or false; sequential runs only",
63
+ },
64
+ json: { type: "string", description: "Raw JSON request body (replaces individual flags)" },
65
+ "dry-run": { type: "boolean", description: "Validate and show request without calling API" },
66
+ },
67
+ async run({ args }) {
68
+ printResult(await runPlanUpdate({
69
+ planId: args.planId,
70
+ title: args.title,
71
+ description: args.description,
72
+ executionMode: args["execution-mode"],
73
+ stopOnFailure: args["stop-on-failure"],
74
+ json: args.json,
75
+ dryRun: args["dry-run"],
76
+ }));
77
+ },
78
+ });
@@ -19,7 +19,7 @@ export function getSchema() {
19
19
  name: "aximo",
20
20
  version: pkg.version,
21
21
  commands: [
22
- cmd("health", "Health commands", [], [cmd("check", "GET /api/health")]),
22
+ cmd("health", "Health commands", [], [cmd("check", "GET /api/health + authenticated API-key probe")]),
23
23
  cmd("session", "Session commands", [], [
24
24
  cmd("create", "POST /api/sessions", [
25
25
  arg("title", "string", "Session title"),
@@ -3,7 +3,7 @@ import { request } from "../../client.js";
3
3
  import { handleApiError } from "../../errors.js";
4
4
  import { dryRunEnvelope, resolveJsonOverride } from "../../mutate.js";
5
5
  import { formatSuccess, printResult, validationError } from "../../output.js";
6
- import { optionalString, requireId } from "../../validate.js";
6
+ import { optionalString, requireId, parseCaseFormat, parseSaveAsCaseType } from "../../validate.js";
7
7
  export async function runSessionSaveAsCase(options, deps) {
8
8
  let sessionId;
9
9
  let body;
@@ -23,9 +23,9 @@ export async function runSessionSaveAsCase(options, deps) {
23
23
  try {
24
24
  body = {
25
25
  title: optionalString("title", options.title),
26
- type: optionalString("type", options.type),
26
+ type: parseSaveAsCaseType(options.type),
27
27
  scenario: optionalString("scenario", options.scenario),
28
- format: optionalString("format", options.format),
28
+ format: parseCaseFormat(options.format),
29
29
  };
30
30
  }
31
31
  catch (err) {
@@ -1,10 +1,11 @@
1
- import type { Envelope, PlanRunResult } from "../../../types.js";
2
1
  import { type ClientDeps } from "../../../client.js";
3
2
  import { type DryRunResult } from "../../../mutate.js";
3
+ import type { Envelope, PlanRunResult } from "../../../types.js";
4
4
  export interface TriggerPlanRunOptions {
5
5
  planId?: unknown;
6
6
  model?: unknown;
7
7
  mobileConfig?: unknown;
8
+ executionMode?: unknown;
8
9
  json?: unknown;
9
10
  dryRun?: unknown;
10
11
  }
@@ -21,6 +22,10 @@ declare const _default: import("citty").CommandDef<{
21
22
  readonly type: "string";
22
23
  readonly description: "JSON object string";
23
24
  };
25
+ readonly "execution-mode": {
26
+ readonly type: "string";
27
+ readonly description: "parallel or sequential (defaults to the plan's stored setting)";
28
+ };
24
29
  readonly json: {
25
30
  readonly type: "string";
26
31
  readonly description: "Raw JSON request body";
@@ -1,9 +1,9 @@
1
1
  import { defineCommand } from "citty";
2
- import { formatSuccess, printResult, validationError } from "../../../output.js";
3
- import { handleApiError } from "../../../errors.js";
4
2
  import { request } from "../../../client.js";
5
- import { requireId, optionalString, parseJsonObject } from "../../../validate.js";
3
+ import { handleApiError } from "../../../errors.js";
6
4
  import { dryRunEnvelope, resolveJsonOverride } from "../../../mutate.js";
5
+ import { formatSuccess, printResult, validationError } from "../../../output.js";
6
+ import { optionalString, parseExecutionMode, parseJsonObject, requireId, } from "../../../validate.js";
7
7
  export async function runTriggerPlanRun(options, deps) {
8
8
  let planId;
9
9
  let body;
@@ -24,6 +24,7 @@ export async function runTriggerPlanRun(options, deps) {
24
24
  body = {
25
25
  model: optionalString("model", options.model),
26
26
  mobileConfig: parseJsonObject("mobileConfig", options.mobileConfig),
27
+ executionMode: parseExecutionMode(options.executionMode),
27
28
  };
28
29
  }
29
30
  catch (err) {
@@ -48,6 +49,10 @@ export default defineCommand({
48
49
  planId: { type: "positional", required: true },
49
50
  model: { type: "string" },
50
51
  "mobile-config": { type: "string", description: "JSON object string" },
52
+ "execution-mode": {
53
+ type: "string",
54
+ description: "parallel or sequential (defaults to the plan's stored setting)",
55
+ },
51
56
  json: { type: "string", description: "Raw JSON request body" },
52
57
  "dry-run": { type: "boolean", description: "Validate and show request without calling API" },
53
58
  },
@@ -56,6 +61,7 @@ export default defineCommand({
56
61
  planId: args.planId,
57
62
  model: args.model,
58
63
  mobileConfig: args["mobile-config"],
64
+ executionMode: args["execution-mode"],
59
65
  json: args.json,
60
66
  dryRun: args["dry-run"],
61
67
  }));
@@ -1,9 +1,9 @@
1
1
  import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
2
- import { server, http, HttpResponse, TEST_BASE_URL } from "../../test-utils.js";
3
- import { runTriggerCaseRun } from "./case/run.js";
2
+ import { HttpResponse, http, server, TEST_BASE_URL } from "../../test-utils.js";
4
3
  import { runTriggerCaseGet } from "./case/get.js";
5
- import { runTriggerPlanRun } from "./plan/run.js";
4
+ import { runTriggerCaseRun } from "./case/run.js";
6
5
  import { runTriggerPlanGet } from "./plan/get.js";
6
+ import { runTriggerPlanRun } from "./plan/run.js";
7
7
  beforeAll(() => {
8
8
  process.env["AXIMO_API_KEY"] = "test-key";
9
9
  process.env["AXIMO_BASE_URL"] = TEST_BASE_URL;
@@ -90,6 +90,25 @@ describe("trigger plan", () => {
90
90
  if (!result.ok)
91
91
  expect(result.error.code).toBe("validation_error");
92
92
  });
93
+ it("sends executionMode in the body and surfaces the echoed effective mode", async () => {
94
+ let seen;
95
+ server.use(http.post(`${TEST_BASE_URL}/api/triggers/plans/p1/run`, async ({ request: req }) => {
96
+ seen = await req.json();
97
+ return HttpResponse.json({ success: true, planRunId: "pr1", totalCases: 2, executionMode: "sequential" }, { status: 201 });
98
+ }));
99
+ const result = await runTriggerPlanRun({ planId: "p1", executionMode: "sequential" });
100
+ expect(result.ok).toBe(true);
101
+ if (result.ok && !("dryRun" in result.data)) {
102
+ expect(result.data.executionMode).toBe("sequential");
103
+ }
104
+ expect(seen).toMatchObject({ executionMode: "sequential" });
105
+ });
106
+ it("rejects an invalid executionMode", async () => {
107
+ const result = await runTriggerPlanRun({ planId: "p1", executionMode: "fastest" });
108
+ expect(result.ok).toBe(false);
109
+ if (!result.ok)
110
+ expect(result.error.code).toBe("validation_error");
111
+ });
93
112
  it("gets plan run", async () => {
94
113
  server.use(http.get(`${TEST_BASE_URL}/api/triggers/plans/p1/runs/pr1`, () => HttpResponse.json({ id: "pr1", status: "running" })));
95
114
  const result = await runTriggerPlanGet("p1", "pr1");
@@ -4,6 +4,7 @@ import { type DryRunResult } from "../../mutate.js";
4
4
  export interface UploadPutOptions {
5
5
  uploadId?: unknown;
6
6
  bytes?: Uint8Array | undefined;
7
+ presignedUrl?: unknown;
7
8
  dryRun?: unknown;
8
9
  }
9
10
  export declare function runUploadPut(options: UploadPutOptions, deps?: ClientDeps): Promise<Envelope<UploadPutResult | DryRunResult>>;
@@ -16,6 +17,10 @@ declare const _default: import("citty").CommandDef<{
16
17
  readonly type: "string";
17
18
  readonly description: "Local path to the file";
18
19
  };
20
+ readonly url: {
21
+ readonly type: "string";
22
+ readonly description: "presignedUrl from `upload create` output (required for S3-backed deployments)";
23
+ };
19
24
  readonly "dry-run": {
20
25
  readonly type: "boolean";
21
26
  readonly description: "Validate and show request without calling API";
@@ -2,34 +2,63 @@ import { readFile } from "node:fs/promises";
2
2
  import { defineCommand } from "citty";
3
3
  import { formatSuccess, formatError, printResult, validationError } from "../../output.js";
4
4
  import { handleApiError } from "../../errors.js";
5
- import { request } from "../../client.js";
6
- import { requireId } from "../../validate.js";
5
+ import { request, putToPresignedUrl } from "../../client.js";
6
+ import { requireId, optionalString } from "../../validate.js";
7
7
  import { dryRunEnvelope } from "../../mutate.js";
8
+ const PRESIGNED_URL_HINT = "This deployment stores uploads in S3, so the API does not accept file bytes directly. " +
9
+ 'Re-run with --url "<presignedUrl>" using the presignedUrl returned by `aximo upload create`.';
10
+ function isAbsoluteUrl(value) {
11
+ return value.startsWith("http://") || value.startsWith("https://");
12
+ }
13
+ function isMethodNotAllowed(err) {
14
+ return typeof err === "object" && err !== null && err.status === 405;
15
+ }
8
16
  export async function runUploadPut(options, deps) {
9
17
  let uploadId;
18
+ let urlOption;
10
19
  try {
11
20
  uploadId = requireId("uploadId", options.uploadId);
12
- if (!options.dryRun && !options.bytes)
13
- throw new Error("file bytes are required");
21
+ urlOption = optionalString("url", options.presignedUrl);
14
22
  }
15
23
  catch (err) {
16
24
  return validationError(err);
17
25
  }
18
- const path = `/api/uploads/${encodeURIComponent(uploadId)}`;
26
+ // The presignedUrl from `upload create` is either an absolute S3 URL
27
+ // (cloud deployments) or a relative API path (local storage).
28
+ const presignedUrl = urlOption;
29
+ const useDirectPut = presignedUrl !== undefined && isAbsoluteUrl(presignedUrl);
30
+ const target = presignedUrl ?? `/api/uploads/${encodeURIComponent(uploadId)}`;
19
31
  if (options.dryRun) {
20
- return dryRunEnvelope("PUT", path);
32
+ return dryRunEnvelope("PUT", target);
33
+ }
34
+ if (!options.bytes) {
35
+ return validationError(new Error("file bytes are required"));
36
+ }
37
+ const body = new Blob([new Uint8Array(options.bytes)]);
38
+ if (useDirectPut) {
39
+ try {
40
+ await putToPresignedUrl(target, body, "application/octet-stream", deps ?? {});
41
+ return formatSuccess({ success: true });
42
+ }
43
+ catch (err) {
44
+ return handleApiError(err);
45
+ }
21
46
  }
22
47
  try {
23
- await request(path, {
48
+ await request(target, {
24
49
  method: "PUT",
25
- rawBody: options.bytes ? new Blob([new Uint8Array(options.bytes)]) : undefined,
50
+ rawBody: body,
26
51
  contentType: "application/octet-stream",
27
52
  expectJson: false,
28
53
  }, deps ?? {});
29
54
  return formatSuccess({ success: true });
30
55
  }
31
56
  catch (err) {
32
- return handleApiError(err);
57
+ const envelope = handleApiError(err);
58
+ if (isMethodNotAllowed(err)) {
59
+ envelope.error.hint = PRESIGNED_URL_HINT;
60
+ }
61
+ return envelope;
33
62
  }
34
63
  }
35
64
  export default defineCommand({
@@ -37,12 +66,16 @@ export default defineCommand({
37
66
  args: {
38
67
  uploadId: { type: "positional", required: true },
39
68
  file: { type: "string", description: "Local path to the file" },
69
+ url: {
70
+ type: "string",
71
+ description: "presignedUrl from `upload create` output (required for S3-backed deployments)",
72
+ },
40
73
  "dry-run": { type: "boolean", description: "Validate and show request without calling API" },
41
74
  },
42
75
  async run({ args }) {
43
76
  const dryRun = args["dry-run"];
44
77
  if (dryRun) {
45
- printResult(await runUploadPut({ uploadId: args.uploadId, dryRun: true }));
78
+ printResult(await runUploadPut({ uploadId: args.uploadId, presignedUrl: args.url, dryRun: true }));
46
79
  return;
47
80
  }
48
81
  if (!args.file) {
@@ -57,6 +90,6 @@ export default defineCommand({
57
90
  printResult(formatError("validation_error", `Cannot read file: ${args.file}`, false, err instanceof Error ? err.message : undefined));
58
91
  return;
59
92
  }
60
- printResult(await runUploadPut({ uploadId: args.uploadId, bytes }));
93
+ printResult(await runUploadPut({ uploadId: args.uploadId, presignedUrl: args.url, bytes }));
61
94
  },
62
95
  });
@@ -98,6 +98,75 @@ describe("runUploadPut", () => {
98
98
  expect(body).not.toBeNull();
99
99
  expect(new Uint8Array(body)).toEqual(new Uint8Array([9, 9]));
100
100
  });
101
+ it("PUTs directly to an absolute presigned URL without auth headers", async () => {
102
+ let seenCT = "";
103
+ let seenAuth = "unset";
104
+ let seenApiKey = "unset";
105
+ let body = null;
106
+ server.use(http.put("https://bucket.s3.test/uploads/u1", async ({ request: req }) => {
107
+ seenCT = req.headers.get("content-type") ?? "";
108
+ seenAuth = req.headers.get("authorization");
109
+ seenApiKey = req.headers.get("x-api-key");
110
+ body = await req.arrayBuffer();
111
+ return new HttpResponse(null, { status: 200 });
112
+ }));
113
+ const result = await runUploadPut({
114
+ uploadId: "u1",
115
+ bytes: new Uint8Array([7, 7]),
116
+ presignedUrl: "https://bucket.s3.test/uploads/u1?X-Amz-Signature=abc",
117
+ });
118
+ expect(result.ok).toBe(true);
119
+ if (result.ok && !("dryRun" in result.data))
120
+ expect(result.data.success).toBe(true);
121
+ expect(seenCT).toContain("application/octet-stream");
122
+ expect(seenAuth).toBeNull();
123
+ expect(seenApiKey).toBeNull();
124
+ expect(new Uint8Array(body)).toEqual(new Uint8Array([7, 7]));
125
+ });
126
+ it("maps a presigned URL failure to an error envelope", async () => {
127
+ server.use(http.put("https://bucket.s3.test/uploads/u2", () => HttpResponse.text("SignatureDoesNotMatch", { status: 403 })));
128
+ const result = await runUploadPut({
129
+ uploadId: "u2",
130
+ bytes: new Uint8Array([1]),
131
+ presignedUrl: "https://bucket.s3.test/uploads/u2",
132
+ });
133
+ expect(result.ok).toBe(false);
134
+ if (!result.ok)
135
+ expect(result.error.code).toBe("unauthorized");
136
+ });
137
+ it("PUTs via the authenticated API when --url is relative", async () => {
138
+ let seenApiKey = null;
139
+ server.use(http.put(`${TEST_BASE_URL}/api/uploads/u1`, ({ request: req }) => {
140
+ seenApiKey = req.headers.get("x-api-key");
141
+ return HttpResponse.json({ success: true });
142
+ }));
143
+ const result = await runUploadPut({
144
+ uploadId: "u1",
145
+ bytes: new Uint8Array([1]),
146
+ presignedUrl: "/api/uploads/u1",
147
+ });
148
+ expect(result.ok).toBe(true);
149
+ expect(seenApiKey).toBe("test-key");
150
+ });
151
+ it("hints at --url when the API PUT returns 405", async () => {
152
+ server.use(http.put(`${TEST_BASE_URL}/api/uploads/u1`, () => HttpResponse.json({ error: "Uploads use presigned URLs; PUT the file to the presigned URL instead" }, { status: 405 })));
153
+ const result = await runUploadPut({ uploadId: "u1", bytes: new Uint8Array([1]) });
154
+ expect(result.ok).toBe(false);
155
+ if (!result.ok) {
156
+ expect(result.error.hint).toContain("--url");
157
+ expect(result.error.hint).toContain("aximo upload create");
158
+ }
159
+ });
160
+ it("rejects a non-string --url", async () => {
161
+ const result = await runUploadPut({
162
+ uploadId: "u1",
163
+ bytes: new Uint8Array([1]),
164
+ presignedUrl: 42,
165
+ });
166
+ expect(result.ok).toBe(false);
167
+ if (!result.ok)
168
+ expect(result.error.code).toBe("validation_error");
169
+ });
101
170
  it("rejects missing bytes", async () => {
102
171
  const result = await runUploadPut({ uploadId: "u1" });
103
172
  expect(result.ok).toBe(false);
@@ -107,6 +176,19 @@ describe("runUploadPut", () => {
107
176
  expect(result.ok).toBe(true);
108
177
  if (result.ok && "dryRun" in result.data) {
109
178
  expect(result.data.method).toBe("PUT");
179
+ expect(result.data.url).toBe(`${TEST_BASE_URL}/api/uploads/u1`);
180
+ }
181
+ });
182
+ it("--dry-run reflects the presigned URL target", async () => {
183
+ const result = await runUploadPut({
184
+ uploadId: "u1",
185
+ presignedUrl: "https://bucket.s3.test/uploads/u1?X-Amz-Signature=abc",
186
+ dryRun: true,
187
+ });
188
+ expect(result.ok).toBe(true);
189
+ if (result.ok && "dryRun" in result.data) {
190
+ expect(result.data.method).toBe("PUT");
191
+ expect(result.data.url).toBe("https://bucket.s3.test/uploads/u1?X-Amz-Signature=abc");
110
192
  }
111
193
  });
112
194
  it("rejects uploadId with forbidden chars", async () => {
@@ -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 {};
@@ -0,0 +1,115 @@
1
+ import { mkdtempSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { beforeAll, describe, expect, it, vi } from "vitest";
5
+ import { confirmProceed, formatPreflightLines, hasBlockingChecks, runLocalPreflight, } from "./preflight.js";
6
+ const REPORT = {
7
+ agent: { platform: "Darwin" },
8
+ checks: [
9
+ {
10
+ name: "capture target",
11
+ status: "info",
12
+ message: "capturing the primary monitor (2560x1440)",
13
+ },
14
+ {
15
+ name: "accessibility permission",
16
+ status: "fail",
17
+ message: "not granted",
18
+ hint: "System Settings → Privacy & Security → Accessibility",
19
+ },
20
+ ],
21
+ };
22
+ const HEALTHY = {
23
+ checks: [{ name: "input source", status: "pass", message: "com.apple.keylayout.ABC" }],
24
+ };
25
+ let pythonPath;
26
+ let scriptPath;
27
+ beforeAll(() => {
28
+ const dir = mkdtempSync(join(tmpdir(), "aximo-preflight-"));
29
+ pythonPath = join(dir, "python3");
30
+ scriptPath = join(dir, "remote.py");
31
+ writeFileSync(pythonPath, "");
32
+ writeFileSync(scriptPath, "");
33
+ });
34
+ function spawnReturning(overrides) {
35
+ return vi.fn(() => ({
36
+ status: 0,
37
+ signal: null,
38
+ pid: 1,
39
+ output: [],
40
+ stdout: "",
41
+ stderr: "",
42
+ ...overrides,
43
+ }));
44
+ }
45
+ describe("runLocalPreflight", () => {
46
+ it("returns the report the agent printed", () => {
47
+ const spawn = spawnReturning({ stdout: JSON.stringify(REPORT) });
48
+ expect(runLocalPreflight({ pythonPath, scriptPath, spawn })).toEqual({
49
+ kind: "ok",
50
+ report: REPORT,
51
+ });
52
+ expect(spawn).toHaveBeenCalledWith(pythonPath, [scriptPath, "--preflight"], expect.objectContaining({ encoding: "utf-8" }));
53
+ });
54
+ // A server still serving an agent script without --preflight exits on the
55
+ // missing GATEWAY_URL rather than printing a report.
56
+ it("reports not-installed when the agent script predates --preflight", () => {
57
+ const spawn = spawnReturning({ status: 1, stderr: "GATEWAY_URL is required" });
58
+ expect(runLocalPreflight({ pythonPath, scriptPath, spawn })).toEqual({
59
+ kind: "not-installed",
60
+ });
61
+ });
62
+ // A crash is not the same as "not set up yet", and doctor must not conflate them.
63
+ it("reports the failure when the agent runs but cannot answer", () => {
64
+ expect(runLocalPreflight({
65
+ pythonPath,
66
+ scriptPath,
67
+ spawn: spawnReturning({ status: 1, stderr: "ImportError: no module named mss" }),
68
+ })).toEqual({ kind: "error", detail: "ImportError: no module named mss" });
69
+ });
70
+ it("reports an error when stdout is not a report", () => {
71
+ for (const stdout of ["boom", "[]", JSON.stringify({ checks: [{ name: "x" }] })]) {
72
+ expect(runLocalPreflight({ pythonPath, scriptPath, spawn: spawnReturning({ stdout }) })).toEqual({ kind: "error", detail: expect.stringContaining("readable report") });
73
+ }
74
+ });
75
+ it("reports not-installed without spawning when python is missing", () => {
76
+ const spawn = spawnReturning({ stdout: JSON.stringify(REPORT) });
77
+ expect(runLocalPreflight({ pythonPath: join(tmpdir(), "no-such-python"), scriptPath, spawn })).toEqual({ kind: "not-installed" });
78
+ expect(spawn).not.toHaveBeenCalled();
79
+ });
80
+ });
81
+ describe("formatPreflightLines", () => {
82
+ it("marks each check and indents its hint", () => {
83
+ const lines = formatPreflightLines(REPORT);
84
+ expect(lines[0]).toBe("Desktop environment:");
85
+ expect(lines[1]).toContain("· capture target: capturing the primary monitor");
86
+ expect(lines[2]).toContain("✘ accessibility permission: not granted");
87
+ expect(lines[2]).toContain("→ System Settings");
88
+ });
89
+ });
90
+ describe("hasBlockingChecks", () => {
91
+ it("is true only when something warns or fails", () => {
92
+ expect(hasBlockingChecks(REPORT)).toBe(true);
93
+ expect(hasBlockingChecks(HEALTHY)).toBe(false);
94
+ });
95
+ });
96
+ describe("confirmProceed", () => {
97
+ it("does not ask when every check is fine", async () => {
98
+ const ask = vi.fn();
99
+ expect(await confirmProceed(HEALTHY, { interactive: true, ask })).toBe(true);
100
+ expect(ask).not.toHaveBeenCalled();
101
+ });
102
+ it("proceeds on an empty answer", async () => {
103
+ expect(await confirmProceed(REPORT, { interactive: true, ask: async () => "" })).toBe(true);
104
+ });
105
+ it("stops on a declined prompt", async () => {
106
+ expect(await confirmProceed(REPORT, { interactive: true, ask: async () => "n" })).toBe(false);
107
+ expect(await confirmProceed(REPORT, { interactive: true, ask: async () => "No" })).toBe(false);
108
+ });
109
+ // Headless/CI: nobody can answer, so a prompt would read as a hang.
110
+ it("never asks without a terminal", async () => {
111
+ const ask = vi.fn();
112
+ expect(await confirmProceed(REPORT, { interactive: false, ask })).toBe(true);
113
+ expect(ask).not.toHaveBeenCalled();
114
+ });
115
+ });