@autifyhq/aximo-cli 0.11.1 → 0.13.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 +1 -1
- package/dist/commands/mcp/server.test.js +4 -1
- package/dist/commands/mcp/tools.js +154 -9
- package/dist/commands/mcp/tools.test.js +258 -4
- package/dist/types.d.ts +63 -5
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -660,7 +660,7 @@ claude mcp add aximo -- aximo mcp
|
|
|
660
660
|
|
|
661
661
|
VS Code (Copilot agent mode) uses the same entry under a `servers` key in `.vscode/mcp.json`.
|
|
662
662
|
|
|
663
|
-
The server exposes
|
|
663
|
+
The server exposes 24 tools: 14 read-only ones for inspecting sessions, cases, plans, and plan runs (`aximo_get_session_timeline` is the load-bearing one for Playwright export; `aximo_list_plans` and `aximo_get_plan` cover plan discovery; `aximo_list_plan_runs` and `aximo_get_plan_run` cover run-status polling), 8 that author content (create, update, and delete cases, create and update plans, save a session as a case), and 2 that trigger runs (`aximo_run_case` starts a case run and returns the `sessionId`; `aximo_run_plan` starts a plan run and returns the `planRunId` — both spend organization credits and require the case or plan to be `apiEnabled`). See the [MCP reference](./skills/aximo-cli/references/mcp.md) for per-client config paths, the full tool table, the Playwright export prompt, and a no-client smoke test.
|
|
664
664
|
|
|
665
665
|
## Agent Skills
|
|
666
666
|
|
|
@@ -24,7 +24,7 @@ async function connect() {
|
|
|
24
24
|
};
|
|
25
25
|
}
|
|
26
26
|
describe("createMcpServer wiring", () => {
|
|
27
|
-
it("exposes all
|
|
27
|
+
it("exposes all 24 tools through tools/list with valid JSON Schema", async () => {
|
|
28
28
|
const { client, cleanup } = await connect();
|
|
29
29
|
try {
|
|
30
30
|
const { tools } = await client.listTools();
|
|
@@ -40,13 +40,16 @@ describe("createMcpServer wiring", () => {
|
|
|
40
40
|
"aximo_list_projects",
|
|
41
41
|
"aximo_list_sessions",
|
|
42
42
|
"aximo_create_case",
|
|
43
|
+
"aximo_update_case",
|
|
43
44
|
"aximo_delete_case",
|
|
45
|
+
"aximo_run_case",
|
|
44
46
|
"aximo_create_plan",
|
|
45
47
|
"aximo_add_case_to_plan",
|
|
46
48
|
"aximo_remove_case_from_plan",
|
|
47
49
|
"aximo_list_plans",
|
|
48
50
|
"aximo_get_plan",
|
|
49
51
|
"aximo_update_plan",
|
|
52
|
+
"aximo_run_plan",
|
|
50
53
|
"aximo_list_plan_runs",
|
|
51
54
|
"aximo_get_plan_run",
|
|
52
55
|
"aximo_save_session_as_case",
|
|
@@ -31,8 +31,12 @@ async function call(fn) {
|
|
|
31
31
|
}
|
|
32
32
|
if (status === 404)
|
|
33
33
|
return err(e.message || "Not found");
|
|
34
|
-
if (status === 429)
|
|
35
|
-
|
|
34
|
+
if (status === 429) {
|
|
35
|
+
const retryAfter = e.retryAfter;
|
|
36
|
+
return err("Rate limited", retryAfter
|
|
37
|
+
? `Back off and retry after ${retryAfter} seconds.`
|
|
38
|
+
: "Back off before retrying; Aximo rate-limits requests per organization.");
|
|
39
|
+
}
|
|
36
40
|
if (typeof status === "number")
|
|
37
41
|
return err(`HTTP ${status}: ${e.message}`);
|
|
38
42
|
if (e.message.startsWith("No API key configured")) {
|
|
@@ -54,6 +58,18 @@ function buildQuery(params) {
|
|
|
54
58
|
}
|
|
55
59
|
return out;
|
|
56
60
|
}
|
|
61
|
+
// Mobile app selection accepted by the run triggers (multi-app, max 3).
|
|
62
|
+
// ARN fields are the encrypted tokens Aximo returns, never raw AWS ARNs.
|
|
63
|
+
const runAppsSchema = z
|
|
64
|
+
.array(z.object({
|
|
65
|
+
uploadArn: z
|
|
66
|
+
.string()
|
|
67
|
+
.describe("Encrypted upload token (from aximo_get_case config or `aximo upload`)."),
|
|
68
|
+
uploadType: z.enum(["own_app", "existing", "sample"]),
|
|
69
|
+
appName: z.string().nullable(),
|
|
70
|
+
bundleId: z.string().optional(),
|
|
71
|
+
}))
|
|
72
|
+
.max(3);
|
|
57
73
|
export function defineTools(deps = {}) {
|
|
58
74
|
return [
|
|
59
75
|
{
|
|
@@ -186,7 +202,7 @@ export function defineTools(deps = {}) {
|
|
|
186
202
|
{
|
|
187
203
|
name: "aximo_get_case",
|
|
188
204
|
title: "Get test case",
|
|
189
|
-
description: "Get the full scenario text and metadata for a saved test case. Useful for grounding generated Playwright specs.",
|
|
205
|
+
description: "Get the full scenario text and metadata for a saved test case, including its per-target default configuration (mobile, web hostname overrides, desktop, multi-device) — the run trigger's per-run override fields supersede those defaults. Useful for grounding generated Playwright specs.",
|
|
190
206
|
inputSchema: {
|
|
191
207
|
caseId: z.string().uuid(),
|
|
192
208
|
},
|
|
@@ -201,17 +217,37 @@ export function defineTools(deps = {}) {
|
|
|
201
217
|
inputSchema: {
|
|
202
218
|
projectId: z.string().uuid(),
|
|
203
219
|
scenario: z.string().min(1),
|
|
204
|
-
type: z.enum(["web", "mobile", "desktop"]).optional(),
|
|
220
|
+
type: z.enum(["web", "mobile", "desktop", "multi_device"]).optional(),
|
|
205
221
|
title: z.string().min(1).max(500).optional(),
|
|
206
222
|
description: z.string().optional(),
|
|
207
223
|
format: z.enum(["gherkin", "markdown", "plain_text"]).optional(),
|
|
208
|
-
startUrl: z.string().url().optional(),
|
|
224
|
+
startUrl: z.string().url().nullable().optional(),
|
|
209
225
|
directoryId: z.string().uuid().optional(),
|
|
210
226
|
},
|
|
211
227
|
handler: async (input) => call(async () => {
|
|
212
228
|
return await request("/api/cases", { method: "POST", body: input }, deps);
|
|
213
229
|
}),
|
|
214
230
|
},
|
|
231
|
+
{
|
|
232
|
+
name: "aximo_update_case",
|
|
233
|
+
title: "Update test case",
|
|
234
|
+
description: "Update a test case's title, description, scenario, type, format, or startUrl (pass null to clear startUrl). Omitted fields are left unchanged; at least one field is required. Per-target device configuration cannot be changed over the API, and changing type does not create or clear it (e.g. switching to mobile leaves apiMobileConfig null until set in the dashboard).",
|
|
235
|
+
inputSchema: {
|
|
236
|
+
caseId: z.string().uuid(),
|
|
237
|
+
title: z.string().min(1).max(500).optional(),
|
|
238
|
+
description: z.string().optional(),
|
|
239
|
+
scenario: z.string().min(1).optional(),
|
|
240
|
+
type: z.enum(["web", "mobile", "desktop", "multi_device"]).optional(),
|
|
241
|
+
format: z.enum(["gherkin", "markdown", "plain_text"]).optional(),
|
|
242
|
+
startUrl: z.string().url().nullable().optional(),
|
|
243
|
+
},
|
|
244
|
+
handler: async ({ caseId, ...body }) => call(async () => {
|
|
245
|
+
if (Object.keys(body).length === 0) {
|
|
246
|
+
throw new Error("at least one field to update is required");
|
|
247
|
+
}
|
|
248
|
+
return await request(`/api/cases/${encodeURIComponent(caseId)}`, { method: "PATCH", body }, deps);
|
|
249
|
+
}),
|
|
250
|
+
},
|
|
215
251
|
{
|
|
216
252
|
name: "aximo_delete_case",
|
|
217
253
|
title: "Delete test case",
|
|
@@ -223,6 +259,45 @@ export function defineTools(deps = {}) {
|
|
|
223
259
|
return await request(`/api/cases/${encodeURIComponent(caseId)}`, { method: "DELETE" }, deps);
|
|
224
260
|
}),
|
|
225
261
|
},
|
|
262
|
+
{
|
|
263
|
+
name: "aximo_run_case",
|
|
264
|
+
title: "Run test case",
|
|
265
|
+
description: "Start a run of a saved test case and return the new sessionId. The case must be apiEnabled (aximo_get_case shows this). All fields except caseId are per-run overrides of the case's stored per-target configuration: omit them to run with the saved defaults. Poll aximo_get_session with the returned sessionId to follow the run; wait at least 5 seconds between polls.",
|
|
266
|
+
inputSchema: {
|
|
267
|
+
caseId: z.string().uuid(),
|
|
268
|
+
model: z
|
|
269
|
+
.string()
|
|
270
|
+
.optional()
|
|
271
|
+
.describe("Agent model override (e.g. 'sonnet', 'haiku', 'auto'). Server-validated."),
|
|
272
|
+
hostnameOverrideIds: z
|
|
273
|
+
.array(z.string().uuid())
|
|
274
|
+
.optional()
|
|
275
|
+
.describe("Web cases: hostname override ids to apply for this run."),
|
|
276
|
+
userDeviceId: z
|
|
277
|
+
.string()
|
|
278
|
+
.uuid()
|
|
279
|
+
.optional()
|
|
280
|
+
.describe("A user-owned (BYOD) device id; overrides the device bound on the case."),
|
|
281
|
+
deviceArn: z
|
|
282
|
+
.string()
|
|
283
|
+
.optional()
|
|
284
|
+
.describe("Mobile cases: encrypted device token; overrides the saved device."),
|
|
285
|
+
deviceName: z.string().optional(),
|
|
286
|
+
platform: z.enum(["ANDROID", "IOS"]).optional(),
|
|
287
|
+
uploadArn: z
|
|
288
|
+
.string()
|
|
289
|
+
.optional()
|
|
290
|
+
.describe("Mobile cases: encrypted app upload token; overrides the saved app."),
|
|
291
|
+
uploadType: z.enum(["own_app", "existing", "sample"]).optional(),
|
|
292
|
+
appName: z.string().optional(),
|
|
293
|
+
apps: runAppsSchema
|
|
294
|
+
.optional()
|
|
295
|
+
.describe("Mobile multi-app runs: up to 3 apps to install; overrides the saved apps and takes precedence over the single-app uploadArn/uploadType/appName fields."),
|
|
296
|
+
},
|
|
297
|
+
handler: async ({ caseId, ...body }) => call(async () => {
|
|
298
|
+
return await request(`/api/triggers/cases/${encodeURIComponent(caseId)}/run`, { method: "POST", body }, deps);
|
|
299
|
+
}),
|
|
300
|
+
},
|
|
226
301
|
{
|
|
227
302
|
name: "aximo_create_plan",
|
|
228
303
|
title: "Create test plan",
|
|
@@ -303,20 +378,90 @@ export function defineTools(deps = {}) {
|
|
|
303
378
|
return await request(`/api/plans/${encodeURIComponent(planId)}`, { method: "PATCH", body }, deps);
|
|
304
379
|
}),
|
|
305
380
|
},
|
|
381
|
+
{
|
|
382
|
+
name: "aximo_run_plan",
|
|
383
|
+
title: "Run test plan",
|
|
384
|
+
description: "Start a run of a test plan (all its cases) and return the new planRunId, totalCases, and the effective executionMode. The plan must be apiEnabled (aximo_get_plan shows this). All fields except planId are per-run overrides of the plan's stored settings: omit them to run with the saved defaults. Poll aximo_get_plan_run with the returned planRunId to follow the run; wait at least 5 seconds between polls.",
|
|
385
|
+
inputSchema: {
|
|
386
|
+
planId: z.string().uuid(),
|
|
387
|
+
model: z
|
|
388
|
+
.string()
|
|
389
|
+
.optional()
|
|
390
|
+
.describe("Agent model override (e.g. 'sonnet', 'haiku', 'auto'). Server-validated."),
|
|
391
|
+
executionMode: z
|
|
392
|
+
.enum(["parallel", "sequential"])
|
|
393
|
+
.optional()
|
|
394
|
+
.describe("Override the plan's stored Execution setting for this run only."),
|
|
395
|
+
mobileConfig: z
|
|
396
|
+
.object({
|
|
397
|
+
deviceArn: z.string().describe("Encrypted device token."),
|
|
398
|
+
deviceName: z.string(),
|
|
399
|
+
platform: z.enum(["ANDROID", "IOS"]).optional(),
|
|
400
|
+
uploadArn: z.string().optional().describe("Encrypted app upload token."),
|
|
401
|
+
uploadType: z.enum(["own_app", "existing", "sample"]).optional(),
|
|
402
|
+
appName: z.string().optional(),
|
|
403
|
+
apps: runAppsSchema
|
|
404
|
+
.optional()
|
|
405
|
+
.describe("Takes precedence over the single-app uploadArn/uploadType/appName fields."),
|
|
406
|
+
})
|
|
407
|
+
.optional()
|
|
408
|
+
.describe("Device + app for the plan's mobile cases; overrides the plan's stored config."),
|
|
409
|
+
desktopConfig: z
|
|
410
|
+
.object({
|
|
411
|
+
devices: z.array(z.object({ userDeviceId: z.string().uuid() })).min(1),
|
|
412
|
+
})
|
|
413
|
+
.optional()
|
|
414
|
+
.describe("User-owned (BYOD) desktop devices to run desktop cases on; overrides the plan's stored desktop config."),
|
|
415
|
+
variables: z
|
|
416
|
+
.array(z.object({
|
|
417
|
+
name: z
|
|
418
|
+
.string()
|
|
419
|
+
.describe("Uppercase letters, digits, underscores; must start with a letter."),
|
|
420
|
+
value: z.string(),
|
|
421
|
+
}))
|
|
422
|
+
.optional()
|
|
423
|
+
.describe("Per-run variable values merged over the plan's stored variables by name; never persisted on the plan."),
|
|
424
|
+
},
|
|
425
|
+
handler: async ({ planId, ...body }) => call(async () => {
|
|
426
|
+
return await request(`/api/triggers/plans/${encodeURIComponent(planId)}/run`, { method: "POST", body }, deps);
|
|
427
|
+
}),
|
|
428
|
+
},
|
|
306
429
|
{
|
|
307
430
|
name: "aximo_list_plan_runs",
|
|
308
431
|
title: "List plan runs",
|
|
309
|
-
description: "List
|
|
432
|
+
description: "List plan runs, newest first, without per-case detail. Pass planId for one plan's runs, or projectId for run history across every plan in a workspace (each item then carries its plan's id and title); exactly one of the two is required, and status filtering is available with projectId. Use aximo_get_plan_run for one run's per-case breakdown. Paginate by passing the previous page's nextCursor as cursor. A run's status reflects execution, not the test verdict: read passedCases/failedCases to judge outcomes.",
|
|
310
433
|
inputSchema: {
|
|
311
|
-
planId: z
|
|
434
|
+
planId: z
|
|
435
|
+
.string()
|
|
436
|
+
.uuid()
|
|
437
|
+
.optional()
|
|
438
|
+
.describe("Exactly one of planId or projectId is required."),
|
|
439
|
+
projectId: z
|
|
440
|
+
.string()
|
|
441
|
+
.uuid()
|
|
442
|
+
.optional()
|
|
443
|
+
.describe("Exactly one of planId or projectId is required."),
|
|
444
|
+
status: z
|
|
445
|
+
.enum(["created", "running", "completed", "failed", "cancelled"])
|
|
446
|
+
.optional()
|
|
447
|
+
.describe("Only with projectId: restrict to runs with this execution status."),
|
|
312
448
|
limit: z.number().int().positive().max(50).optional(),
|
|
313
449
|
cursor: z
|
|
314
450
|
.string()
|
|
315
451
|
.optional()
|
|
316
452
|
.describe("nextCursor from the previous page (ISO 8601 createdAt of its last run)."),
|
|
317
453
|
},
|
|
318
|
-
handler: async ({ planId, ...rest }) => call(async () => {
|
|
319
|
-
|
|
454
|
+
handler: async ({ planId, projectId, status, ...rest }) => call(async () => {
|
|
455
|
+
if (!planId === !projectId) {
|
|
456
|
+
throw new Error("pass exactly one of planId or projectId");
|
|
457
|
+
}
|
|
458
|
+
if (planId) {
|
|
459
|
+
if (status) {
|
|
460
|
+
throw new Error("the status filter requires projectId");
|
|
461
|
+
}
|
|
462
|
+
return await request(`/api/triggers/plans/${encodeURIComponent(planId)}/runs`, { query: buildQuery(rest) }, deps);
|
|
463
|
+
}
|
|
464
|
+
return await request("/api/plan-runs", { query: buildQuery({ projectId, status, ...rest }) }, deps);
|
|
320
465
|
}),
|
|
321
466
|
},
|
|
322
467
|
{
|
|
@@ -32,13 +32,16 @@ describe("defineTools", () => {
|
|
|
32
32
|
"aximo_list_cases",
|
|
33
33
|
"aximo_get_case",
|
|
34
34
|
"aximo_create_case",
|
|
35
|
+
"aximo_update_case",
|
|
35
36
|
"aximo_delete_case",
|
|
37
|
+
"aximo_run_case",
|
|
36
38
|
"aximo_create_plan",
|
|
37
39
|
"aximo_add_case_to_plan",
|
|
38
40
|
"aximo_remove_case_from_plan",
|
|
39
41
|
"aximo_list_plans",
|
|
40
42
|
"aximo_get_plan",
|
|
41
43
|
"aximo_update_plan",
|
|
44
|
+
"aximo_run_plan",
|
|
42
45
|
"aximo_list_plan_runs",
|
|
43
46
|
"aximo_get_plan_run",
|
|
44
47
|
"aximo_save_session_as_case",
|
|
@@ -146,6 +149,131 @@ describe("aximo_list_projects", () => {
|
|
|
146
149
|
expect(new URL(captured.url).pathname).toBe("/api/organizations/projects");
|
|
147
150
|
});
|
|
148
151
|
});
|
|
152
|
+
describe("aximo_update_case", () => {
|
|
153
|
+
const caseId = "55555555-5555-4555-9555-555555555555";
|
|
154
|
+
it("PATCHes /api/cases/:id with only the provided fields", async () => {
|
|
155
|
+
let body = null;
|
|
156
|
+
const payload = {
|
|
157
|
+
success: true,
|
|
158
|
+
case: {
|
|
159
|
+
id: caseId,
|
|
160
|
+
projectId: "p1",
|
|
161
|
+
type: "web",
|
|
162
|
+
title: "Renamed",
|
|
163
|
+
description: null,
|
|
164
|
+
scenario: "Given a renamed page",
|
|
165
|
+
format: "gherkin",
|
|
166
|
+
startUrl: null,
|
|
167
|
+
updatedAt: "2026-08-14T00:00:00.000Z",
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
server.use(http.patch(`${TEST_BASE_URL}/api/cases/${caseId}`, async ({ request: req }) => {
|
|
171
|
+
body = await req.json();
|
|
172
|
+
return HttpResponse.json(payload);
|
|
173
|
+
}));
|
|
174
|
+
const tool = byName(defineTools(), "aximo_update_case");
|
|
175
|
+
const result = await tool.handler({
|
|
176
|
+
caseId,
|
|
177
|
+
title: "Renamed",
|
|
178
|
+
scenario: "Given a renamed page",
|
|
179
|
+
});
|
|
180
|
+
expect(result.isError).toBeUndefined();
|
|
181
|
+
expect(parsePayload(result)).toEqual(payload);
|
|
182
|
+
expect(body).toEqual({ title: "Renamed", scenario: "Given a renamed page" });
|
|
183
|
+
});
|
|
184
|
+
it("rejects an update with no fields instead of issuing an empty PATCH", async () => {
|
|
185
|
+
let called = false;
|
|
186
|
+
server.use(http.patch(`${TEST_BASE_URL}/api/cases/${caseId}`, () => {
|
|
187
|
+
called = true;
|
|
188
|
+
return HttpResponse.json({ success: true });
|
|
189
|
+
}));
|
|
190
|
+
const tool = byName(defineTools(), "aximo_update_case");
|
|
191
|
+
const result = await tool.handler({ caseId });
|
|
192
|
+
expect(result.isError).toBe(true);
|
|
193
|
+
expect(parsePayload(result).error).toBe("at least one field to update is required");
|
|
194
|
+
expect(called).toBe(false);
|
|
195
|
+
});
|
|
196
|
+
it("passes startUrl: null through as an explicit clear, not an empty update", async () => {
|
|
197
|
+
let body = null;
|
|
198
|
+
server.use(http.patch(`${TEST_BASE_URL}/api/cases/${caseId}`, async ({ request: req }) => {
|
|
199
|
+
body = await req.json();
|
|
200
|
+
return HttpResponse.json({ success: true });
|
|
201
|
+
}));
|
|
202
|
+
const tool = byName(defineTools(), "aximo_update_case");
|
|
203
|
+
const result = await tool.handler({ caseId, startUrl: null });
|
|
204
|
+
expect(result.isError).toBeUndefined();
|
|
205
|
+
expect(body).toEqual({ startUrl: null });
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
describe("aximo_run_case", () => {
|
|
209
|
+
const caseId = "77777777-7777-4777-9777-777777777777";
|
|
210
|
+
it("POSTs /api/triggers/cases/:id/run with the per-run overrides and returns sessionId", async () => {
|
|
211
|
+
let body = null;
|
|
212
|
+
let apiKey = null;
|
|
213
|
+
const payload = { success: true, sessionId: "88888888-8888-4888-9888-888888888888" };
|
|
214
|
+
server.use(http.post(`${TEST_BASE_URL}/api/triggers/cases/${caseId}/run`, async ({ request: req }) => {
|
|
215
|
+
apiKey = req.headers.get("x-api-key");
|
|
216
|
+
body = await req.json();
|
|
217
|
+
return HttpResponse.json(payload, { status: 201 });
|
|
218
|
+
}));
|
|
219
|
+
const tool = byName(defineTools(), "aximo_run_case");
|
|
220
|
+
const result = await tool.handler({
|
|
221
|
+
caseId,
|
|
222
|
+
model: "sonnet",
|
|
223
|
+
hostnameOverrideIds: ["99999999-9999-4999-9999-999999999999"],
|
|
224
|
+
apps: [{ uploadArn: "tok-1", uploadType: "own_app", appName: null }],
|
|
225
|
+
});
|
|
226
|
+
expect(result.isError).toBeUndefined();
|
|
227
|
+
expect(parsePayload(result)).toEqual(payload);
|
|
228
|
+
expect(apiKey).toBe("test-key");
|
|
229
|
+
expect(body).toEqual({
|
|
230
|
+
model: "sonnet",
|
|
231
|
+
hostnameOverrideIds: ["99999999-9999-4999-9999-999999999999"],
|
|
232
|
+
apps: [{ uploadArn: "tok-1", uploadType: "own_app", appName: null }],
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
it("sends an empty body when only caseId is given, so the saved config applies", async () => {
|
|
236
|
+
let body = null;
|
|
237
|
+
server.use(http.post(`${TEST_BASE_URL}/api/triggers/cases/${caseId}/run`, async ({ request: req }) => {
|
|
238
|
+
body = await req.json();
|
|
239
|
+
return HttpResponse.json({ success: true, sessionId: "s" }, { status: 201 });
|
|
240
|
+
}));
|
|
241
|
+
const tool = byName(defineTools(), "aximo_run_case");
|
|
242
|
+
const result = await tool.handler({ caseId });
|
|
243
|
+
expect(result.isError).toBeUndefined();
|
|
244
|
+
expect(body).toEqual({});
|
|
245
|
+
});
|
|
246
|
+
it("surfaces the apiEnabled rejection as the error message", async () => {
|
|
247
|
+
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 })));
|
|
248
|
+
const tool = byName(defineTools(), "aximo_run_case");
|
|
249
|
+
const result = await tool.handler({ caseId });
|
|
250
|
+
expect(result.isError).toBe(true);
|
|
251
|
+
expect(parsePayload(result).error).toBe("API access is not enabled for this case");
|
|
252
|
+
});
|
|
253
|
+
it("surfaces the insufficient-credits rejection distinctly", async () => {
|
|
254
|
+
server.use(http.post(`${TEST_BASE_URL}/api/triggers/cases/${caseId}/run`, () => HttpResponse.json({ error: "Insufficient credits to start session" }, { status: 403 })));
|
|
255
|
+
const tool = byName(defineTools(), "aximo_run_case");
|
|
256
|
+
const result = await tool.handler({ caseId });
|
|
257
|
+
expect(result.isError).toBe(true);
|
|
258
|
+
expect(parsePayload(result).error).toBe("Insufficient credits to start session");
|
|
259
|
+
});
|
|
260
|
+
it("maps a 429 to a rate-limit error an agent can back off on", async () => {
|
|
261
|
+
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" } })));
|
|
262
|
+
const tool = byName(defineTools(), "aximo_run_case");
|
|
263
|
+
const result = await tool.handler({ caseId });
|
|
264
|
+
expect(result.isError).toBe(true);
|
|
265
|
+
const payload = parsePayload(result);
|
|
266
|
+
expect(payload.error).toMatch(/Rate limited/);
|
|
267
|
+
expect(payload.hint).toBe("Back off and retry after 60 seconds.");
|
|
268
|
+
});
|
|
269
|
+
it("returns the API's 404 for a case outside the organization without leaking existence", async () => {
|
|
270
|
+
server.use(http.post(`${TEST_BASE_URL}/api/triggers/cases/${caseId}/run`, () => HttpResponse.json({ error: "Test case not found" }, { status: 404 })));
|
|
271
|
+
const tool = byName(defineTools(), "aximo_run_case");
|
|
272
|
+
const result = await tool.handler({ caseId });
|
|
273
|
+
expect(result.isError).toBe(true);
|
|
274
|
+
expect(parsePayload(result).error).toBe("Test case not found");
|
|
275
|
+
});
|
|
276
|
+
});
|
|
149
277
|
describe("aximo_list_plans", () => {
|
|
150
278
|
const projectId = "44444444-4444-4444-8444-444444444444";
|
|
151
279
|
it("calls /api/plans, passing filters through as query params", async () => {
|
|
@@ -247,6 +375,63 @@ describe("aximo_update_plan", () => {
|
|
|
247
375
|
expect(called).toBe(false);
|
|
248
376
|
});
|
|
249
377
|
});
|
|
378
|
+
describe("aximo_run_plan", () => {
|
|
379
|
+
const planId = "aaaaaaaa-aaaa-4aaa-9aaa-aaaaaaaaaaaa";
|
|
380
|
+
it("POSTs /api/triggers/plans/:id/run with the per-run overrides and returns planRunId", async () => {
|
|
381
|
+
let body = null;
|
|
382
|
+
const payload = {
|
|
383
|
+
success: true,
|
|
384
|
+
planRunId: "bbbbbbbb-bbbb-4bbb-9bbb-bbbbbbbbbbbb",
|
|
385
|
+
totalCases: 3,
|
|
386
|
+
executionMode: "sequential",
|
|
387
|
+
};
|
|
388
|
+
server.use(http.post(`${TEST_BASE_URL}/api/triggers/plans/${planId}/run`, async ({ request: req }) => {
|
|
389
|
+
body = await req.json();
|
|
390
|
+
return HttpResponse.json(payload, { status: 201 });
|
|
391
|
+
}));
|
|
392
|
+
const tool = byName(defineTools(), "aximo_run_plan");
|
|
393
|
+
const result = await tool.handler({
|
|
394
|
+
planId,
|
|
395
|
+
executionMode: "sequential",
|
|
396
|
+
mobileConfig: { deviceArn: "tok-device", deviceName: "Pixel 8", platform: "ANDROID" },
|
|
397
|
+
desktopConfig: { devices: [{ userDeviceId: "cccccccc-cccc-4ccc-9ccc-cccccccccccc" }] },
|
|
398
|
+
variables: [{ name: "BASE_URL", value: "https://staging.example.com" }],
|
|
399
|
+
});
|
|
400
|
+
expect(result.isError).toBeUndefined();
|
|
401
|
+
expect(parsePayload(result)).toEqual(payload);
|
|
402
|
+
expect(body).toEqual({
|
|
403
|
+
executionMode: "sequential",
|
|
404
|
+
mobileConfig: { deviceArn: "tok-device", deviceName: "Pixel 8", platform: "ANDROID" },
|
|
405
|
+
desktopConfig: { devices: [{ userDeviceId: "cccccccc-cccc-4ccc-9ccc-cccccccccccc" }] },
|
|
406
|
+
variables: [{ name: "BASE_URL", value: "https://staging.example.com" }],
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
it("sends an empty body when only planId is given, so the plan's stored settings apply", async () => {
|
|
410
|
+
let body = null;
|
|
411
|
+
server.use(http.post(`${TEST_BASE_URL}/api/triggers/plans/${planId}/run`, async ({ request: req }) => {
|
|
412
|
+
body = await req.json();
|
|
413
|
+
return HttpResponse.json({ success: true, planRunId: "r", totalCases: 1 }, { status: 201 });
|
|
414
|
+
}));
|
|
415
|
+
const tool = byName(defineTools(), "aximo_run_plan");
|
|
416
|
+
const result = await tool.handler({ planId });
|
|
417
|
+
expect(result.isError).toBeUndefined();
|
|
418
|
+
expect(body).toEqual({});
|
|
419
|
+
});
|
|
420
|
+
it("surfaces the apiEnabled rejection as the error message", async () => {
|
|
421
|
+
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 })));
|
|
422
|
+
const tool = byName(defineTools(), "aximo_run_plan");
|
|
423
|
+
const result = await tool.handler({ planId });
|
|
424
|
+
expect(result.isError).toBe(true);
|
|
425
|
+
expect(parsePayload(result).error).toBe("API access is not enabled for this plan");
|
|
426
|
+
});
|
|
427
|
+
it("returns the API's 404 for a plan outside the organization without leaking existence", async () => {
|
|
428
|
+
server.use(http.post(`${TEST_BASE_URL}/api/triggers/plans/${planId}/run`, () => HttpResponse.json({ error: "Test plan not found" }, { status: 404 })));
|
|
429
|
+
const tool = byName(defineTools(), "aximo_run_plan");
|
|
430
|
+
const result = await tool.handler({ planId });
|
|
431
|
+
expect(result.isError).toBe(true);
|
|
432
|
+
expect(parsePayload(result).error).toBe("Test plan not found");
|
|
433
|
+
});
|
|
434
|
+
});
|
|
250
435
|
describe("aximo_list_plan_runs", () => {
|
|
251
436
|
const planId = "77777777-7777-4777-9777-777777777777";
|
|
252
437
|
it("calls /api/triggers/plans/:id/runs, passing limit and cursor through", async () => {
|
|
@@ -255,7 +440,7 @@ describe("aximo_list_plan_runs", () => {
|
|
|
255
440
|
items: [
|
|
256
441
|
{
|
|
257
442
|
id: "88888888-8888-4888-9888-888888888888",
|
|
258
|
-
status: "
|
|
443
|
+
status: "completed",
|
|
259
444
|
triggerSource: "api",
|
|
260
445
|
executionMode: "parallel",
|
|
261
446
|
totalCases: 3,
|
|
@@ -292,6 +477,66 @@ describe("aximo_list_plan_runs", () => {
|
|
|
292
477
|
expect(result.isError).toBe(true);
|
|
293
478
|
expect(parsePayload(result).error).toBe("Test plan not found");
|
|
294
479
|
});
|
|
480
|
+
it("calls /api/plan-runs when given a projectId, passing status through", async () => {
|
|
481
|
+
const projectId = "44444444-4444-4444-8444-444444444444";
|
|
482
|
+
let url = null;
|
|
483
|
+
const payload = {
|
|
484
|
+
items: [
|
|
485
|
+
{
|
|
486
|
+
id: "88888888-8888-4888-9888-888888888888",
|
|
487
|
+
planId,
|
|
488
|
+
plan: { id: planId, title: "Nightly regression" },
|
|
489
|
+
status: "failed",
|
|
490
|
+
triggerSource: "api",
|
|
491
|
+
executionMode: "parallel",
|
|
492
|
+
totalCases: 3,
|
|
493
|
+
completedCases: 3,
|
|
494
|
+
passedCases: 2,
|
|
495
|
+
failedCases: 1,
|
|
496
|
+
startedAt: "2026-08-01T00:00:00.000Z",
|
|
497
|
+
completedAt: "2026-08-01T00:10:00.000Z",
|
|
498
|
+
createdAt: "2026-08-01T00:00:00.000Z",
|
|
499
|
+
},
|
|
500
|
+
],
|
|
501
|
+
nextCursor: "2026-08-01T00:00:00.000Z",
|
|
502
|
+
};
|
|
503
|
+
server.use(http.get(`${TEST_BASE_URL}/api/plan-runs`, ({ request: req }) => {
|
|
504
|
+
url = new URL(req.url);
|
|
505
|
+
return HttpResponse.json(payload);
|
|
506
|
+
}));
|
|
507
|
+
const tool = byName(defineTools(), "aximo_list_plan_runs");
|
|
508
|
+
const result = await tool.handler({
|
|
509
|
+
projectId,
|
|
510
|
+
status: "failed",
|
|
511
|
+
limit: 5,
|
|
512
|
+
cursor: "2026-08-02T00:00:00.000Z",
|
|
513
|
+
});
|
|
514
|
+
expect(result.isError).toBeUndefined();
|
|
515
|
+
expect(parsePayload(result)).toEqual(payload);
|
|
516
|
+
expect(url).not.toBeNull();
|
|
517
|
+
expect(url.searchParams.get("projectId")).toBe(projectId);
|
|
518
|
+
expect(url.searchParams.get("status")).toBe("failed");
|
|
519
|
+
expect(url.searchParams.get("limit")).toBe("5");
|
|
520
|
+
expect(url.searchParams.get("cursor")).toBe("2026-08-02T00:00:00.000Z");
|
|
521
|
+
});
|
|
522
|
+
it("rejects calls with both, or neither, of planId and projectId", async () => {
|
|
523
|
+
const tool = byName(defineTools(), "aximo_list_plan_runs");
|
|
524
|
+
const both = await tool.handler({
|
|
525
|
+
planId,
|
|
526
|
+
projectId: "44444444-4444-4444-8444-444444444444",
|
|
527
|
+
});
|
|
528
|
+
expect(both.isError).toBe(true);
|
|
529
|
+
expect(parsePayload(both).error).toBe("pass exactly one of planId or projectId");
|
|
530
|
+
const neither = await tool.handler({});
|
|
531
|
+
expect(neither.isError).toBe(true);
|
|
532
|
+
expect(parsePayload(neither).error).toBe("pass exactly one of planId or projectId");
|
|
533
|
+
});
|
|
534
|
+
it("rejects the status filter without projectId", async () => {
|
|
535
|
+
const tool = byName(defineTools(), "aximo_list_plan_runs");
|
|
536
|
+
const result = await tool.handler({ planId, status: "failed" });
|
|
537
|
+
expect(result.isError).toBe(true);
|
|
538
|
+
expect(parsePayload(result).error).toBe("the status filter requires projectId");
|
|
539
|
+
});
|
|
295
540
|
});
|
|
296
541
|
describe("aximo_get_plan_run", () => {
|
|
297
542
|
const planId = "77777777-7777-4777-9777-777777777777";
|
|
@@ -299,7 +544,7 @@ describe("aximo_get_plan_run", () => {
|
|
|
299
544
|
it("calls /api/triggers/plans/:id/runs/:runId and returns the run with cases", async () => {
|
|
300
545
|
const payload = {
|
|
301
546
|
id: planRunId,
|
|
302
|
-
status: "
|
|
547
|
+
status: "completed",
|
|
303
548
|
triggerSource: "api",
|
|
304
549
|
executionMode: "sequential",
|
|
305
550
|
totalCases: 1,
|
|
@@ -354,14 +599,23 @@ describe("call() error mapping", () => {
|
|
|
354
599
|
const payload = parsePayload(result);
|
|
355
600
|
expect(payload.error).toBe("Session was archived");
|
|
356
601
|
});
|
|
357
|
-
it("flags 429 with
|
|
602
|
+
it("flags 429 with a back-off hint when no Retry-After header is present", async () => {
|
|
358
603
|
server.use(http.get(`${TEST_BASE_URL}/api/sessions/${sessionId}/timeline`, () => HttpResponse.json({ error: "Too many requests" }, { status: 429 })));
|
|
359
604
|
const tool = byName(defineTools(), "aximo_get_session_timeline");
|
|
360
605
|
const result = await tool.handler({ sessionId });
|
|
361
606
|
expect(result.isError).toBe(true);
|
|
362
607
|
const payload = parsePayload(result);
|
|
363
608
|
expect(payload.error).toBe("Rate limited");
|
|
364
|
-
expect(payload.hint).toMatch(/
|
|
609
|
+
expect(payload.hint).toMatch(/back off/i);
|
|
610
|
+
});
|
|
611
|
+
it("carries the Retry-After value into the 429 hint so an agent knows how long to wait", async () => {
|
|
612
|
+
server.use(http.get(`${TEST_BASE_URL}/api/sessions/${sessionId}/timeline`, () => HttpResponse.json({ error: "Too many requests" }, { status: 429, headers: { "Retry-After": "42" } })));
|
|
613
|
+
const tool = byName(defineTools(), "aximo_get_session_timeline");
|
|
614
|
+
const result = await tool.handler({ sessionId });
|
|
615
|
+
expect(result.isError).toBe(true);
|
|
616
|
+
const payload = parsePayload(result);
|
|
617
|
+
expect(payload.error).toBe("Rate limited");
|
|
618
|
+
expect(payload.hint).toBe("Back off and retry after 42 seconds.");
|
|
365
619
|
});
|
|
366
620
|
it("distinguishes a 500 with the HTTP-prefixed message", async () => {
|
|
367
621
|
server.use(http.get(`${TEST_BASE_URL}/api/sessions/${sessionId}/timeline`, () => HttpResponse.json({ error: "Internal server error" }, { status: 500 })));
|
package/dist/types.d.ts
CHANGED
|
@@ -161,7 +161,7 @@ export interface PlanDetail {
|
|
|
161
161
|
export interface PlanRunSummary {
|
|
162
162
|
id: string;
|
|
163
163
|
status: string;
|
|
164
|
-
triggerSource: string
|
|
164
|
+
triggerSource: string;
|
|
165
165
|
executionMode: string;
|
|
166
166
|
totalCases: number;
|
|
167
167
|
completedCases: number;
|
|
@@ -171,8 +171,15 @@ export interface PlanRunSummary {
|
|
|
171
171
|
completedAt: string | null;
|
|
172
172
|
createdAt: string;
|
|
173
173
|
}
|
|
174
|
-
export interface
|
|
175
|
-
|
|
174
|
+
export interface ProjectPlanRunSummary extends PlanRunSummary {
|
|
175
|
+
planId: string;
|
|
176
|
+
plan: {
|
|
177
|
+
id: string;
|
|
178
|
+
title: string;
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
export interface PlanRunList<T = PlanRunSummary> {
|
|
182
|
+
items: T[];
|
|
176
183
|
nextCursor?: string | undefined;
|
|
177
184
|
}
|
|
178
185
|
export interface Device {
|
|
@@ -236,11 +243,11 @@ export interface UpdateCaseResult {
|
|
|
236
243
|
case: {
|
|
237
244
|
id: string;
|
|
238
245
|
projectId: string;
|
|
239
|
-
type:
|
|
246
|
+
type: CaseType;
|
|
240
247
|
title: string;
|
|
241
248
|
description: string | null;
|
|
242
249
|
scenario: string;
|
|
243
|
-
format:
|
|
250
|
+
format: CaseFormat;
|
|
244
251
|
startUrl: string | null;
|
|
245
252
|
updatedAt: string;
|
|
246
253
|
};
|
|
@@ -366,6 +373,53 @@ export interface SessionCapture {
|
|
|
366
373
|
createdAt: string;
|
|
367
374
|
updatedAt: string;
|
|
368
375
|
}
|
|
376
|
+
export interface CaseAppInfo {
|
|
377
|
+
uploadArn: string;
|
|
378
|
+
uploadType: "own_app" | "existing" | "sample";
|
|
379
|
+
appName: string | null;
|
|
380
|
+
bundleId?: string;
|
|
381
|
+
}
|
|
382
|
+
export interface CaseMobileConfig {
|
|
383
|
+
deviceArn: string;
|
|
384
|
+
deviceName: string;
|
|
385
|
+
platform?: "ANDROID" | "IOS";
|
|
386
|
+
uploadArn?: string;
|
|
387
|
+
uploadType?: "own_app" | "existing" | "sample";
|
|
388
|
+
appName?: string;
|
|
389
|
+
bundleId?: string;
|
|
390
|
+
apps?: CaseAppInfo[];
|
|
391
|
+
}
|
|
392
|
+
export interface CaseWebConfig {
|
|
393
|
+
hostnameOverrideIds?: string[];
|
|
394
|
+
}
|
|
395
|
+
export interface CaseDesktopConfig {
|
|
396
|
+
userDeviceId: string;
|
|
397
|
+
name: string;
|
|
398
|
+
platform: "windows" | "macos" | "linux";
|
|
399
|
+
os: string | null;
|
|
400
|
+
}
|
|
401
|
+
export interface CaseMultiDeviceWebDevice {
|
|
402
|
+
kind: "web";
|
|
403
|
+
name: string;
|
|
404
|
+
startUrl?: string | null;
|
|
405
|
+
userDeviceId?: string;
|
|
406
|
+
localDeviceName?: string;
|
|
407
|
+
}
|
|
408
|
+
export interface CaseMultiDeviceMobileDevice {
|
|
409
|
+
kind: "mobile";
|
|
410
|
+
name: string;
|
|
411
|
+
mobilePlatform: "android" | "ios";
|
|
412
|
+
deviceArn?: string;
|
|
413
|
+
deviceName?: string;
|
|
414
|
+
userDeviceId?: string;
|
|
415
|
+
localDeviceName?: string;
|
|
416
|
+
uploadArn?: string;
|
|
417
|
+
uploadType?: "own_app" | "existing" | "sample";
|
|
418
|
+
appName?: string;
|
|
419
|
+
bundleId?: string;
|
|
420
|
+
apps?: CaseAppInfo[];
|
|
421
|
+
}
|
|
422
|
+
export type CaseMultiDeviceDevice = CaseMultiDeviceWebDevice | CaseMultiDeviceMobileDevice;
|
|
369
423
|
export interface CaseDetail {
|
|
370
424
|
id: string;
|
|
371
425
|
projectId: string;
|
|
@@ -380,4 +434,8 @@ export interface CaseDetail {
|
|
|
380
434
|
lastStatus: string | null;
|
|
381
435
|
createdAt: string;
|
|
382
436
|
updatedAt: string;
|
|
437
|
+
apiMobileConfig: CaseMobileConfig | null;
|
|
438
|
+
apiWebConfig: CaseWebConfig | null;
|
|
439
|
+
apiDesktopConfig: CaseDesktopConfig | null;
|
|
440
|
+
apiMultiDeviceConfig: CaseMultiDeviceDevice[] | null;
|
|
383
441
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@autifyhq/aximo-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Autify Aximo Command Line Interface (CLI)",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"autify",
|
|
@@ -59,6 +59,7 @@
|
|
|
59
59
|
"oxfmt": "0.45.0",
|
|
60
60
|
"oxlint": "1.60.0",
|
|
61
61
|
"postject": "1.0.0-alpha.6",
|
|
62
|
+
"resedit": "3.0.2",
|
|
62
63
|
"secretlint": "11.7.1",
|
|
63
64
|
"tsx": "4.21.0",
|
|
64
65
|
"typescript": "6.0.2",
|