@autifyhq/aximo-cli 0.12.0 → 0.14.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.
@@ -1,4 +1,6 @@
1
1
  import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
2
+ import { z } from "zod";
3
+ import { ARCHIVE_FIRST_HINT, handleApiError, isArchiveFirstConflict } from "../../errors.js";
2
4
  import { HttpResponse, http, server, TEST_BASE_URL } from "../../test-utils.js";
3
5
  import { defineTools } from "./tools.js";
4
6
  beforeAll(() => {
@@ -22,6 +24,9 @@ describe("defineTools", () => {
22
24
  const names = defineTools().map((t) => t.name);
23
25
  expect(names).toEqual([
24
26
  "aximo_list_projects",
27
+ "aximo_list_devices",
28
+ "aximo_list_user_devices",
29
+ "aximo_list_uploads",
25
30
  "aximo_list_sessions",
26
31
  "aximo_get_session",
27
32
  "aximo_get_session_messages",
@@ -33,13 +38,20 @@ describe("defineTools", () => {
33
38
  "aximo_get_case",
34
39
  "aximo_create_case",
35
40
  "aximo_update_case",
41
+ "aximo_archive_case",
42
+ "aximo_restore_case",
36
43
  "aximo_delete_case",
44
+ "aximo_run_case",
37
45
  "aximo_create_plan",
38
46
  "aximo_add_case_to_plan",
39
47
  "aximo_remove_case_from_plan",
40
48
  "aximo_list_plans",
41
49
  "aximo_get_plan",
42
50
  "aximo_update_plan",
51
+ "aximo_archive_plan",
52
+ "aximo_restore_plan",
53
+ "aximo_delete_plan",
54
+ "aximo_run_plan",
43
55
  "aximo_list_plan_runs",
44
56
  "aximo_get_plan_run",
45
57
  "aximo_save_session_as_case",
@@ -178,6 +190,9 @@ describe("aximo_update_case", () => {
178
190
  expect(result.isError).toBeUndefined();
179
191
  expect(parsePayload(result)).toEqual(payload);
180
192
  expect(body).toEqual({ title: "Renamed", scenario: "Given a renamed page" });
193
+ // GEN-2844 regression guard: an omitted description must not be sent at
194
+ // all, or every partial update would clear the field server-side.
195
+ expect(body).not.toHaveProperty("description");
181
196
  });
182
197
  it("rejects an update with no fields instead of issuing an empty PATCH", async () => {
183
198
  let called = false;
@@ -202,6 +217,104 @@ describe("aximo_update_case", () => {
202
217
  expect(result.isError).toBeUndefined();
203
218
  expect(body).toEqual({ startUrl: null });
204
219
  });
220
+ it("passes description: null through as an explicit clear (GEN-2844)", async () => {
221
+ let body = null;
222
+ server.use(http.patch(`${TEST_BASE_URL}/api/cases/${caseId}`, async ({ request: req }) => {
223
+ body = await req.json();
224
+ return HttpResponse.json({ success: true });
225
+ }));
226
+ const tool = byName(defineTools(), "aximo_update_case");
227
+ const result = await tool.handler({ caseId, description: null });
228
+ expect(result.isError).toBeUndefined();
229
+ expect(body).toEqual({ description: null });
230
+ });
231
+ // The tests above call handler() directly, which bypasses Zod entirely --
232
+ // the MCP SDK is what parses in production. These assertions are what
233
+ // actually pin the clearable fields to their schema.
234
+ it("accepts null for the clearable fields at schema time, and drops omitted keys", () => {
235
+ const tool = byName(defineTools(), "aximo_update_case");
236
+ // biome-ignore lint/suspicious/noExplicitAny: zod runtime check
237
+ const schemas = tool.inputSchema;
238
+ expect(schemas.description.safeParse(null).success).toBe(true);
239
+ expect(schemas.description.safeParse("").success).toBe(true);
240
+ expect(schemas.description.safeParse(5).success).toBe(false);
241
+ expect(schemas.startUrl.safeParse(null).success).toBe(true);
242
+ expect(schemas.startUrl.safeParse("not-a-url").success).toBe(false);
243
+ // The object schema must leave omitted keys absent rather than defaulting
244
+ // them to null, which would clear on every partial update.
245
+ const parsed = z.object(schemas).parse({ caseId, title: "Renamed" });
246
+ expect(parsed).not.toHaveProperty("description");
247
+ expect(parsed).not.toHaveProperty("startUrl");
248
+ });
249
+ });
250
+ describe("aximo_run_case", () => {
251
+ const caseId = "77777777-7777-4777-9777-777777777777";
252
+ it("POSTs /api/triggers/cases/:id/run with the per-run overrides and returns sessionId", async () => {
253
+ let body = null;
254
+ let apiKey = null;
255
+ const payload = { success: true, sessionId: "88888888-8888-4888-9888-888888888888" };
256
+ server.use(http.post(`${TEST_BASE_URL}/api/triggers/cases/${caseId}/run`, async ({ request: req }) => {
257
+ apiKey = req.headers.get("x-api-key");
258
+ body = await req.json();
259
+ return HttpResponse.json(payload, { status: 201 });
260
+ }));
261
+ const tool = byName(defineTools(), "aximo_run_case");
262
+ const result = await tool.handler({
263
+ caseId,
264
+ model: "sonnet",
265
+ hostnameOverrideIds: ["99999999-9999-4999-9999-999999999999"],
266
+ apps: [{ uploadArn: "tok-1", uploadType: "own_app", appName: null }],
267
+ });
268
+ expect(result.isError).toBeUndefined();
269
+ expect(parsePayload(result)).toEqual(payload);
270
+ expect(apiKey).toBe("test-key");
271
+ expect(body).toEqual({
272
+ model: "sonnet",
273
+ hostnameOverrideIds: ["99999999-9999-4999-9999-999999999999"],
274
+ apps: [{ uploadArn: "tok-1", uploadType: "own_app", appName: null }],
275
+ });
276
+ });
277
+ it("sends an empty body when only caseId is given, so the saved config applies", async () => {
278
+ let body = null;
279
+ server.use(http.post(`${TEST_BASE_URL}/api/triggers/cases/${caseId}/run`, async ({ request: req }) => {
280
+ body = await req.json();
281
+ return HttpResponse.json({ success: true, sessionId: "s" }, { status: 201 });
282
+ }));
283
+ const tool = byName(defineTools(), "aximo_run_case");
284
+ const result = await tool.handler({ caseId });
285
+ expect(result.isError).toBeUndefined();
286
+ expect(body).toEqual({});
287
+ });
288
+ it("surfaces the apiEnabled rejection as the error message", async () => {
289
+ server.use(http.post(`${TEST_BASE_URL}/api/triggers/cases/${caseId}/run`, () => HttpResponse.json({ error: "API access is not enabled for this case" }, { status: 403 })));
290
+ const tool = byName(defineTools(), "aximo_run_case");
291
+ const result = await tool.handler({ caseId });
292
+ expect(result.isError).toBe(true);
293
+ expect(parsePayload(result).error).toBe("API access is not enabled for this case");
294
+ });
295
+ it("surfaces the insufficient-credits rejection distinctly", async () => {
296
+ server.use(http.post(`${TEST_BASE_URL}/api/triggers/cases/${caseId}/run`, () => HttpResponse.json({ error: "Insufficient credits to start session" }, { status: 403 })));
297
+ const tool = byName(defineTools(), "aximo_run_case");
298
+ const result = await tool.handler({ caseId });
299
+ expect(result.isError).toBe(true);
300
+ expect(parsePayload(result).error).toBe("Insufficient credits to start session");
301
+ });
302
+ it("maps a 429 to a rate-limit error an agent can back off on", async () => {
303
+ server.use(http.post(`${TEST_BASE_URL}/api/triggers/cases/${caseId}/run`, () => HttpResponse.json({ error: "Rate limit exceeded. Try again later." }, { status: 429, headers: { "Retry-After": "60" } })));
304
+ const tool = byName(defineTools(), "aximo_run_case");
305
+ const result = await tool.handler({ caseId });
306
+ expect(result.isError).toBe(true);
307
+ const payload = parsePayload(result);
308
+ expect(payload.error).toMatch(/Rate limited/);
309
+ expect(payload.hint).toBe("Back off and retry after 60 seconds.");
310
+ });
311
+ it("returns the API's 404 for a case outside the organization without leaking existence", async () => {
312
+ server.use(http.post(`${TEST_BASE_URL}/api/triggers/cases/${caseId}/run`, () => HttpResponse.json({ error: "Test case not found" }, { status: 404 })));
313
+ const tool = byName(defineTools(), "aximo_run_case");
314
+ const result = await tool.handler({ caseId });
315
+ expect(result.isError).toBe(true);
316
+ expect(parsePayload(result).error).toBe("Test case not found");
317
+ });
205
318
  });
206
319
  describe("aximo_list_plans", () => {
207
320
  const projectId = "44444444-4444-4444-8444-444444444444";
@@ -304,6 +417,63 @@ describe("aximo_update_plan", () => {
304
417
  expect(called).toBe(false);
305
418
  });
306
419
  });
420
+ describe("aximo_run_plan", () => {
421
+ const planId = "aaaaaaaa-aaaa-4aaa-9aaa-aaaaaaaaaaaa";
422
+ it("POSTs /api/triggers/plans/:id/run with the per-run overrides and returns planRunId", async () => {
423
+ let body = null;
424
+ const payload = {
425
+ success: true,
426
+ planRunId: "bbbbbbbb-bbbb-4bbb-9bbb-bbbbbbbbbbbb",
427
+ totalCases: 3,
428
+ executionMode: "sequential",
429
+ };
430
+ server.use(http.post(`${TEST_BASE_URL}/api/triggers/plans/${planId}/run`, async ({ request: req }) => {
431
+ body = await req.json();
432
+ return HttpResponse.json(payload, { status: 201 });
433
+ }));
434
+ const tool = byName(defineTools(), "aximo_run_plan");
435
+ const result = await tool.handler({
436
+ planId,
437
+ executionMode: "sequential",
438
+ mobileConfig: { deviceArn: "tok-device", deviceName: "Pixel 8", platform: "ANDROID" },
439
+ desktopConfig: { devices: [{ userDeviceId: "cccccccc-cccc-4ccc-9ccc-cccccccccccc" }] },
440
+ variables: [{ name: "BASE_URL", value: "https://staging.example.com" }],
441
+ });
442
+ expect(result.isError).toBeUndefined();
443
+ expect(parsePayload(result)).toEqual(payload);
444
+ expect(body).toEqual({
445
+ executionMode: "sequential",
446
+ mobileConfig: { deviceArn: "tok-device", deviceName: "Pixel 8", platform: "ANDROID" },
447
+ desktopConfig: { devices: [{ userDeviceId: "cccccccc-cccc-4ccc-9ccc-cccccccccccc" }] },
448
+ variables: [{ name: "BASE_URL", value: "https://staging.example.com" }],
449
+ });
450
+ });
451
+ it("sends an empty body when only planId is given, so the plan's stored settings apply", async () => {
452
+ let body = null;
453
+ server.use(http.post(`${TEST_BASE_URL}/api/triggers/plans/${planId}/run`, async ({ request: req }) => {
454
+ body = await req.json();
455
+ return HttpResponse.json({ success: true, planRunId: "r", totalCases: 1 }, { status: 201 });
456
+ }));
457
+ const tool = byName(defineTools(), "aximo_run_plan");
458
+ const result = await tool.handler({ planId });
459
+ expect(result.isError).toBeUndefined();
460
+ expect(body).toEqual({});
461
+ });
462
+ it("surfaces the apiEnabled rejection as the error message", async () => {
463
+ server.use(http.post(`${TEST_BASE_URL}/api/triggers/plans/${planId}/run`, () => HttpResponse.json({ error: "API access is not enabled for this plan" }, { status: 403 })));
464
+ const tool = byName(defineTools(), "aximo_run_plan");
465
+ const result = await tool.handler({ planId });
466
+ expect(result.isError).toBe(true);
467
+ expect(parsePayload(result).error).toBe("API access is not enabled for this plan");
468
+ });
469
+ it("returns the API's 404 for a plan outside the organization without leaking existence", async () => {
470
+ server.use(http.post(`${TEST_BASE_URL}/api/triggers/plans/${planId}/run`, () => HttpResponse.json({ error: "Test plan not found" }, { status: 404 })));
471
+ const tool = byName(defineTools(), "aximo_run_plan");
472
+ const result = await tool.handler({ planId });
473
+ expect(result.isError).toBe(true);
474
+ expect(parsePayload(result).error).toBe("Test plan not found");
475
+ });
476
+ });
307
477
  describe("aximo_list_plan_runs", () => {
308
478
  const planId = "77777777-7777-4777-9777-777777777777";
309
479
  it("calls /api/triggers/plans/:id/runs, passing limit and cursor through", async () => {
@@ -471,14 +641,23 @@ describe("call() error mapping", () => {
471
641
  const payload = parsePayload(result);
472
642
  expect(payload.error).toBe("Session was archived");
473
643
  });
474
- it("flags 429 with the rate-limit hint", async () => {
644
+ it("flags 429 with a back-off hint when no Retry-After header is present", async () => {
475
645
  server.use(http.get(`${TEST_BASE_URL}/api/sessions/${sessionId}/timeline`, () => HttpResponse.json({ error: "Too many requests" }, { status: 429 })));
476
646
  const tool = byName(defineTools(), "aximo_get_session_timeline");
477
647
  const result = await tool.handler({ sessionId });
478
648
  expect(result.isError).toBe(true);
479
649
  const payload = parsePayload(result);
480
650
  expect(payload.error).toBe("Rate limited");
481
- expect(payload.hint).toMatch(/daily/i);
651
+ expect(payload.hint).toMatch(/back off/i);
652
+ });
653
+ it("carries the Retry-After value into the 429 hint so an agent knows how long to wait", async () => {
654
+ server.use(http.get(`${TEST_BASE_URL}/api/sessions/${sessionId}/timeline`, () => HttpResponse.json({ error: "Too many requests" }, { status: 429, headers: { "Retry-After": "42" } })));
655
+ const tool = byName(defineTools(), "aximo_get_session_timeline");
656
+ const result = await tool.handler({ sessionId });
657
+ expect(result.isError).toBe(true);
658
+ const payload = parsePayload(result);
659
+ expect(payload.error).toBe("Rate limited");
660
+ expect(payload.hint).toBe("Back off and retry after 42 seconds.");
482
661
  });
483
662
  it("distinguishes a 500 with the HTTP-prefixed message", async () => {
484
663
  server.use(http.get(`${TEST_BASE_URL}/api/sessions/${sessionId}/timeline`, () => HttpResponse.json({ error: "Internal server error" }, { status: 500 })));
@@ -567,6 +746,32 @@ describe("write tools", () => {
567
746
  expect(parsePayload(result)).toEqual({ success: true });
568
747
  expect(method).toBe("DELETE");
569
748
  });
749
+ it("aximo_archive_case POSTs the case archive path", async () => {
750
+ let method = null;
751
+ server.use(http.post(`${TEST_BASE_URL}/api/cases/c1/archive`, ({ request: req }) => {
752
+ method = req.method;
753
+ return HttpResponse.json({ archived: true });
754
+ }));
755
+ const tool = byName(defineTools(), "aximo_archive_case");
756
+ const result = await tool.handler({ caseId: "c1" });
757
+ expect(parsePayload(result)).toEqual({ archived: true });
758
+ expect(method).toBe("POST");
759
+ });
760
+ it("aximo_archive_plan POSTs the plan archive path", async () => {
761
+ let method = null;
762
+ server.use(http.post(`${TEST_BASE_URL}/api/plans/pl1/archive`, ({ request: req }) => {
763
+ method = req.method;
764
+ return HttpResponse.json({ archived: true });
765
+ }));
766
+ const tool = byName(defineTools(), "aximo_archive_plan");
767
+ const result = await tool.handler({ planId: "pl1" });
768
+ expect(parsePayload(result)).toEqual({ archived: true });
769
+ expect(method).toBe("POST");
770
+ });
771
+ it("aximo_delete_case describes itself as archive-first and permanent", () => {
772
+ const tool = byName(defineTools(), "aximo_delete_case");
773
+ expect(tool.description).toBe("Permanently delete an already-archived test case. Archive it first with aximo_archive_case. Requires an organization admin or owner API key. It is removed from any plans it belongs to, and its rows in past plan runs go with it, so those runs list fewer cases than their stored totals; the agent sessions themselves are kept but detached. This cannot be undone.");
774
+ });
570
775
  it("aximo_delete_case DELETEs the case path", async () => {
571
776
  let method = null;
572
777
  server.use(http.delete(`${TEST_BASE_URL}/api/cases/c1`, ({ request: req }) => {
@@ -590,3 +795,245 @@ describe("write tools", () => {
590
795
  expect(body).toEqual({ title: "Saved" });
591
796
  });
592
797
  });
798
+ describe("aximo_update_case per-target config", () => {
799
+ const caseId = "55555555-5555-4555-9555-555555555555";
800
+ const userDeviceId = "77777777-7777-4777-9777-777777777777";
801
+ it("forwards the config objects untouched, including keys the CLI does not know about", async () => {
802
+ let body = null;
803
+ server.use(http.patch(`${TEST_BASE_URL}/api/cases/${caseId}`, async ({ request: req }) => {
804
+ body = await req.json();
805
+ return HttpResponse.json({ success: true });
806
+ }));
807
+ const mobile = {
808
+ deviceArn: "enc-device",
809
+ deviceName: "Pixel 8",
810
+ platform: "ANDROID",
811
+ apps: [{ uploadArn: "enc-upload", uploadType: "own_app", appName: null, bundleId: "com.x" }],
812
+ futureField: "kept",
813
+ };
814
+ const multi = [
815
+ { kind: "web", name: "Buyer", startUrl: "https://shop.example" },
816
+ { kind: "mobile", name: "Seller", mobilePlatform: "ios", userDeviceId },
817
+ ];
818
+ const tool = byName(defineTools(), "aximo_update_case");
819
+ const result = await tool.handler({
820
+ caseId,
821
+ type: "multi_device",
822
+ apiMobileConfig: mobile,
823
+ apiDesktopConfig: { userDeviceId },
824
+ apiMultiDeviceConfig: multi,
825
+ apiWebConfig: { hostnameOverrideIds: ["h1"] },
826
+ });
827
+ expect(result.isError).toBeUndefined();
828
+ expect(body).toEqual({
829
+ type: "multi_device",
830
+ apiMobileConfig: mobile,
831
+ apiDesktopConfig: { userDeviceId },
832
+ apiMultiDeviceConfig: multi,
833
+ apiWebConfig: { hostnameOverrideIds: ["h1"] },
834
+ });
835
+ });
836
+ it("passes null configs (and description) through as explicit clears", async () => {
837
+ let body = null;
838
+ server.use(http.patch(`${TEST_BASE_URL}/api/cases/${caseId}`, async ({ request: req }) => {
839
+ body = await req.json();
840
+ return HttpResponse.json({ success: true });
841
+ }));
842
+ const tool = byName(defineTools(), "aximo_update_case");
843
+ const result = await tool.handler({
844
+ caseId,
845
+ description: null,
846
+ apiMobileConfig: null,
847
+ apiDesktopConfig: null,
848
+ apiMultiDeviceConfig: null,
849
+ apiWebConfig: null,
850
+ });
851
+ expect(result.isError).toBeUndefined();
852
+ expect(body).toEqual({
853
+ description: null,
854
+ apiMobileConfig: null,
855
+ apiDesktopConfig: null,
856
+ apiMultiDeviceConfig: null,
857
+ apiWebConfig: null,
858
+ });
859
+ });
860
+ it("keeps unknown nested keys at schema time and only pins userDeviceId's shape", () => {
861
+ const schema = z.object(byName(defineTools(), "aximo_update_case").inputSchema);
862
+ const parsed = schema.parse({
863
+ caseId,
864
+ apiMobileConfig: { deviceArn: "x", newKey: 1 },
865
+ apiMultiDeviceConfig: [{ kind: "web", extra: true }],
866
+ apiWebConfig: { hostnameOverrideIds: [], newKey: true },
867
+ });
868
+ expect(parsed["apiMobileConfig"]).toEqual({ deviceArn: "x", newKey: 1 });
869
+ expect(parsed["apiMultiDeviceConfig"]).toEqual([{ kind: "web", extra: true }]);
870
+ expect(parsed["apiWebConfig"]).toEqual({ hostnameOverrideIds: [], newKey: true });
871
+ expect(schema.safeParse({ caseId, apiDesktopConfig: { userDeviceId: "nope" } }).success).toBe(false);
872
+ expect(schema.safeParse({ caseId, apiMultiDeviceConfig: "web" }).success).toBe(false);
873
+ });
874
+ it("names the failing path from the API's error details", async () => {
875
+ server.use(http.patch(`${TEST_BASE_URL}/api/cases/${caseId}`, () => HttpResponse.json({
876
+ error: "Upload not found",
877
+ details: { path: "apiMultiDeviceConfig[1].apps[0].uploadArn" },
878
+ }, { status: 404 })));
879
+ const tool = byName(defineTools(), "aximo_update_case");
880
+ const result = await tool.handler({ caseId, apiMultiDeviceConfig: [{}, {}] });
881
+ expect(result.isError).toBe(true);
882
+ expect(parsePayload(result).error).toBe("Upload not found (at apiMultiDeviceConfig[1].apps[0].uploadArn)");
883
+ });
884
+ it("renders zod issues from an Invalid request body tree", async () => {
885
+ server.use(http.patch(`${TEST_BASE_URL}/api/cases/${caseId}`, () => HttpResponse.json({
886
+ error: "Invalid request body",
887
+ details: {
888
+ errors: [],
889
+ properties: {
890
+ apiMobileConfig: {
891
+ errors: [],
892
+ properties: { deviceArn: { errors: ["Invalid input: expected string"] } },
893
+ },
894
+ },
895
+ },
896
+ }, { status: 400 })));
897
+ const tool = byName(defineTools(), "aximo_update_case");
898
+ const result = await tool.handler({ caseId, apiMobileConfig: {} });
899
+ expect(result.isError).toBe(true);
900
+ expect(parsePayload(result).error).toBe("HTTP 400: Invalid request body (apiMobileConfig.deviceArn: Invalid input: expected string)");
901
+ });
902
+ it("points at an org admin, not the API key, when a feature gate rejects the config", async () => {
903
+ server.use(http.patch(`${TEST_BASE_URL}/api/cases/${caseId}`, () => HttpResponse.json({
904
+ error: "Desktop cases are not enabled for this organization",
905
+ details: { feature: "byodEnabled" },
906
+ }, { status: 403 })));
907
+ const tool = byName(defineTools(), "aximo_update_case");
908
+ const result = await tool.handler({ caseId, apiDesktopConfig: { userDeviceId } });
909
+ expect(result.isError).toBe(true);
910
+ const payload = parsePayload(result);
911
+ expect(payload.error).toBe("Desktop cases are not enabled for this organization (requires feature byodEnabled)");
912
+ expect(payload.hint).toBe("Ask an Aximo organization admin to enable byodEnabled.");
913
+ });
914
+ it("recognises the feature-gate message even without details", async () => {
915
+ server.use(http.patch(`${TEST_BASE_URL}/api/cases/${caseId}`, () => HttpResponse.json({ error: "Multi-device cases are not enabled for this organization" }, { status: 403 })));
916
+ const tool = byName(defineTools(), "aximo_update_case");
917
+ const result = await tool.handler({ caseId, apiMultiDeviceConfig: [{}, {}] });
918
+ const payload = parsePayload(result);
919
+ expect(payload.error).toBe("Multi-device cases are not enabled for this organization");
920
+ expect(payload.hint).toMatch(/organization admin/);
921
+ expect(payload.hint).not.toMatch(/API key/);
922
+ });
923
+ });
924
+ describe("device and upload read tools", () => {
925
+ it("aximo_list_devices GETs /api/devices with platform and availableOnly", async () => {
926
+ let url = null;
927
+ const payload = {
928
+ devices: [{ arn: "enc-device", name: "Pixel 8", platform: "ANDROID", os: "14" }],
929
+ };
930
+ server.use(http.get(`${TEST_BASE_URL}/api/devices`, ({ request: req }) => {
931
+ url = new URL(req.url);
932
+ return HttpResponse.json(payload);
933
+ }));
934
+ const tool = byName(defineTools(), "aximo_list_devices");
935
+ const result = await tool.handler({ platform: "ANDROID", availableOnly: true });
936
+ expect(result.isError).toBeUndefined();
937
+ expect(parsePayload(result)).toEqual(payload);
938
+ expect(url.searchParams.get("platform")).toBe("ANDROID");
939
+ expect(url.searchParams.get("availableOnly")).toBe("true");
940
+ });
941
+ it("aximo_list_devices sends no filters when none are given", async () => {
942
+ let url = null;
943
+ server.use(http.get(`${TEST_BASE_URL}/api/devices`, ({ request: req }) => {
944
+ url = new URL(req.url);
945
+ return HttpResponse.json({ devices: [] });
946
+ }));
947
+ const tool = byName(defineTools(), "aximo_list_devices");
948
+ await tool.handler({ availableOnly: false });
949
+ expect([...url.searchParams.keys()]).toEqual([]);
950
+ });
951
+ it("aximo_list_user_devices GETs /api/devices/user", async () => {
952
+ const payload = {
953
+ devices: [{ id: "u1", name: "Abdul's Mac", platform: "macos", os: "15.0", status: "online" }],
954
+ };
955
+ let called = false;
956
+ server.use(http.get(`${TEST_BASE_URL}/api/devices/user`, () => {
957
+ called = true;
958
+ return HttpResponse.json(payload);
959
+ }));
960
+ const tool = byName(defineTools(), "aximo_list_user_devices");
961
+ const result = await tool.handler({});
962
+ expect(called).toBe(true);
963
+ expect(parsePayload(result)).toEqual(payload);
964
+ });
965
+ it("aximo_list_user_devices explains the bare 404 the route returns when BYOD is disabled", async () => {
966
+ server.use(http.get(`${TEST_BASE_URL}/api/devices/user`, () => HttpResponse.json({ error: "Not found" }, { status: 404 })));
967
+ const tool = byName(defineTools(), "aximo_list_user_devices");
968
+ const result = await tool.handler({});
969
+ expect(result.isError).toBe(true);
970
+ const payload = parsePayload(result);
971
+ expect(payload.error).toContain("BYOD is not enabled for this organization");
972
+ expect(payload.hint).toContain("byodEnabled");
973
+ });
974
+ it("aximo_list_uploads GETs /api/uploads with fileType", async () => {
975
+ let url = null;
976
+ const payload = {
977
+ uploads: [
978
+ {
979
+ id: "up1",
980
+ uploadArn: "enc-upload",
981
+ status: "succeeded",
982
+ appName: "Shop",
983
+ identifier: "com.example.shop",
984
+ fileType: "IOS_APP",
985
+ },
986
+ ],
987
+ };
988
+ server.use(http.get(`${TEST_BASE_URL}/api/uploads`, ({ request: req }) => {
989
+ url = new URL(req.url);
990
+ return HttpResponse.json(payload);
991
+ }));
992
+ const tool = byName(defineTools(), "aximo_list_uploads");
993
+ const result = await tool.handler({ fileType: "IOS_APP" });
994
+ expect(result.isError).toBeUndefined();
995
+ expect(parsePayload(result)).toEqual(payload);
996
+ expect(url.searchParams.get("fileType")).toBe("IOS_APP");
997
+ });
998
+ });
999
+ describe("409 handling parity between the CLI and MCP error mappers", () => {
1000
+ const caseId = "11111111-1111-4111-8111-111111111111";
1001
+ const ARCHIVE_409 = "Test case must be archived before it can be permanently deleted. Archive it first with POST /api/cases/{caseId}/archive, then retry the delete.";
1002
+ const OTHER_409 = "Case already in plan";
1003
+ function conflictOnDelete(message) {
1004
+ server.use(http.delete(`${TEST_BASE_URL}/api/cases/${caseId}`, () => HttpResponse.json({ error: message }, { status: 409 })));
1005
+ }
1006
+ it("surfaces the archive hint on the MCP surface, not just the CLI", async () => {
1007
+ conflictOnDelete(ARCHIVE_409);
1008
+ const tool = byName(defineTools(), "aximo_delete_case");
1009
+ const result = await tool.handler({ caseId });
1010
+ expect(result.isError).toBe(true);
1011
+ const payload = parsePayload(result);
1012
+ expect(payload.error).toMatch(/must be archived/);
1013
+ // The hint names aximo_archive_case, so the MCP caller it names has to receive
1014
+ // it. Before this branch existed the agent got a bare "HTTP 409: ..." pointing
1015
+ // at a REST endpoint it cannot call.
1016
+ expect(payload.hint).toBe(ARCHIVE_FIRST_HINT);
1017
+ expect(payload.hint).toMatch(/aximo_archive_case/);
1018
+ });
1019
+ it("stays silent on a 409 that archiving would not fix", async () => {
1020
+ conflictOnDelete(OTHER_409);
1021
+ const tool = byName(defineTools(), "aximo_delete_case");
1022
+ const result = await tool.handler({ caseId });
1023
+ expect(result.isError).toBe(true);
1024
+ const payload = parsePayload(result);
1025
+ expect(payload.error).toMatch(/already in plan/);
1026
+ expect(payload.hint).toBeUndefined();
1027
+ });
1028
+ it("agrees with handleApiError on which 409s earn the hint", () => {
1029
+ // Both mappers key off the same predicate and the same constant. If one side
1030
+ // grows a rule the other lacks, this fails rather than silently diverging on
1031
+ // the agent-facing surface.
1032
+ for (const message of [ARCHIVE_409, OTHER_409]) {
1033
+ const cliEnvelope = handleApiError(Object.assign(new Error(message), { status: 409 }));
1034
+ const expectedHint = isArchiveFirstConflict(message) ? ARCHIVE_FIRST_HINT : undefined;
1035
+ expect(cliEnvelope.error.code).toBe("conflict");
1036
+ expect(cliEnvelope.error.hint).toBe(expectedHint);
1037
+ }
1038
+ });
1039
+ });
@@ -0,0 +1,20 @@
1
+ import { type ClientDeps } from "../../client.js";
2
+ import { type DryRunResult } from "../../mutate.js";
3
+ import type { ArchivePlanResult, Envelope } from "../../types.js";
4
+ export interface ArchivePlanOptions {
5
+ planId?: unknown;
6
+ dryRun?: unknown;
7
+ }
8
+ export declare function runPlanArchive(options: ArchivePlanOptions, deps?: ClientDeps): Promise<Envelope<ArchivePlanResult | DryRunResult>>;
9
+ declare const _default: import("citty").CommandDef<{
10
+ readonly planId: {
11
+ readonly type: "positional";
12
+ readonly description: "Plan ID";
13
+ readonly required: true;
14
+ };
15
+ readonly "dry-run": {
16
+ readonly type: "boolean";
17
+ readonly description: "Validate and show request without calling API";
18
+ };
19
+ }>;
20
+ export default _default;
@@ -0,0 +1,42 @@
1
+ import { defineCommand } from "citty";
2
+ import { request } from "../../client.js";
3
+ import { handleApiError } from "../../errors.js";
4
+ import { dryRunEnvelope } from "../../mutate.js";
5
+ import { formatSuccess, printResult, validationError } from "../../output.js";
6
+ import { requireId } from "../../validate.js";
7
+ export async function runPlanArchive(options, deps) {
8
+ let planId;
9
+ try {
10
+ planId = requireId("planId", options.planId);
11
+ }
12
+ catch (err) {
13
+ return validationError(err);
14
+ }
15
+ const path = `/api/plans/${encodeURIComponent(planId)}/archive`;
16
+ if (options.dryRun) {
17
+ return dryRunEnvelope("POST", path);
18
+ }
19
+ try {
20
+ const res = await request(path, { method: "POST" }, deps ?? {});
21
+ return formatSuccess(res);
22
+ }
23
+ catch (err) {
24
+ return handleApiError(err);
25
+ }
26
+ }
27
+ export default defineCommand({
28
+ meta: {
29
+ name: "archive",
30
+ description: "Archive a test plan (hides it from lists; cases, run history, and its schedule are kept)",
31
+ },
32
+ args: {
33
+ planId: { type: "positional", description: "Plan ID", required: true },
34
+ "dry-run": { type: "boolean", description: "Validate and show request without calling API" },
35
+ },
36
+ async run({ args }) {
37
+ printResult(await runPlanArchive({
38
+ planId: args.planId,
39
+ dryRun: args["dry-run"],
40
+ }));
41
+ },
42
+ });
@@ -0,0 +1,20 @@
1
+ import { type ClientDeps } from "../../client.js";
2
+ import { type DryRunResult } from "../../mutate.js";
3
+ import type { DeletePlanResult, Envelope } from "../../types.js";
4
+ export interface DeletePlanOptions {
5
+ planId?: unknown;
6
+ dryRun?: unknown;
7
+ }
8
+ export declare function runPlanDelete(options: DeletePlanOptions, deps?: ClientDeps): Promise<Envelope<DeletePlanResult | DryRunResult>>;
9
+ declare const _default: import("citty").CommandDef<{
10
+ readonly planId: {
11
+ readonly type: "positional";
12
+ readonly description: "Plan ID";
13
+ readonly required: true;
14
+ };
15
+ readonly "dry-run": {
16
+ readonly type: "boolean";
17
+ readonly description: "Validate and show request without calling API";
18
+ };
19
+ }>;
20
+ export default _default;
@@ -0,0 +1,42 @@
1
+ import { defineCommand } from "citty";
2
+ import { request } from "../../client.js";
3
+ import { handleApiError } from "../../errors.js";
4
+ import { dryRunEnvelope } from "../../mutate.js";
5
+ import { formatSuccess, printResult, validationError } from "../../output.js";
6
+ import { requireId } from "../../validate.js";
7
+ export async function runPlanDelete(options, deps) {
8
+ let planId;
9
+ try {
10
+ planId = requireId("planId", options.planId);
11
+ }
12
+ catch (err) {
13
+ return validationError(err);
14
+ }
15
+ const path = `/api/plans/${encodeURIComponent(planId)}`;
16
+ if (options.dryRun) {
17
+ return dryRunEnvelope("DELETE", path);
18
+ }
19
+ try {
20
+ const res = await request(path, { method: "DELETE" }, deps ?? {});
21
+ return formatSuccess(res);
22
+ }
23
+ catch (err) {
24
+ return handleApiError(err);
25
+ }
26
+ }
27
+ export default defineCommand({
28
+ meta: {
29
+ name: "delete",
30
+ description: "Permanently delete an archived test plan (archive it first with `aximo plan archive`). Requires an org admin or owner. Member cases and run history are kept; the runs are detached from the plan",
31
+ },
32
+ args: {
33
+ planId: { type: "positional", description: "Plan ID", required: true },
34
+ "dry-run": { type: "boolean", description: "Validate and show request without calling API" },
35
+ },
36
+ async run({ args }) {
37
+ printResult(await runPlanDelete({
38
+ planId: args.planId,
39
+ dryRun: args["dry-run"],
40
+ }));
41
+ },
42
+ });
@@ -4,6 +4,9 @@ export default defineCommand({
4
4
  subCommands: {
5
5
  create: () => import("./create.js").then((m) => m.default),
6
6
  update: () => import("./update.js").then((m) => m.default),
7
+ archive: () => import("./archive.js").then((m) => m.default),
8
+ restore: () => import("./restore.js").then((m) => m.default),
9
+ delete: () => import("./delete.js").then((m) => m.default),
7
10
  "add-case": () => import("./add-case.js").then((m) => m.default),
8
11
  "remove-case": () => import("./remove-case.js").then((m) => m.default),
9
12
  },