@autifyhq/aximo-cli 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -5
- package/dist/client.d.ts +5 -0
- package/dist/client.js +23 -22
- package/dist/client.test.js +28 -0
- package/dist/commands/case/archive.d.ts +20 -0
- package/dist/commands/case/archive.js +42 -0
- package/dist/commands/case/case.test.js +181 -0
- package/dist/commands/case/delete.js +4 -1
- package/dist/commands/case/index.js +2 -0
- package/dist/commands/case/restore.d.ts +20 -0
- package/dist/commands/case/restore.js +42 -0
- package/dist/commands/case/update.d.ts +22 -0
- package/dist/commands/case/update.js +34 -3
- package/dist/commands/device/status.d.ts +1 -10
- package/dist/commands/mcp/index.js +6 -3
- package/dist/commands/mcp/server.test.js +32 -1
- package/dist/commands/mcp/tools.js +242 -9
- package/dist/commands/mcp/tools.test.js +434 -1
- package/dist/commands/mcp/wait.d.ts +20 -0
- package/dist/commands/mcp/wait.js +46 -0
- package/dist/commands/mcp/wait.test.d.ts +1 -0
- package/dist/commands/mcp/wait.test.js +91 -0
- package/dist/commands/plan/archive.d.ts +20 -0
- package/dist/commands/plan/archive.js +42 -0
- package/dist/commands/plan/delete.d.ts +20 -0
- package/dist/commands/plan/delete.js +42 -0
- package/dist/commands/plan/index.js +3 -0
- package/dist/commands/plan/plan.test.js +115 -3
- package/dist/commands/plan/restore.d.ts +20 -0
- package/dist/commands/plan/restore.js +42 -0
- package/dist/commands/session/export.d.ts +30 -0
- package/dist/commands/session/export.js +58 -0
- package/dist/commands/session/index.js +1 -0
- package/dist/commands/session/session.test.js +62 -0
- package/dist/commands/upload/upload.test.js +5 -2
- package/dist/errors.d.ts +5 -0
- package/dist/errors.js +110 -2
- package/dist/errors.test.js +106 -3
- package/dist/session-export.d.ts +30 -0
- package/dist/session-export.js +72 -0
- package/dist/session-export.test.d.ts +1 -0
- package/dist/session-export.test.js +107 -0
- package/dist/types.d.ts +41 -3
- package/dist/validate.d.ts +1 -0
- package/dist/validate.js +15 -3
- package/dist/validate.test.js +18 -1
- package/package.json +1 -1
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { ARCHIVE_FIRST_HINT, handleApiError, isArchiveFirstConflict } from "../../errors.js";
|
|
2
7
|
import { HttpResponse, http, server, TEST_BASE_URL } from "../../test-utils.js";
|
|
3
8
|
import { defineTools } from "./tools.js";
|
|
4
9
|
beforeAll(() => {
|
|
@@ -22,25 +27,37 @@ describe("defineTools", () => {
|
|
|
22
27
|
const names = defineTools().map((t) => t.name);
|
|
23
28
|
expect(names).toEqual([
|
|
24
29
|
"aximo_list_projects",
|
|
30
|
+
"aximo_list_devices",
|
|
31
|
+
"aximo_list_user_devices",
|
|
32
|
+
"aximo_list_uploads",
|
|
25
33
|
"aximo_list_sessions",
|
|
26
34
|
"aximo_get_session",
|
|
35
|
+
"aximo_wait_for_session",
|
|
27
36
|
"aximo_get_session_messages",
|
|
28
37
|
"aximo_get_session_screenshots",
|
|
29
38
|
"aximo_get_session_timeline",
|
|
30
39
|
"aximo_get_session_capture",
|
|
31
40
|
"aximo_get_session_snapshot",
|
|
41
|
+
"aximo_export_session_pdf",
|
|
42
|
+
"aximo_export_session_screenshots",
|
|
32
43
|
"aximo_list_cases",
|
|
33
44
|
"aximo_get_case",
|
|
34
45
|
"aximo_create_case",
|
|
35
46
|
"aximo_update_case",
|
|
47
|
+
"aximo_archive_case",
|
|
48
|
+
"aximo_restore_case",
|
|
36
49
|
"aximo_delete_case",
|
|
37
50
|
"aximo_run_case",
|
|
51
|
+
"aximo_create_session",
|
|
38
52
|
"aximo_create_plan",
|
|
39
53
|
"aximo_add_case_to_plan",
|
|
40
54
|
"aximo_remove_case_from_plan",
|
|
41
55
|
"aximo_list_plans",
|
|
42
56
|
"aximo_get_plan",
|
|
43
57
|
"aximo_update_plan",
|
|
58
|
+
"aximo_archive_plan",
|
|
59
|
+
"aximo_restore_plan",
|
|
60
|
+
"aximo_delete_plan",
|
|
44
61
|
"aximo_run_plan",
|
|
45
62
|
"aximo_list_plan_runs",
|
|
46
63
|
"aximo_get_plan_run",
|
|
@@ -180,6 +197,9 @@ describe("aximo_update_case", () => {
|
|
|
180
197
|
expect(result.isError).toBeUndefined();
|
|
181
198
|
expect(parsePayload(result)).toEqual(payload);
|
|
182
199
|
expect(body).toEqual({ title: "Renamed", scenario: "Given a renamed page" });
|
|
200
|
+
// GEN-2844 regression guard: an omitted description must not be sent at
|
|
201
|
+
// all, or every partial update would clear the field server-side.
|
|
202
|
+
expect(body).not.toHaveProperty("description");
|
|
183
203
|
});
|
|
184
204
|
it("rejects an update with no fields instead of issuing an empty PATCH", async () => {
|
|
185
205
|
let called = false;
|
|
@@ -204,6 +224,101 @@ describe("aximo_update_case", () => {
|
|
|
204
224
|
expect(result.isError).toBeUndefined();
|
|
205
225
|
expect(body).toEqual({ startUrl: null });
|
|
206
226
|
});
|
|
227
|
+
it("passes description: null through as an explicit clear (GEN-2844)", async () => {
|
|
228
|
+
let body = null;
|
|
229
|
+
server.use(http.patch(`${TEST_BASE_URL}/api/cases/${caseId}`, async ({ request: req }) => {
|
|
230
|
+
body = await req.json();
|
|
231
|
+
return HttpResponse.json({ success: true });
|
|
232
|
+
}));
|
|
233
|
+
const tool = byName(defineTools(), "aximo_update_case");
|
|
234
|
+
const result = await tool.handler({ caseId, description: null });
|
|
235
|
+
expect(result.isError).toBeUndefined();
|
|
236
|
+
expect(body).toEqual({ description: null });
|
|
237
|
+
});
|
|
238
|
+
// The tests above call handler() directly, which bypasses Zod entirely --
|
|
239
|
+
// the MCP SDK is what parses in production. These assertions are what
|
|
240
|
+
// actually pin the clearable fields to their schema.
|
|
241
|
+
it("accepts null for the clearable fields at schema time, and drops omitted keys", () => {
|
|
242
|
+
const tool = byName(defineTools(), "aximo_update_case");
|
|
243
|
+
// biome-ignore lint/suspicious/noExplicitAny: zod runtime check
|
|
244
|
+
const schemas = tool.inputSchema;
|
|
245
|
+
expect(schemas.description.safeParse(null).success).toBe(true);
|
|
246
|
+
expect(schemas.description.safeParse("").success).toBe(true);
|
|
247
|
+
expect(schemas.description.safeParse(5).success).toBe(false);
|
|
248
|
+
expect(schemas.startUrl.safeParse(null).success).toBe(true);
|
|
249
|
+
expect(schemas.startUrl.safeParse("not-a-url").success).toBe(false);
|
|
250
|
+
// The object schema must leave omitted keys absent rather than defaulting
|
|
251
|
+
// them to null, which would clear on every partial update.
|
|
252
|
+
const parsed = z.object(schemas).parse({ caseId, title: "Renamed" });
|
|
253
|
+
expect(parsed).not.toHaveProperty("description");
|
|
254
|
+
expect(parsed).not.toHaveProperty("startUrl");
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
describe("aximo_wait_for_session", () => {
|
|
258
|
+
const sessionId = "bbbbbbbb-bbbb-4bbb-9bbb-bbbbbbbbbbbb";
|
|
259
|
+
it("returns done=true with the session when the status is already final", async () => {
|
|
260
|
+
const session = { id: sessionId, status: "failed", summary: "Button missing" };
|
|
261
|
+
server.use(http.get(`${TEST_BASE_URL}/api/sessions/${sessionId}`, () => HttpResponse.json(session)));
|
|
262
|
+
const tool = byName(defineTools(), "aximo_wait_for_session");
|
|
263
|
+
const result = await tool.handler({ sessionId });
|
|
264
|
+
expect(result.isError).toBeUndefined();
|
|
265
|
+
expect(parsePayload(result)).toEqual({
|
|
266
|
+
done: true,
|
|
267
|
+
status: "failed",
|
|
268
|
+
waitedSeconds: 0,
|
|
269
|
+
session,
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
it("returns done=false with a hint when the run outlasts the timeout", async () => {
|
|
273
|
+
server.use(http.get(`${TEST_BASE_URL}/api/sessions/${sessionId}`, () => HttpResponse.json({ id: sessionId, status: "running" })));
|
|
274
|
+
const tool = byName(defineTools(), "aximo_wait_for_session");
|
|
275
|
+
// 1 s is shorter than one poll interval, so this returns after a single poll.
|
|
276
|
+
const result = await tool.handler({ sessionId, timeoutSeconds: 1 });
|
|
277
|
+
expect(result.isError).toBeUndefined();
|
|
278
|
+
const payload = parsePayload(result);
|
|
279
|
+
expect(payload.done).toBe(false);
|
|
280
|
+
expect(payload.status).toBe("running");
|
|
281
|
+
expect(payload.hint).toMatch(/aximo_wait_for_session again/);
|
|
282
|
+
});
|
|
283
|
+
it("shapes API errors like every other tool", async () => {
|
|
284
|
+
server.use(http.get(`${TEST_BASE_URL}/api/sessions/${sessionId}`, () => HttpResponse.json({ error: "Session not found" }, { status: 404 })));
|
|
285
|
+
const tool = byName(defineTools(), "aximo_wait_for_session");
|
|
286
|
+
const result = await tool.handler({ sessionId, timeoutSeconds: 1 });
|
|
287
|
+
expect(result.isError).toBe(true);
|
|
288
|
+
expect(parsePayload(result)).toEqual({ error: "Session not found" });
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
describe("aximo_create_session", () => {
|
|
292
|
+
const projectId = "12121212-1212-4212-9212-121212121212";
|
|
293
|
+
it("POSTs /api/sessions with the prompt and device and returns sessionId", async () => {
|
|
294
|
+
let body = null;
|
|
295
|
+
let apiKey = null;
|
|
296
|
+
const payload = { success: true, sessionId: "34343434-3434-4343-9343-343434343434" };
|
|
297
|
+
server.use(http.post(`${TEST_BASE_URL}/api/sessions`, async ({ request: req }) => {
|
|
298
|
+
apiKey = req.headers.get("x-api-key");
|
|
299
|
+
body = await req.json();
|
|
300
|
+
return HttpResponse.json(payload, { status: 201 });
|
|
301
|
+
}));
|
|
302
|
+
const tool = byName(defineTools(), "aximo_create_session");
|
|
303
|
+
const input = {
|
|
304
|
+
title: "Login works",
|
|
305
|
+
prompt: "Open http://localhost:3000 and sign in as demo@example.com. Pass if the dashboard loads.",
|
|
306
|
+
projectId,
|
|
307
|
+
userDeviceId: "56565656-5656-4565-9565-565656565656",
|
|
308
|
+
};
|
|
309
|
+
const result = await tool.handler(input);
|
|
310
|
+
expect(result.isError).toBeUndefined();
|
|
311
|
+
expect(parsePayload(result)).toEqual(payload);
|
|
312
|
+
expect(apiKey).toBe("test-key");
|
|
313
|
+
expect(body).toEqual(input);
|
|
314
|
+
});
|
|
315
|
+
it("passes API validation errors through as a tool error", async () => {
|
|
316
|
+
server.use(http.post(`${TEST_BASE_URL}/api/sessions`, () => HttpResponse.json({ error: "Project not found" }, { status: 404 })));
|
|
317
|
+
const tool = byName(defineTools(), "aximo_create_session");
|
|
318
|
+
const result = await tool.handler({ title: "t", prompt: "p", projectId });
|
|
319
|
+
expect(result.isError).toBe(true);
|
|
320
|
+
expect(parsePayload(result)).toEqual({ error: "Project not found" });
|
|
321
|
+
});
|
|
207
322
|
});
|
|
208
323
|
describe("aximo_run_case", () => {
|
|
209
324
|
const caseId = "77777777-7777-4777-9777-777777777777";
|
|
@@ -704,6 +819,32 @@ describe("write tools", () => {
|
|
|
704
819
|
expect(parsePayload(result)).toEqual({ success: true });
|
|
705
820
|
expect(method).toBe("DELETE");
|
|
706
821
|
});
|
|
822
|
+
it("aximo_archive_case POSTs the case archive path", async () => {
|
|
823
|
+
let method = null;
|
|
824
|
+
server.use(http.post(`${TEST_BASE_URL}/api/cases/c1/archive`, ({ request: req }) => {
|
|
825
|
+
method = req.method;
|
|
826
|
+
return HttpResponse.json({ archived: true });
|
|
827
|
+
}));
|
|
828
|
+
const tool = byName(defineTools(), "aximo_archive_case");
|
|
829
|
+
const result = await tool.handler({ caseId: "c1" });
|
|
830
|
+
expect(parsePayload(result)).toEqual({ archived: true });
|
|
831
|
+
expect(method).toBe("POST");
|
|
832
|
+
});
|
|
833
|
+
it("aximo_archive_plan POSTs the plan archive path", async () => {
|
|
834
|
+
let method = null;
|
|
835
|
+
server.use(http.post(`${TEST_BASE_URL}/api/plans/pl1/archive`, ({ request: req }) => {
|
|
836
|
+
method = req.method;
|
|
837
|
+
return HttpResponse.json({ archived: true });
|
|
838
|
+
}));
|
|
839
|
+
const tool = byName(defineTools(), "aximo_archive_plan");
|
|
840
|
+
const result = await tool.handler({ planId: "pl1" });
|
|
841
|
+
expect(parsePayload(result)).toEqual({ archived: true });
|
|
842
|
+
expect(method).toBe("POST");
|
|
843
|
+
});
|
|
844
|
+
it("aximo_delete_case describes itself as archive-first and permanent", () => {
|
|
845
|
+
const tool = byName(defineTools(), "aximo_delete_case");
|
|
846
|
+
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.");
|
|
847
|
+
});
|
|
707
848
|
it("aximo_delete_case DELETEs the case path", async () => {
|
|
708
849
|
let method = null;
|
|
709
850
|
server.use(http.delete(`${TEST_BASE_URL}/api/cases/c1`, ({ request: req }) => {
|
|
@@ -727,3 +868,295 @@ describe("write tools", () => {
|
|
|
727
868
|
expect(body).toEqual({ title: "Saved" });
|
|
728
869
|
});
|
|
729
870
|
});
|
|
871
|
+
describe("aximo_update_case per-target config", () => {
|
|
872
|
+
const caseId = "55555555-5555-4555-9555-555555555555";
|
|
873
|
+
const userDeviceId = "77777777-7777-4777-9777-777777777777";
|
|
874
|
+
it("forwards the config objects untouched, including keys the CLI does not know about", async () => {
|
|
875
|
+
let body = null;
|
|
876
|
+
server.use(http.patch(`${TEST_BASE_URL}/api/cases/${caseId}`, async ({ request: req }) => {
|
|
877
|
+
body = await req.json();
|
|
878
|
+
return HttpResponse.json({ success: true });
|
|
879
|
+
}));
|
|
880
|
+
const mobile = {
|
|
881
|
+
deviceArn: "enc-device",
|
|
882
|
+
deviceName: "Pixel 8",
|
|
883
|
+
platform: "ANDROID",
|
|
884
|
+
apps: [{ uploadArn: "enc-upload", uploadType: "own_app", appName: null, bundleId: "com.x" }],
|
|
885
|
+
futureField: "kept",
|
|
886
|
+
};
|
|
887
|
+
const multi = [
|
|
888
|
+
{ kind: "web", name: "Buyer", startUrl: "https://shop.example" },
|
|
889
|
+
{ kind: "mobile", name: "Seller", mobilePlatform: "ios", userDeviceId },
|
|
890
|
+
];
|
|
891
|
+
const tool = byName(defineTools(), "aximo_update_case");
|
|
892
|
+
const result = await tool.handler({
|
|
893
|
+
caseId,
|
|
894
|
+
type: "multi_device",
|
|
895
|
+
apiMobileConfig: mobile,
|
|
896
|
+
apiDesktopConfig: { userDeviceId },
|
|
897
|
+
apiMultiDeviceConfig: multi,
|
|
898
|
+
apiWebConfig: { hostnameOverrideIds: ["h1"] },
|
|
899
|
+
});
|
|
900
|
+
expect(result.isError).toBeUndefined();
|
|
901
|
+
expect(body).toEqual({
|
|
902
|
+
type: "multi_device",
|
|
903
|
+
apiMobileConfig: mobile,
|
|
904
|
+
apiDesktopConfig: { userDeviceId },
|
|
905
|
+
apiMultiDeviceConfig: multi,
|
|
906
|
+
apiWebConfig: { hostnameOverrideIds: ["h1"] },
|
|
907
|
+
});
|
|
908
|
+
});
|
|
909
|
+
it("passes null configs (and description) through as explicit clears", async () => {
|
|
910
|
+
let body = null;
|
|
911
|
+
server.use(http.patch(`${TEST_BASE_URL}/api/cases/${caseId}`, async ({ request: req }) => {
|
|
912
|
+
body = await req.json();
|
|
913
|
+
return HttpResponse.json({ success: true });
|
|
914
|
+
}));
|
|
915
|
+
const tool = byName(defineTools(), "aximo_update_case");
|
|
916
|
+
const result = await tool.handler({
|
|
917
|
+
caseId,
|
|
918
|
+
description: null,
|
|
919
|
+
apiMobileConfig: null,
|
|
920
|
+
apiDesktopConfig: null,
|
|
921
|
+
apiMultiDeviceConfig: null,
|
|
922
|
+
apiWebConfig: null,
|
|
923
|
+
});
|
|
924
|
+
expect(result.isError).toBeUndefined();
|
|
925
|
+
expect(body).toEqual({
|
|
926
|
+
description: null,
|
|
927
|
+
apiMobileConfig: null,
|
|
928
|
+
apiDesktopConfig: null,
|
|
929
|
+
apiMultiDeviceConfig: null,
|
|
930
|
+
apiWebConfig: null,
|
|
931
|
+
});
|
|
932
|
+
});
|
|
933
|
+
it("keeps unknown nested keys at schema time and only pins userDeviceId's shape", () => {
|
|
934
|
+
const schema = z.object(byName(defineTools(), "aximo_update_case").inputSchema);
|
|
935
|
+
const parsed = schema.parse({
|
|
936
|
+
caseId,
|
|
937
|
+
apiMobileConfig: { deviceArn: "x", newKey: 1 },
|
|
938
|
+
apiMultiDeviceConfig: [{ kind: "web", extra: true }],
|
|
939
|
+
apiWebConfig: { hostnameOverrideIds: [], newKey: true },
|
|
940
|
+
});
|
|
941
|
+
expect(parsed["apiMobileConfig"]).toEqual({ deviceArn: "x", newKey: 1 });
|
|
942
|
+
expect(parsed["apiMultiDeviceConfig"]).toEqual([{ kind: "web", extra: true }]);
|
|
943
|
+
expect(parsed["apiWebConfig"]).toEqual({ hostnameOverrideIds: [], newKey: true });
|
|
944
|
+
expect(schema.safeParse({ caseId, apiDesktopConfig: { userDeviceId: "nope" } }).success).toBe(false);
|
|
945
|
+
expect(schema.safeParse({ caseId, apiMultiDeviceConfig: "web" }).success).toBe(false);
|
|
946
|
+
});
|
|
947
|
+
it("names the failing path from the API's error details", async () => {
|
|
948
|
+
server.use(http.patch(`${TEST_BASE_URL}/api/cases/${caseId}`, () => HttpResponse.json({
|
|
949
|
+
error: "Upload not found",
|
|
950
|
+
details: { path: "apiMultiDeviceConfig[1].apps[0].uploadArn" },
|
|
951
|
+
}, { status: 404 })));
|
|
952
|
+
const tool = byName(defineTools(), "aximo_update_case");
|
|
953
|
+
const result = await tool.handler({ caseId, apiMultiDeviceConfig: [{}, {}] });
|
|
954
|
+
expect(result.isError).toBe(true);
|
|
955
|
+
expect(parsePayload(result).error).toBe("Upload not found (at apiMultiDeviceConfig[1].apps[0].uploadArn)");
|
|
956
|
+
});
|
|
957
|
+
it("renders zod issues from an Invalid request body tree", async () => {
|
|
958
|
+
server.use(http.patch(`${TEST_BASE_URL}/api/cases/${caseId}`, () => HttpResponse.json({
|
|
959
|
+
error: "Invalid request body",
|
|
960
|
+
details: {
|
|
961
|
+
errors: [],
|
|
962
|
+
properties: {
|
|
963
|
+
apiMobileConfig: {
|
|
964
|
+
errors: [],
|
|
965
|
+
properties: { deviceArn: { errors: ["Invalid input: expected string"] } },
|
|
966
|
+
},
|
|
967
|
+
},
|
|
968
|
+
},
|
|
969
|
+
}, { status: 400 })));
|
|
970
|
+
const tool = byName(defineTools(), "aximo_update_case");
|
|
971
|
+
const result = await tool.handler({ caseId, apiMobileConfig: {} });
|
|
972
|
+
expect(result.isError).toBe(true);
|
|
973
|
+
expect(parsePayload(result).error).toBe("HTTP 400: Invalid request body (apiMobileConfig.deviceArn: Invalid input: expected string)");
|
|
974
|
+
});
|
|
975
|
+
it("points at an org admin, not the API key, when a feature gate rejects the config", async () => {
|
|
976
|
+
server.use(http.patch(`${TEST_BASE_URL}/api/cases/${caseId}`, () => HttpResponse.json({
|
|
977
|
+
error: "Desktop cases are not enabled for this organization",
|
|
978
|
+
details: { feature: "byodEnabled" },
|
|
979
|
+
}, { status: 403 })));
|
|
980
|
+
const tool = byName(defineTools(), "aximo_update_case");
|
|
981
|
+
const result = await tool.handler({ caseId, apiDesktopConfig: { userDeviceId } });
|
|
982
|
+
expect(result.isError).toBe(true);
|
|
983
|
+
const payload = parsePayload(result);
|
|
984
|
+
expect(payload.error).toBe("Desktop cases are not enabled for this organization (requires feature byodEnabled)");
|
|
985
|
+
expect(payload.hint).toBe("Ask an Aximo organization admin to enable byodEnabled.");
|
|
986
|
+
});
|
|
987
|
+
it("recognises the feature-gate message even without details", async () => {
|
|
988
|
+
server.use(http.patch(`${TEST_BASE_URL}/api/cases/${caseId}`, () => HttpResponse.json({ error: "Multi-device cases are not enabled for this organization" }, { status: 403 })));
|
|
989
|
+
const tool = byName(defineTools(), "aximo_update_case");
|
|
990
|
+
const result = await tool.handler({ caseId, apiMultiDeviceConfig: [{}, {}] });
|
|
991
|
+
const payload = parsePayload(result);
|
|
992
|
+
expect(payload.error).toBe("Multi-device cases are not enabled for this organization");
|
|
993
|
+
expect(payload.hint).toMatch(/organization admin/);
|
|
994
|
+
expect(payload.hint).not.toMatch(/API key/);
|
|
995
|
+
});
|
|
996
|
+
});
|
|
997
|
+
describe("device and upload read tools", () => {
|
|
998
|
+
it("aximo_list_devices GETs /api/devices with platform and availableOnly", async () => {
|
|
999
|
+
let url = null;
|
|
1000
|
+
const payload = {
|
|
1001
|
+
devices: [{ arn: "enc-device", name: "Pixel 8", platform: "ANDROID", os: "14" }],
|
|
1002
|
+
};
|
|
1003
|
+
server.use(http.get(`${TEST_BASE_URL}/api/devices`, ({ request: req }) => {
|
|
1004
|
+
url = new URL(req.url);
|
|
1005
|
+
return HttpResponse.json(payload);
|
|
1006
|
+
}));
|
|
1007
|
+
const tool = byName(defineTools(), "aximo_list_devices");
|
|
1008
|
+
const result = await tool.handler({ platform: "ANDROID", availableOnly: true });
|
|
1009
|
+
expect(result.isError).toBeUndefined();
|
|
1010
|
+
expect(parsePayload(result)).toEqual(payload);
|
|
1011
|
+
expect(url.searchParams.get("platform")).toBe("ANDROID");
|
|
1012
|
+
expect(url.searchParams.get("availableOnly")).toBe("true");
|
|
1013
|
+
});
|
|
1014
|
+
it("aximo_list_devices sends no filters when none are given", async () => {
|
|
1015
|
+
let url = null;
|
|
1016
|
+
server.use(http.get(`${TEST_BASE_URL}/api/devices`, ({ request: req }) => {
|
|
1017
|
+
url = new URL(req.url);
|
|
1018
|
+
return HttpResponse.json({ devices: [] });
|
|
1019
|
+
}));
|
|
1020
|
+
const tool = byName(defineTools(), "aximo_list_devices");
|
|
1021
|
+
await tool.handler({ availableOnly: false });
|
|
1022
|
+
expect([...url.searchParams.keys()]).toEqual([]);
|
|
1023
|
+
});
|
|
1024
|
+
it("aximo_list_user_devices GETs /api/devices/user", async () => {
|
|
1025
|
+
const payload = {
|
|
1026
|
+
devices: [{ id: "u1", name: "Abdul's Mac", platform: "macos", os: "15.0", status: "online" }],
|
|
1027
|
+
};
|
|
1028
|
+
let called = false;
|
|
1029
|
+
server.use(http.get(`${TEST_BASE_URL}/api/devices/user`, () => {
|
|
1030
|
+
called = true;
|
|
1031
|
+
return HttpResponse.json(payload);
|
|
1032
|
+
}));
|
|
1033
|
+
const tool = byName(defineTools(), "aximo_list_user_devices");
|
|
1034
|
+
const result = await tool.handler({});
|
|
1035
|
+
expect(called).toBe(true);
|
|
1036
|
+
expect(parsePayload(result)).toEqual(payload);
|
|
1037
|
+
});
|
|
1038
|
+
it("aximo_list_user_devices explains the bare 404 the route returns when BYOD is disabled", async () => {
|
|
1039
|
+
server.use(http.get(`${TEST_BASE_URL}/api/devices/user`, () => HttpResponse.json({ error: "Not found" }, { status: 404 })));
|
|
1040
|
+
const tool = byName(defineTools(), "aximo_list_user_devices");
|
|
1041
|
+
const result = await tool.handler({});
|
|
1042
|
+
expect(result.isError).toBe(true);
|
|
1043
|
+
const payload = parsePayload(result);
|
|
1044
|
+
expect(payload.error).toContain("BYOD is not enabled for this organization");
|
|
1045
|
+
expect(payload.hint).toContain("byodEnabled");
|
|
1046
|
+
});
|
|
1047
|
+
it("aximo_list_uploads GETs /api/uploads with fileType", async () => {
|
|
1048
|
+
let url = null;
|
|
1049
|
+
const payload = {
|
|
1050
|
+
uploads: [
|
|
1051
|
+
{
|
|
1052
|
+
id: "up1",
|
|
1053
|
+
uploadArn: "enc-upload",
|
|
1054
|
+
status: "succeeded",
|
|
1055
|
+
appName: "Shop",
|
|
1056
|
+
identifier: "com.example.shop",
|
|
1057
|
+
fileType: "IOS_APP",
|
|
1058
|
+
},
|
|
1059
|
+
],
|
|
1060
|
+
};
|
|
1061
|
+
server.use(http.get(`${TEST_BASE_URL}/api/uploads`, ({ request: req }) => {
|
|
1062
|
+
url = new URL(req.url);
|
|
1063
|
+
return HttpResponse.json(payload);
|
|
1064
|
+
}));
|
|
1065
|
+
const tool = byName(defineTools(), "aximo_list_uploads");
|
|
1066
|
+
const result = await tool.handler({ fileType: "IOS_APP" });
|
|
1067
|
+
expect(result.isError).toBeUndefined();
|
|
1068
|
+
expect(parsePayload(result)).toEqual(payload);
|
|
1069
|
+
expect(url.searchParams.get("fileType")).toBe("IOS_APP");
|
|
1070
|
+
});
|
|
1071
|
+
});
|
|
1072
|
+
describe("409 handling parity between the CLI and MCP error mappers", () => {
|
|
1073
|
+
const caseId = "11111111-1111-4111-8111-111111111111";
|
|
1074
|
+
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.";
|
|
1075
|
+
const OTHER_409 = "Case already in plan";
|
|
1076
|
+
function conflictOnDelete(message) {
|
|
1077
|
+
server.use(http.delete(`${TEST_BASE_URL}/api/cases/${caseId}`, () => HttpResponse.json({ error: message }, { status: 409 })));
|
|
1078
|
+
}
|
|
1079
|
+
it("surfaces the archive hint on the MCP surface, not just the CLI", async () => {
|
|
1080
|
+
conflictOnDelete(ARCHIVE_409);
|
|
1081
|
+
const tool = byName(defineTools(), "aximo_delete_case");
|
|
1082
|
+
const result = await tool.handler({ caseId });
|
|
1083
|
+
expect(result.isError).toBe(true);
|
|
1084
|
+
const payload = parsePayload(result);
|
|
1085
|
+
expect(payload.error).toMatch(/must be archived/);
|
|
1086
|
+
// The hint names aximo_archive_case, so the MCP caller it names has to receive
|
|
1087
|
+
// it. Before this branch existed the agent got a bare "HTTP 409: ..." pointing
|
|
1088
|
+
// at a REST endpoint it cannot call.
|
|
1089
|
+
expect(payload.hint).toBe(ARCHIVE_FIRST_HINT);
|
|
1090
|
+
expect(payload.hint).toMatch(/aximo_archive_case/);
|
|
1091
|
+
});
|
|
1092
|
+
it("stays silent on a 409 that archiving would not fix", async () => {
|
|
1093
|
+
conflictOnDelete(OTHER_409);
|
|
1094
|
+
const tool = byName(defineTools(), "aximo_delete_case");
|
|
1095
|
+
const result = await tool.handler({ caseId });
|
|
1096
|
+
expect(result.isError).toBe(true);
|
|
1097
|
+
const payload = parsePayload(result);
|
|
1098
|
+
expect(payload.error).toMatch(/already in plan/);
|
|
1099
|
+
expect(payload.hint).toBeUndefined();
|
|
1100
|
+
});
|
|
1101
|
+
it("agrees with handleApiError on which 409s earn the hint", () => {
|
|
1102
|
+
// Both mappers key off the same predicate and the same constant. If one side
|
|
1103
|
+
// grows a rule the other lacks, this fails rather than silently diverging on
|
|
1104
|
+
// the agent-facing surface.
|
|
1105
|
+
for (const message of [ARCHIVE_409, OTHER_409]) {
|
|
1106
|
+
const cliEnvelope = handleApiError(Object.assign(new Error(message), { status: 409 }));
|
|
1107
|
+
const expectedHint = isArchiveFirstConflict(message) ? ARCHIVE_FIRST_HINT : undefined;
|
|
1108
|
+
expect(cliEnvelope.error.code).toBe("conflict");
|
|
1109
|
+
expect(cliEnvelope.error.hint).toBe(expectedHint);
|
|
1110
|
+
}
|
|
1111
|
+
});
|
|
1112
|
+
});
|
|
1113
|
+
describe("session export tools", () => {
|
|
1114
|
+
const sessionId = "33333333-3333-4333-9333-333333333333";
|
|
1115
|
+
let dir;
|
|
1116
|
+
beforeEach(() => {
|
|
1117
|
+
dir = mkdtempSync(join(tmpdir(), "aximo-mcp-export-"));
|
|
1118
|
+
});
|
|
1119
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
1120
|
+
it("aximo_export_session_pdf writes the PDF and returns its path, not the bytes", async () => {
|
|
1121
|
+
let apiKey = null;
|
|
1122
|
+
let locale = null;
|
|
1123
|
+
server.use(http.get(`${TEST_BASE_URL}/api/sessions/${sessionId}/export/pdf`, ({ request: req }) => {
|
|
1124
|
+
apiKey = req.headers.get("x-api-key");
|
|
1125
|
+
locale = new URL(req.url).searchParams.get("locale");
|
|
1126
|
+
return new HttpResponse("%PDF-1.3\n%%EOF\n");
|
|
1127
|
+
}));
|
|
1128
|
+
const outputPath = join(dir, "result.pdf");
|
|
1129
|
+
const tool = byName(defineTools(), "aximo_export_session_pdf");
|
|
1130
|
+
const result = await tool.handler({ sessionId, outputPath, locale: "ko" });
|
|
1131
|
+
expect(result.isError).toBeUndefined();
|
|
1132
|
+
expect(parsePayload(result)).toEqual({
|
|
1133
|
+
sessionId,
|
|
1134
|
+
format: "pdf",
|
|
1135
|
+
path: outputPath,
|
|
1136
|
+
sizeBytes: 15,
|
|
1137
|
+
});
|
|
1138
|
+
expect(readFileSync(outputPath, "utf8")).toBe("%PDF-1.3\n%%EOF\n");
|
|
1139
|
+
expect([apiKey, locale]).toEqual(["test-key", "ko"]);
|
|
1140
|
+
});
|
|
1141
|
+
it("aximo_export_session_screenshots deletes a truncated archive and reports an error", async () => {
|
|
1142
|
+
server.use(http.get(`${TEST_BASE_URL}/api/sessions/${sessionId}/export/screenshots`, () => new HttpResponse(Buffer.from("PK\x03\x04 half an archive"))));
|
|
1143
|
+
const tool = byName(defineTools(), "aximo_export_session_screenshots");
|
|
1144
|
+
const result = await tool.handler({ sessionId, outputPath: join(dir, "shots.zip") });
|
|
1145
|
+
expect(result.isError).toBe(true);
|
|
1146
|
+
expect(parsePayload(result).error).toMatch(/truncated/);
|
|
1147
|
+
expect(readdirSync(dir)).toEqual([]);
|
|
1148
|
+
});
|
|
1149
|
+
it("passes on the API's 409 for a session without screenshots", async () => {
|
|
1150
|
+
server.use(http.get(`${TEST_BASE_URL}/api/sessions/${sessionId}/export/screenshots`, () => HttpResponse.json({ error: "Session has no screenshots to export" }, { status: 409 })));
|
|
1151
|
+
const tool = byName(defineTools(), "aximo_export_session_screenshots");
|
|
1152
|
+
const result = await tool.handler({ sessionId, outputPath: join(dir, "shots.zip") });
|
|
1153
|
+
expect(result.isError).toBe(true);
|
|
1154
|
+
expect(parsePayload(result)).toEqual({ error: "Session has no screenshots to export" });
|
|
1155
|
+
});
|
|
1156
|
+
it("rejects a relative outputPath", async () => {
|
|
1157
|
+
const tool = byName(defineTools(), "aximo_export_session_pdf");
|
|
1158
|
+
const result = await tool.handler({ sessionId, outputPath: "result.pdf" });
|
|
1159
|
+
expect(result.isError).toBe(true);
|
|
1160
|
+
expect(parsePayload(result)).toEqual({ error: "outputPath must be an absolute path" });
|
|
1161
|
+
});
|
|
1162
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type ClientDeps } from "../../client.js";
|
|
2
|
+
import type { Session } from "../../types.js";
|
|
3
|
+
export declare const TERMINAL_SESSION_STATUSES: readonly ["succeeded", "failed", "terminated", "error", "archived"];
|
|
4
|
+
export declare const WAIT_DEFAULT_SECONDS = 45;
|
|
5
|
+
export declare const WAIT_MAX_SECONDS = 90;
|
|
6
|
+
export declare const WAIT_POLL_INTERVAL_MS = 5000;
|
|
7
|
+
export interface WaitClock {
|
|
8
|
+
now(): number;
|
|
9
|
+
sleep(ms: number): Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
export declare const realClock: WaitClock;
|
|
12
|
+
export interface WaitResult {
|
|
13
|
+
done: boolean;
|
|
14
|
+
status: string | undefined;
|
|
15
|
+
waitedSeconds: number;
|
|
16
|
+
session: Session;
|
|
17
|
+
hint?: string;
|
|
18
|
+
}
|
|
19
|
+
export declare function isTerminalStatus(status: string | undefined): boolean;
|
|
20
|
+
export declare function waitForSession(sessionId: string, timeoutSeconds: number, deps?: ClientDeps, clock?: WaitClock): Promise<WaitResult>;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { request } from "../../client.js";
|
|
2
|
+
// Mirrors TERMINAL_SESSION_STATUSES in ai-tester (packages/db/src/agent-sessions.ts).
|
|
3
|
+
export const TERMINAL_SESSION_STATUSES = [
|
|
4
|
+
"succeeded",
|
|
5
|
+
"failed",
|
|
6
|
+
"terminated",
|
|
7
|
+
"error",
|
|
8
|
+
"archived",
|
|
9
|
+
];
|
|
10
|
+
// Most MCP clients cut a tool call off at 60 s; Kiro has no cutoff at all, so a
|
|
11
|
+
// call that never returns hangs the whole chat. The default stays under 60 and
|
|
12
|
+
// the ceiling gives clients without a limit a longer stretch per call.
|
|
13
|
+
export const WAIT_DEFAULT_SECONDS = 45;
|
|
14
|
+
export const WAIT_MAX_SECONDS = 90;
|
|
15
|
+
export const WAIT_POLL_INTERVAL_MS = 5_000;
|
|
16
|
+
export const realClock = {
|
|
17
|
+
now: () => Date.now(),
|
|
18
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
19
|
+
};
|
|
20
|
+
export function isTerminalStatus(status) {
|
|
21
|
+
return TERMINAL_SESSION_STATUSES.includes(status ?? "");
|
|
22
|
+
}
|
|
23
|
+
export async function waitForSession(sessionId, timeoutSeconds, deps = {}, clock = realClock) {
|
|
24
|
+
const start = clock.now();
|
|
25
|
+
const deadline = start + timeoutSeconds * 1000;
|
|
26
|
+
const path = `/api/sessions/${encodeURIComponent(sessionId)}`;
|
|
27
|
+
for (;;) {
|
|
28
|
+
const session = await request(path, {}, deps);
|
|
29
|
+
const waitedSeconds = Math.round((clock.now() - start) / 1000);
|
|
30
|
+
if (isTerminalStatus(session.status)) {
|
|
31
|
+
return { done: true, status: session.status, waitedSeconds, session };
|
|
32
|
+
}
|
|
33
|
+
// Stop when the next poll would land past the deadline, so the call
|
|
34
|
+
// returns within timeoutSeconds even when a poll itself is slow.
|
|
35
|
+
if (clock.now() + WAIT_POLL_INTERVAL_MS > deadline) {
|
|
36
|
+
return {
|
|
37
|
+
done: false,
|
|
38
|
+
status: session.status,
|
|
39
|
+
waitedSeconds,
|
|
40
|
+
session,
|
|
41
|
+
hint: "Still running. Call aximo_wait_for_session again with the same sessionId.",
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
await clock.sleep(WAIT_POLL_INTERVAL_MS);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
2
|
+
import { HttpResponse, http, server, TEST_BASE_URL } from "../../test-utils.js";
|
|
3
|
+
import { isTerminalStatus, realClock, WAIT_POLL_INTERVAL_MS, waitForSession, } from "./wait.js";
|
|
4
|
+
beforeAll(() => {
|
|
5
|
+
process.env["AXIMO_API_KEY"] = "test-key";
|
|
6
|
+
process.env["AXIMO_BASE_URL"] = TEST_BASE_URL;
|
|
7
|
+
server.listen();
|
|
8
|
+
});
|
|
9
|
+
afterEach(() => server.resetHandlers());
|
|
10
|
+
afterAll(() => server.close());
|
|
11
|
+
const sessionId = "aaaaaaaa-aaaa-4aaa-9aaa-aaaaaaaaaaaa";
|
|
12
|
+
// A clock where sleep advances time instantly, so the poll loop runs without waiting.
|
|
13
|
+
function fakeClock() {
|
|
14
|
+
let t = 1_000_000;
|
|
15
|
+
const sleeps = [];
|
|
16
|
+
return {
|
|
17
|
+
sleeps,
|
|
18
|
+
now: () => t,
|
|
19
|
+
sleep: async (ms) => {
|
|
20
|
+
sleeps.push(ms);
|
|
21
|
+
t += ms;
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function statusSequence(statuses) {
|
|
26
|
+
let calls = 0;
|
|
27
|
+
server.use(http.get(`${TEST_BASE_URL}/api/sessions/${sessionId}`, () => {
|
|
28
|
+
const status = statuses[Math.min(calls, statuses.length - 1)];
|
|
29
|
+
calls += 1;
|
|
30
|
+
return HttpResponse.json({ id: sessionId, status });
|
|
31
|
+
}));
|
|
32
|
+
return () => calls;
|
|
33
|
+
}
|
|
34
|
+
describe("isTerminalStatus", () => {
|
|
35
|
+
it("matches the five final statuses and nothing else", () => {
|
|
36
|
+
for (const s of ["succeeded", "failed", "terminated", "error", "archived"]) {
|
|
37
|
+
expect(isTerminalStatus(s)).toBe(true);
|
|
38
|
+
}
|
|
39
|
+
for (const s of ["queued", "created", "preparing", "running", undefined]) {
|
|
40
|
+
expect(isTerminalStatus(s)).toBe(false);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
describe("waitForSession", () => {
|
|
45
|
+
it("polls every 5 s and returns done once the status is final", async () => {
|
|
46
|
+
const calls = statusSequence(["running", "running", "succeeded"]);
|
|
47
|
+
const clock = fakeClock();
|
|
48
|
+
const result = await waitForSession(sessionId, 60, {}, clock);
|
|
49
|
+
expect(result.done).toBe(true);
|
|
50
|
+
expect(result.status).toBe("succeeded");
|
|
51
|
+
expect(result.waitedSeconds).toBe(10);
|
|
52
|
+
expect(result.session).toEqual({ id: sessionId, status: "succeeded" });
|
|
53
|
+
expect(result.hint).toBeUndefined();
|
|
54
|
+
expect(calls()).toBe(3);
|
|
55
|
+
expect(clock.sleeps).toEqual([WAIT_POLL_INTERVAL_MS, WAIT_POLL_INTERVAL_MS]);
|
|
56
|
+
});
|
|
57
|
+
it("returns done=false before the deadline instead of overrunning it", async () => {
|
|
58
|
+
const calls = statusSequence(["running"]);
|
|
59
|
+
const clock = fakeClock();
|
|
60
|
+
// 7 s: poll at 0 s, sleep to 5 s, poll, then 5 + 5 > 7 so stop.
|
|
61
|
+
const result = await waitForSession(sessionId, 7, {}, clock);
|
|
62
|
+
expect(result.done).toBe(false);
|
|
63
|
+
expect(result.status).toBe("running");
|
|
64
|
+
expect(result.waitedSeconds).toBe(5);
|
|
65
|
+
expect(result.hint).toMatch(/call aximo_wait_for_session again/i);
|
|
66
|
+
expect(calls()).toBe(2);
|
|
67
|
+
});
|
|
68
|
+
it("polls once and returns when the timeout is shorter than one interval", async () => {
|
|
69
|
+
const calls = statusSequence(["queued"]);
|
|
70
|
+
const clock = fakeClock();
|
|
71
|
+
const result = await waitForSession(sessionId, 1, {}, clock);
|
|
72
|
+
expect(result.done).toBe(false);
|
|
73
|
+
expect(result.status).toBe("queued");
|
|
74
|
+
expect(calls()).toBe(1);
|
|
75
|
+
expect(clock.sleeps).toEqual([]);
|
|
76
|
+
});
|
|
77
|
+
it("lets API errors through untouched so the tool layer can shape them", async () => {
|
|
78
|
+
server.use(http.get(`${TEST_BASE_URL}/api/sessions/${sessionId}`, () => HttpResponse.json({ error: "Session not found" }, { status: 404 })));
|
|
79
|
+
await expect(waitForSession(sessionId, 10, {}, fakeClock())).rejects.toMatchObject({
|
|
80
|
+
status: 404,
|
|
81
|
+
message: "Session not found",
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
describe("realClock", () => {
|
|
86
|
+
it("sleeps for at least the requested time", async () => {
|
|
87
|
+
const before = realClock.now();
|
|
88
|
+
await realClock.sleep(20);
|
|
89
|
+
expect(realClock.now() - before).toBeGreaterThanOrEqual(15);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -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;
|