@autifyhq/aximo-cli 0.14.0 → 0.16.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 +24 -1
- package/dist/commands/device/device-config.d.ts +2 -2
- package/dist/commands/device/device-config.js +5 -8
- package/dist/commands/device/index.js +2 -1
- package/dist/commands/device/paths.d.ts +4 -0
- package/dist/commands/device/paths.js +12 -0
- package/dist/commands/device/reset.d.ts +1 -0
- package/dist/commands/device/reset.js +18 -3
- package/dist/commands/device/reset.test.js +13 -0
- package/dist/commands/device/testbox/box.d.ts +90 -0
- package/dist/commands/device/testbox/box.js +379 -0
- package/dist/commands/device/testbox/box.test.d.ts +1 -0
- package/dist/commands/device/testbox/box.test.js +815 -0
- package/dist/commands/device/testbox/container.d.ts +58 -0
- package/dist/commands/device/testbox/container.js +289 -0
- package/dist/commands/device/testbox/docker.d.ts +31 -0
- package/dist/commands/device/testbox/docker.js +78 -0
- package/dist/commands/device/testbox/docker.test.d.ts +1 -0
- package/dist/commands/device/testbox/docker.test.js +82 -0
- package/dist/commands/device/testbox/down.d.ts +7 -0
- package/dist/commands/device/testbox/down.js +14 -0
- package/dist/commands/device/testbox/errors.d.ts +4 -0
- package/dist/commands/device/testbox/errors.js +7 -0
- package/dist/commands/device/testbox/fake-docker.d.ts +25 -0
- package/dist/commands/device/testbox/fake-docker.js +35 -0
- package/dist/commands/device/testbox/index.d.ts +2 -0
- package/dist/commands/device/testbox/index.js +16 -0
- package/dist/commands/device/testbox/logs.d.ts +15 -0
- package/dist/commands/device/testbox/logs.js +43 -0
- package/dist/commands/device/testbox/prune.d.ts +11 -0
- package/dist/commands/device/testbox/prune.js +17 -0
- package/dist/commands/device/testbox/registration.d.ts +9 -0
- package/dist/commands/device/testbox/registration.js +56 -0
- package/dist/commands/device/testbox/reset.d.ts +7 -0
- package/dist/commands/device/testbox/reset.js +14 -0
- package/dist/commands/device/testbox/result.d.ts +8 -0
- package/dist/commands/device/testbox/result.js +23 -0
- package/dist/commands/device/testbox/result.test.d.ts +1 -0
- package/dist/commands/device/testbox/result.test.js +34 -0
- package/dist/commands/device/testbox/self-command.d.ts +12 -0
- package/dist/commands/device/testbox/self-command.js +36 -0
- package/dist/commands/device/testbox/self-command.test.d.ts +1 -0
- package/dist/commands/device/testbox/self-command.test.js +58 -0
- package/dist/commands/device/testbox/status.d.ts +7 -0
- package/dist/commands/device/testbox/status.js +11 -0
- package/dist/commands/device/testbox/up.d.ts +47 -0
- package/dist/commands/device/testbox/up.js +76 -0
- package/dist/commands/device/testbox/up.test.d.ts +1 -0
- package/dist/commands/device/testbox/up.test.js +52 -0
- package/dist/commands/device/testbox/url.d.ts +7 -0
- package/dist/commands/device/testbox/url.js +20 -0
- package/dist/commands/device/user-devices.d.ts +4 -0
- package/dist/commands/device/user-devices.js +18 -0
- package/dist/commands/mcp/index.js +6 -3
- package/dist/commands/mcp/server.d.ts +2 -2
- package/dist/commands/mcp/server.test.js +28 -1
- package/dist/commands/mcp/testbox-tools.test.d.ts +1 -0
- package/dist/commands/mcp/testbox-tools.test.js +278 -0
- package/dist/commands/mcp/tools.d.ts +8 -1
- package/dist/commands/mcp/tools.js +252 -19
- package/dist/commands/mcp/tools.test.js +151 -3
- 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/schema.js +4 -0
- package/dist/commands/session/cancel.d.ts +20 -0
- package/dist/commands/session/cancel.js +39 -0
- package/dist/commands/session/export.d.ts +30 -0
- package/dist/commands/session/export.js +58 -0
- package/dist/commands/session/index.js +2 -0
- package/dist/commands/session/session.test.js +100 -0
- package/dist/errors.d.ts +2 -0
- package/dist/errors.js +5 -0
- package/dist/errors.test.js +6 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- 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 +5 -0
- package/package.json +1 -1
|
@@ -1,6 +1,16 @@
|
|
|
1
|
+
import { isAbsolute } from "node:path";
|
|
1
2
|
import { z } from "zod";
|
|
2
3
|
import { request } from "../../client.js";
|
|
3
|
-
import {
|
|
4
|
+
import { getTestboxLogPath } from "../device/paths.js";
|
|
5
|
+
import { clearState, downTestbox, pendingUp, readState, statusTestbox, waitForTestbox, } from "../device/testbox/box.js";
|
|
6
|
+
import { BOX_NAME_RE, validateBoxName } from "../device/testbox/container.js";
|
|
7
|
+
import { isDockerProblem, preflightDocker, runDocker } from "../device/testbox/docker.js";
|
|
8
|
+
import { TestboxError } from "../device/testbox/errors.js";
|
|
9
|
+
import { spawnDetached } from "../device/testbox/self-command.js";
|
|
10
|
+
import { listUserDevices } from "../device/user-devices.js";
|
|
11
|
+
import { realClock, WAIT_DEFAULT_SECONDS, WAIT_MAX_SECONDS, waitForSession } from "./wait.js";
|
|
12
|
+
import { exportSession, SESSION_EXPORT_LOCALES, } from "../../session-export.js";
|
|
13
|
+
import { ARCHIVE_FIRST_HINT, creditHint, featureHint, isArchiveFirstConflict, withDetails, } from "../../errors.js";
|
|
4
14
|
function ok(payload) {
|
|
5
15
|
return {
|
|
6
16
|
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
|
|
@@ -29,7 +39,9 @@ async function call(fn) {
|
|
|
29
39
|
if (status === 401)
|
|
30
40
|
return err("Unauthorized: check AXIMO_API_KEY", "Run `aximo configure`.");
|
|
31
41
|
if (status === 403) {
|
|
32
|
-
return err(message || "Forbidden", featureHint(e.message, details) ??
|
|
42
|
+
return err(message || "Forbidden", featureHint(e.message, details) ??
|
|
43
|
+
creditHint(e.message) ??
|
|
44
|
+
"API key may not have access to this resource.");
|
|
33
45
|
}
|
|
34
46
|
if (status === 404)
|
|
35
47
|
return err(message || "Not found");
|
|
@@ -50,7 +62,7 @@ async function call(fn) {
|
|
|
50
62
|
if (typeof status === "number")
|
|
51
63
|
return err(`HTTP ${status}: ${message}`);
|
|
52
64
|
if (e.message.startsWith("No API key configured")) {
|
|
53
|
-
return err("API key not configured", "Run `aximo configure
|
|
65
|
+
return err("API key not configured", "Run `aximo configure`, then call the tool again.");
|
|
54
66
|
}
|
|
55
67
|
const cause = e.cause;
|
|
56
68
|
const networkCode = cause?.code;
|
|
@@ -80,7 +92,64 @@ const runAppsSchema = z
|
|
|
80
92
|
bundleId: z.string().optional(),
|
|
81
93
|
}))
|
|
82
94
|
.max(3);
|
|
95
|
+
const exportOutputPathSchema = z
|
|
96
|
+
.string()
|
|
97
|
+
.min(1)
|
|
98
|
+
.describe("Absolute file path to write, e.g. /tmp/result.pdf. Parent directories are created and an existing file is replaced.");
|
|
99
|
+
const exportLocaleSchema = z
|
|
100
|
+
.enum(SESSION_EXPORT_LOCALES)
|
|
101
|
+
.optional()
|
|
102
|
+
.describe("Language of the report labels. Default en.");
|
|
103
|
+
// The MCP server's working directory is wherever the client launched it, so a
|
|
104
|
+
// relative path would land somewhere the caller cannot predict.
|
|
105
|
+
function exportSessionTo(input, deps) {
|
|
106
|
+
if (!isAbsolute(input.outputPath)) {
|
|
107
|
+
throw new Error("outputPath must be an absolute path");
|
|
108
|
+
}
|
|
109
|
+
return exportSession(input, deps);
|
|
110
|
+
}
|
|
111
|
+
const TESTBOX_HINTS = {
|
|
112
|
+
online: "Pass userDeviceId as the device for aximo_create_session or aximo_run_case, and give the user liveViewUrl to watch the run.",
|
|
113
|
+
pulling: "Still starting (a first start pulls a ~1 GB image). Call aximo_get_testbox with timeoutSeconds: 45 until ready.",
|
|
114
|
+
starting: "Still starting. Call aximo_get_testbox with timeoutSeconds: 45 until ready.",
|
|
115
|
+
connecting: "The box is up and the agent is connecting. Call aximo_get_testbox with timeoutSeconds: 45 until ready.",
|
|
116
|
+
down: "Not running. Call aximo_start_testbox.",
|
|
117
|
+
error: "Fix the cause in lastError, then call aximo_start_testbox again.",
|
|
118
|
+
};
|
|
119
|
+
function testboxReport(status) {
|
|
120
|
+
return {
|
|
121
|
+
ready: status.state === "online",
|
|
122
|
+
state: status.state,
|
|
123
|
+
name: status.name,
|
|
124
|
+
container: status.container,
|
|
125
|
+
...(status.agent ? { agent: status.agent } : {}),
|
|
126
|
+
...(status.device
|
|
127
|
+
? {
|
|
128
|
+
userDeviceId: status.device.userDeviceId,
|
|
129
|
+
deviceName: status.device.name,
|
|
130
|
+
deviceStatus: status.device.status,
|
|
131
|
+
}
|
|
132
|
+
: {}),
|
|
133
|
+
...(status.liveViewUrl ? { liveViewUrl: status.liveViewUrl } : {}),
|
|
134
|
+
...(status.ports ? { ports: status.ports } : {}),
|
|
135
|
+
...(status.agentStatus ? { agentStatus: status.agentStatus } : {}),
|
|
136
|
+
...(status.lastError ? { lastError: status.lastError } : {}),
|
|
137
|
+
hint: TESTBOX_HINTS[status.state],
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
const testboxNameSchema = z
|
|
141
|
+
.string()
|
|
142
|
+
.regex(BOX_NAME_RE)
|
|
143
|
+
.optional()
|
|
144
|
+
.describe("Box name, only when running more than one box on this machine.");
|
|
145
|
+
// The detached `up` normally writes its first state within a second; after this
|
|
146
|
+
// the tool reads Docker directly.
|
|
147
|
+
const START_SETTLE_MS = 10_000;
|
|
83
148
|
export function defineTools(deps = {}) {
|
|
149
|
+
const testbox = { ...deps, ...deps.testbox };
|
|
150
|
+
const spawnUp = deps.testbox?.spawn ?? spawnDetached;
|
|
151
|
+
const clock = testbox.clock ?? realClock;
|
|
152
|
+
const secondsLeft = (deadline) => Math.max(0, Math.round((deadline - clock.now()) / 1000));
|
|
84
153
|
return [
|
|
85
154
|
{
|
|
86
155
|
name: "aximo_list_projects",
|
|
@@ -112,21 +181,7 @@ export function defineTools(deps = {}) {
|
|
|
112
181
|
title: "List registered BYOD devices",
|
|
113
182
|
description: "List the BYOD (bring-your-own) desktop and mobile devices registered with `aximo device register` by the owner of this API key — other members' devices are not included. Each device's id is the userDeviceId to pass in a case's apiDesktopConfig or multi-device local entries (aximo_update_case) and in per-run overrides. status shows whether the device is online. Returns 404 when BYOD is not enabled for the organization.",
|
|
114
183
|
inputSchema: {},
|
|
115
|
-
handler: async () => call(async () => {
|
|
116
|
-
try {
|
|
117
|
-
return await request("/api/devices/user", {}, deps);
|
|
118
|
-
}
|
|
119
|
-
catch (e) {
|
|
120
|
-
// The route answers a bare 404 when the org has no BYOD feature.
|
|
121
|
-
if (e.status === 404) {
|
|
122
|
-
throw Object.assign(new Error("BYOD is not enabled for this organization"), {
|
|
123
|
-
status: 403,
|
|
124
|
-
details: { feature: "byodEnabled" },
|
|
125
|
-
});
|
|
126
|
-
}
|
|
127
|
-
throw e;
|
|
128
|
-
}
|
|
129
|
-
}),
|
|
184
|
+
handler: async () => call(async () => ({ devices: await listUserDevices(deps) })),
|
|
130
185
|
},
|
|
131
186
|
{
|
|
132
187
|
name: "aximo_list_uploads",
|
|
@@ -165,6 +220,22 @@ export function defineTools(deps = {}) {
|
|
|
165
220
|
return await request(`/api/sessions/${encodeURIComponent(sessionId)}`, {}, deps);
|
|
166
221
|
}),
|
|
167
222
|
},
|
|
223
|
+
{
|
|
224
|
+
name: "aximo_wait_for_session",
|
|
225
|
+
title: "Wait for session",
|
|
226
|
+
description: "Wait for a session to finish and return within timeoutSeconds whatever its state. Polls every 5 seconds. Returns done=true with the final session (status succeeded, failed, terminated, error, or archived), or done=false with the current status so you can call again. Default 45 seconds, maximum 90: most MCP clients cut a tool call off at 60, so only go above that where the client has no limit. A multi-minute run is a handful of these calls.",
|
|
227
|
+
inputSchema: {
|
|
228
|
+
sessionId: z.string().uuid(),
|
|
229
|
+
timeoutSeconds: z
|
|
230
|
+
.number()
|
|
231
|
+
.int()
|
|
232
|
+
.min(1)
|
|
233
|
+
.max(WAIT_MAX_SECONDS)
|
|
234
|
+
.optional()
|
|
235
|
+
.describe(`Seconds to wait before returning. Default ${WAIT_DEFAULT_SECONDS}.`),
|
|
236
|
+
},
|
|
237
|
+
handler: async ({ sessionId, timeoutSeconds }) => call(() => waitForSession(sessionId, timeoutSeconds ?? WAIT_DEFAULT_SECONDS, deps)),
|
|
238
|
+
},
|
|
168
239
|
{
|
|
169
240
|
name: "aximo_get_session_messages",
|
|
170
241
|
title: "Get session messages",
|
|
@@ -240,6 +311,28 @@ export function defineTools(deps = {}) {
|
|
|
240
311
|
return await request(`/api/sessions/${encodeURIComponent(sessionId)}/snapshots/${encodeURIComponent(snapshotId)}`, {}, deps);
|
|
241
312
|
}),
|
|
242
313
|
},
|
|
314
|
+
{
|
|
315
|
+
name: "aximo_export_session_pdf",
|
|
316
|
+
title: "Export session result as PDF",
|
|
317
|
+
description: "Download a session's test result report (overview, result summary, and every step with its screenshot) as a PDF and write it to outputPath on this machine. Returns the written path and size, not the file bytes. A running session exports the steps recorded so far. Sessions over 500 steps return HTTP 413: export the screenshots archive instead. For a plan run, take each case's sessionId from aximo_get_plan_run.",
|
|
318
|
+
inputSchema: {
|
|
319
|
+
sessionId: z.string().uuid(),
|
|
320
|
+
outputPath: exportOutputPathSchema,
|
|
321
|
+
locale: exportLocaleSchema,
|
|
322
|
+
},
|
|
323
|
+
handler: async ({ sessionId, outputPath, locale }) => call(() => exportSessionTo({ sessionId, format: "pdf", outputPath, locale }, deps)),
|
|
324
|
+
},
|
|
325
|
+
{
|
|
326
|
+
name: "aximo_export_session_screenshots",
|
|
327
|
+
title: "Export session screenshots as ZIP",
|
|
328
|
+
description: "Download a ZIP of every step screenshot of a session (screenshots/step-001.png or .jpg, as stored) plus metadata.csv (one row per step: screenshot file, AI thoughts, tool, details) and session.csv, and write it to outputPath on this machine. Returns the written path and size, not the file bytes. The download is verified complete before success is reported; a truncated archive is deleted and returned as an error. A session with no screenshots returns HTTP 409.",
|
|
329
|
+
inputSchema: {
|
|
330
|
+
sessionId: z.string().uuid(),
|
|
331
|
+
outputPath: exportOutputPathSchema,
|
|
332
|
+
locale: exportLocaleSchema,
|
|
333
|
+
},
|
|
334
|
+
handler: async ({ sessionId, outputPath, locale }) => call(() => exportSessionTo({ sessionId, format: "screenshots", outputPath, locale }, deps)),
|
|
335
|
+
},
|
|
243
336
|
{
|
|
244
337
|
name: "aximo_list_cases",
|
|
245
338
|
title: "List test cases",
|
|
@@ -398,6 +491,60 @@ export function defineTools(deps = {}) {
|
|
|
398
491
|
return await request(`/api/triggers/cases/${encodeURIComponent(caseId)}/run`, { method: "POST", body }, deps);
|
|
399
492
|
}),
|
|
400
493
|
},
|
|
494
|
+
{
|
|
495
|
+
name: "aximo_create_session",
|
|
496
|
+
title: "Create session",
|
|
497
|
+
description: "Start an agent session from a prompt, without saving a test case. Consumes organization credits. Returns the new sessionId; follow it with aximo_wait_for_session. Runs in Aximo's cloud browser unless userDeviceId names a registered local device (aximo_list_user_devices); for an app on localhost use a local device and put the app's URL in the prompt itself.",
|
|
498
|
+
inputSchema: {
|
|
499
|
+
title: z.string().min(1).max(500).describe("Shown in the session list."),
|
|
500
|
+
prompt: z
|
|
501
|
+
.string()
|
|
502
|
+
.min(1)
|
|
503
|
+
.max(10000)
|
|
504
|
+
.describe("What to test, in plain language: the steps and what counts as a pass."),
|
|
505
|
+
projectId: z.string().uuid(),
|
|
506
|
+
platform: z.enum(["desktop", "android", "ios"]).optional().describe("Default desktop."),
|
|
507
|
+
model: z
|
|
508
|
+
.string()
|
|
509
|
+
.optional()
|
|
510
|
+
.describe("Agent model (e.g. 'sonnet', 'haiku', 'auto'). Server-validated."),
|
|
511
|
+
startUrl: z
|
|
512
|
+
.string()
|
|
513
|
+
.url()
|
|
514
|
+
.optional()
|
|
515
|
+
.describe("Web sessions on cloud browsers: first URL to open."),
|
|
516
|
+
tags: z.array(z.string().max(64)).max(10).optional(),
|
|
517
|
+
hostnameOverrideIds: z.array(z.string().uuid()).optional(),
|
|
518
|
+
userDeviceId: z
|
|
519
|
+
.string()
|
|
520
|
+
.uuid()
|
|
521
|
+
.optional()
|
|
522
|
+
.describe("A user-owned (BYOD) device id; runs there instead of the cloud."),
|
|
523
|
+
deviceArn: z.string().optional().describe("Mobile: encrypted Device Farm device token."),
|
|
524
|
+
deviceName: z.string().optional(),
|
|
525
|
+
uploadArn: z.string().optional().describe("Mobile: encrypted app upload token."),
|
|
526
|
+
uploadType: z.enum(["own_app", "existing", "sample"]).optional(),
|
|
527
|
+
appName: z.string().optional(),
|
|
528
|
+
bundleId: z.string().optional(),
|
|
529
|
+
apps: runAppsSchema
|
|
530
|
+
.optional()
|
|
531
|
+
.describe("Mobile multi-app sessions: up to 3 apps; takes precedence over the single-app fields."),
|
|
532
|
+
},
|
|
533
|
+
handler: async (body) => call(async () => {
|
|
534
|
+
return await request("/api/sessions", { method: "POST", body }, deps);
|
|
535
|
+
}),
|
|
536
|
+
},
|
|
537
|
+
{
|
|
538
|
+
name: "aximo_cancel_session",
|
|
539
|
+
title: "Cancel session",
|
|
540
|
+
description: "Cancel a queued or running session: it moves to terminated and stops on its device at the next step, including a BYOD session waiting for an offline device. Use it to stop a run that went wrong instead of letting it spend credits. A session that has already finished returns a conflict error and is left unchanged.",
|
|
541
|
+
inputSchema: {
|
|
542
|
+
sessionId: z.string().uuid(),
|
|
543
|
+
},
|
|
544
|
+
handler: async ({ sessionId }) => call(async () => {
|
|
545
|
+
return await request(`/api/sessions/${encodeURIComponent(sessionId)}/cancel`, { method: "POST" }, deps);
|
|
546
|
+
}),
|
|
547
|
+
},
|
|
401
548
|
{
|
|
402
549
|
name: "aximo_create_plan",
|
|
403
550
|
title: "Create test plan",
|
|
@@ -600,7 +747,7 @@ export function defineTools(deps = {}) {
|
|
|
600
747
|
{
|
|
601
748
|
name: "aximo_get_plan_run",
|
|
602
749
|
title: "Get plan run status",
|
|
603
|
-
description: "Get the overall status and per-case breakdown of a plan run (from aximo_list_plan_runs, or the planRunId returned when a run was triggered). Poll this to observe a run in progress; wait at least 5 seconds between polls. A run's status reflects execution, not the test verdict: a run can complete with failedCases > 0, so read passedCases/failedCases and the per-case statuses to judge outcomes.",
|
|
750
|
+
description: "Get the overall status and per-case breakdown of a plan run (from aximo_list_plan_runs, or the planRunId returned when a run was triggered). Poll this to observe a run in progress; wait at least 5 seconds between polls. A run's status reflects execution, not the test verdict: a run can complete with failedCases > 0, so read passedCases/failedCases and the per-case statuses to judge outcomes. Each case carries its sessionId (null until its session is created) for aximo_get_session and the export tools.",
|
|
604
751
|
inputSchema: {
|
|
605
752
|
planId: z.string().uuid(),
|
|
606
753
|
planRunId: z.string().uuid(),
|
|
@@ -624,5 +771,91 @@ export function defineTools(deps = {}) {
|
|
|
624
771
|
return await request(`/api/sessions/${encodeURIComponent(sessionId)}/save-as-case`, { method: "POST", body }, deps);
|
|
625
772
|
}),
|
|
626
773
|
},
|
|
774
|
+
// Local tools: these run Docker on the machine hosting the MCP server.
|
|
775
|
+
{
|
|
776
|
+
name: "aximo_start_testbox",
|
|
777
|
+
title: "Start the testbox",
|
|
778
|
+
description: "Start the testbox: a sandboxed Chromium in Docker on this machine, registered as a local (BYOD) device, whose localhost is this machine's localhost. Use it to test an app running locally without taking over the user's screen. Idempotent: a box that is already up is reused, and calling again never starts a second one. Returns within timeoutSeconds with ready=true and the userDeviceId once the device is online, or ready=false with the current state; then poll with aximo_get_testbox (read-only) rather than calling this again. Requires Docker; the error says how to install it. The user signs into the app through liveViewUrl if needed, and keeps that tab to watch runs.",
|
|
779
|
+
inputSchema: {
|
|
780
|
+
name: testboxNameSchema,
|
|
781
|
+
ports: z
|
|
782
|
+
.string()
|
|
783
|
+
.regex(/^[0-9, -]+$/)
|
|
784
|
+
.optional()
|
|
785
|
+
.describe("Host ports the app serves on, e.g. 3000 or 3000,5173-5180. Default 3000."),
|
|
786
|
+
blockHosts: z
|
|
787
|
+
.array(z.string())
|
|
788
|
+
.optional()
|
|
789
|
+
.describe("Hosts the browser must refuse, e.g. the production domain. A browser rule matched by name, not a network boundary."),
|
|
790
|
+
startUrl: z.string().url().optional().describe("Page the browser opens on start."),
|
|
791
|
+
screen: z
|
|
792
|
+
.string()
|
|
793
|
+
.regex(/^\d{3,4}x\d{3,4}$/)
|
|
794
|
+
.optional()
|
|
795
|
+
.describe("Virtual screen size WIDTHxHEIGHT. Default 1280x720; use 1920x1080 only for a layout that needs a wide viewport."),
|
|
796
|
+
timeoutSeconds: z
|
|
797
|
+
.number()
|
|
798
|
+
.int()
|
|
799
|
+
.min(1)
|
|
800
|
+
.max(WAIT_MAX_SECONDS)
|
|
801
|
+
.optional()
|
|
802
|
+
.describe(`Seconds to wait before returning. Default ${WAIT_DEFAULT_SECONDS}.`),
|
|
803
|
+
},
|
|
804
|
+
handler: async ({ name, ports, blockHosts, startUrl, screen, timeoutSeconds }) => call(async () => {
|
|
805
|
+
const deadline = clock.now() + (timeoutSeconds ?? WAIT_DEFAULT_SECONDS) * 1000;
|
|
806
|
+
const boxName = validateBoxName(name);
|
|
807
|
+
const docker = await preflightDocker(testbox.docker ?? runDocker);
|
|
808
|
+
if (isDockerProblem(docker))
|
|
809
|
+
throw new TestboxError(docker.message);
|
|
810
|
+
const current = await statusTestbox(boxName, testbox);
|
|
811
|
+
const reconfigure = ports !== undefined ||
|
|
812
|
+
blockHosts !== undefined ||
|
|
813
|
+
startUrl !== undefined ||
|
|
814
|
+
screen !== undefined;
|
|
815
|
+
if (current.state === "online" && !reconfigure)
|
|
816
|
+
return testboxReport(current);
|
|
817
|
+
if (!pendingUp(boxName, clock)) {
|
|
818
|
+
// A previous run's error would otherwise be read as this run's result.
|
|
819
|
+
clearState(boxName);
|
|
820
|
+
const args = ["device", "testbox", "up", "--no-wait", "--name", boxName];
|
|
821
|
+
if (ports)
|
|
822
|
+
args.push("--ports", ports);
|
|
823
|
+
if (blockHosts?.length)
|
|
824
|
+
args.push("--block-hosts", blockHosts.join(","));
|
|
825
|
+
if (startUrl)
|
|
826
|
+
args.push("--start-url", startUrl);
|
|
827
|
+
if (screen)
|
|
828
|
+
args.push("--screen", screen);
|
|
829
|
+
spawnUp(args, getTestboxLogPath(boxName));
|
|
830
|
+
const settleBy = Math.min(deadline, clock.now() + START_SETTLE_MS);
|
|
831
|
+
while (readState(boxName) === null && clock.now() < settleBy)
|
|
832
|
+
await clock.sleep(500);
|
|
833
|
+
}
|
|
834
|
+
return testboxReport(await waitForTestbox(boxName, secondsLeft(deadline), testbox));
|
|
835
|
+
}),
|
|
836
|
+
},
|
|
837
|
+
{
|
|
838
|
+
name: "aximo_get_testbox",
|
|
839
|
+
title: "Get testbox status",
|
|
840
|
+
description: "Read-only. Container, agent and device state of the testbox, which mirrored ports answer from inside the box, and the live-view link. With timeoutSeconds it waits up to that long (max 90) for the box to come online and returns whatever the state is then: pulling, starting, connecting, online, down or error. A first start pulls the image and can take a few minutes, so keep calling until ready=true. Check ports before a run: an unreachable port means the app is not listening, not that the test failed.",
|
|
841
|
+
inputSchema: {
|
|
842
|
+
name: testboxNameSchema,
|
|
843
|
+
timeoutSeconds: z
|
|
844
|
+
.number()
|
|
845
|
+
.int()
|
|
846
|
+
.min(0)
|
|
847
|
+
.max(WAIT_MAX_SECONDS)
|
|
848
|
+
.optional()
|
|
849
|
+
.describe("Seconds to wait for the box to come online. Default 0: answer at once."),
|
|
850
|
+
},
|
|
851
|
+
handler: async ({ name, timeoutSeconds }) => call(async () => testboxReport(await waitForTestbox(name, timeoutSeconds ?? 0, testbox))),
|
|
852
|
+
},
|
|
853
|
+
{
|
|
854
|
+
name: "aximo_stop_testbox",
|
|
855
|
+
title: "Stop the testbox",
|
|
856
|
+
description: "Stop and remove the testbox container. The browser profile (sign-ins) and the device registration are kept, so the next aximo_start_testbox is quick. Dropping the profile or the registration is a human action: `aximo device testbox reset` / `prune` in a terminal.",
|
|
857
|
+
inputSchema: { name: testboxNameSchema },
|
|
858
|
+
handler: async ({ name }) => call(() => downTestbox(name, testbox)),
|
|
859
|
+
},
|
|
627
860
|
];
|
|
628
861
|
}
|
|
@@ -1,6 +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";
|
|
2
5
|
import { z } from "zod";
|
|
3
|
-
import { ARCHIVE_FIRST_HINT, handleApiError, isArchiveFirstConflict } from "../../errors.js";
|
|
6
|
+
import { ARCHIVE_FIRST_HINT, CREDIT_HINT, handleApiError, isArchiveFirstConflict, } from "../../errors.js";
|
|
4
7
|
import { HttpResponse, http, server, TEST_BASE_URL } from "../../test-utils.js";
|
|
5
8
|
import { defineTools } from "./tools.js";
|
|
6
9
|
beforeAll(() => {
|
|
@@ -29,11 +32,14 @@ describe("defineTools", () => {
|
|
|
29
32
|
"aximo_list_uploads",
|
|
30
33
|
"aximo_list_sessions",
|
|
31
34
|
"aximo_get_session",
|
|
35
|
+
"aximo_wait_for_session",
|
|
32
36
|
"aximo_get_session_messages",
|
|
33
37
|
"aximo_get_session_screenshots",
|
|
34
38
|
"aximo_get_session_timeline",
|
|
35
39
|
"aximo_get_session_capture",
|
|
36
40
|
"aximo_get_session_snapshot",
|
|
41
|
+
"aximo_export_session_pdf",
|
|
42
|
+
"aximo_export_session_screenshots",
|
|
37
43
|
"aximo_list_cases",
|
|
38
44
|
"aximo_get_case",
|
|
39
45
|
"aximo_create_case",
|
|
@@ -42,6 +48,8 @@ describe("defineTools", () => {
|
|
|
42
48
|
"aximo_restore_case",
|
|
43
49
|
"aximo_delete_case",
|
|
44
50
|
"aximo_run_case",
|
|
51
|
+
"aximo_create_session",
|
|
52
|
+
"aximo_cancel_session",
|
|
45
53
|
"aximo_create_plan",
|
|
46
54
|
"aximo_add_case_to_plan",
|
|
47
55
|
"aximo_remove_case_from_plan",
|
|
@@ -55,6 +63,9 @@ describe("defineTools", () => {
|
|
|
55
63
|
"aximo_list_plan_runs",
|
|
56
64
|
"aximo_get_plan_run",
|
|
57
65
|
"aximo_save_session_as_case",
|
|
66
|
+
"aximo_start_testbox",
|
|
67
|
+
"aximo_get_testbox",
|
|
68
|
+
"aximo_stop_testbox",
|
|
58
69
|
]);
|
|
59
70
|
});
|
|
60
71
|
});
|
|
@@ -247,6 +258,90 @@ describe("aximo_update_case", () => {
|
|
|
247
258
|
expect(parsed).not.toHaveProperty("startUrl");
|
|
248
259
|
});
|
|
249
260
|
});
|
|
261
|
+
describe("aximo_wait_for_session", () => {
|
|
262
|
+
const sessionId = "bbbbbbbb-bbbb-4bbb-9bbb-bbbbbbbbbbbb";
|
|
263
|
+
it("returns done=true with the session when the status is already final", async () => {
|
|
264
|
+
const session = { id: sessionId, status: "failed", summary: "Button missing" };
|
|
265
|
+
server.use(http.get(`${TEST_BASE_URL}/api/sessions/${sessionId}`, () => HttpResponse.json(session)));
|
|
266
|
+
const tool = byName(defineTools(), "aximo_wait_for_session");
|
|
267
|
+
const result = await tool.handler({ sessionId });
|
|
268
|
+
expect(result.isError).toBeUndefined();
|
|
269
|
+
expect(parsePayload(result)).toEqual({
|
|
270
|
+
done: true,
|
|
271
|
+
status: "failed",
|
|
272
|
+
waitedSeconds: 0,
|
|
273
|
+
session,
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
it("returns done=false with a hint when the run outlasts the timeout", async () => {
|
|
277
|
+
server.use(http.get(`${TEST_BASE_URL}/api/sessions/${sessionId}`, () => HttpResponse.json({ id: sessionId, status: "running" })));
|
|
278
|
+
const tool = byName(defineTools(), "aximo_wait_for_session");
|
|
279
|
+
// 1 s is shorter than one poll interval, so this returns after a single poll.
|
|
280
|
+
const result = await tool.handler({ sessionId, timeoutSeconds: 1 });
|
|
281
|
+
expect(result.isError).toBeUndefined();
|
|
282
|
+
const payload = parsePayload(result);
|
|
283
|
+
expect(payload.done).toBe(false);
|
|
284
|
+
expect(payload.status).toBe("running");
|
|
285
|
+
expect(payload.hint).toMatch(/aximo_wait_for_session again/);
|
|
286
|
+
});
|
|
287
|
+
it("shapes API errors like every other tool", async () => {
|
|
288
|
+
server.use(http.get(`${TEST_BASE_URL}/api/sessions/${sessionId}`, () => HttpResponse.json({ error: "Session not found" }, { status: 404 })));
|
|
289
|
+
const tool = byName(defineTools(), "aximo_wait_for_session");
|
|
290
|
+
const result = await tool.handler({ sessionId, timeoutSeconds: 1 });
|
|
291
|
+
expect(result.isError).toBe(true);
|
|
292
|
+
expect(parsePayload(result)).toEqual({ error: "Session not found" });
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
describe("aximo_cancel_session", () => {
|
|
296
|
+
const sessionId = "78787878-7878-4787-9787-787878787878";
|
|
297
|
+
it("POSTs the cancel endpoint and returns the terminated session", async () => {
|
|
298
|
+
const payload = { success: true, sessionId, status: "terminated" };
|
|
299
|
+
server.use(http.post(`${TEST_BASE_URL}/api/sessions/${sessionId}/cancel`, () => HttpResponse.json(payload)));
|
|
300
|
+
const result = await byName(defineTools(), "aximo_cancel_session").handler({ sessionId });
|
|
301
|
+
expect(result.isError).toBeUndefined();
|
|
302
|
+
expect(parsePayload(result)).toEqual(payload);
|
|
303
|
+
});
|
|
304
|
+
it("reports a finished session as an error with no archive hint", async () => {
|
|
305
|
+
server.use(http.post(`${TEST_BASE_URL}/api/sessions/${sessionId}/cancel`, () => HttpResponse.json({ error: "Cannot cancel a session that has already finished" }, { status: 409 })));
|
|
306
|
+
const result = await byName(defineTools(), "aximo_cancel_session").handler({ sessionId });
|
|
307
|
+
expect(result.isError).toBe(true);
|
|
308
|
+
expect(parsePayload(result)).toEqual({
|
|
309
|
+
error: "Cannot cancel a session that has already finished",
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
describe("aximo_create_session", () => {
|
|
314
|
+
const projectId = "12121212-1212-4212-9212-121212121212";
|
|
315
|
+
it("POSTs /api/sessions with the prompt and device and returns sessionId", async () => {
|
|
316
|
+
let body = null;
|
|
317
|
+
let apiKey = null;
|
|
318
|
+
const payload = { success: true, sessionId: "34343434-3434-4343-9343-343434343434" };
|
|
319
|
+
server.use(http.post(`${TEST_BASE_URL}/api/sessions`, async ({ request: req }) => {
|
|
320
|
+
apiKey = req.headers.get("x-api-key");
|
|
321
|
+
body = await req.json();
|
|
322
|
+
return HttpResponse.json(payload, { status: 201 });
|
|
323
|
+
}));
|
|
324
|
+
const tool = byName(defineTools(), "aximo_create_session");
|
|
325
|
+
const input = {
|
|
326
|
+
title: "Login works",
|
|
327
|
+
prompt: "Open http://localhost:3000 and sign in as demo@example.com. Pass if the dashboard loads.",
|
|
328
|
+
projectId,
|
|
329
|
+
userDeviceId: "56565656-5656-4565-9565-565656565656",
|
|
330
|
+
};
|
|
331
|
+
const result = await tool.handler(input);
|
|
332
|
+
expect(result.isError).toBeUndefined();
|
|
333
|
+
expect(parsePayload(result)).toEqual(payload);
|
|
334
|
+
expect(apiKey).toBe("test-key");
|
|
335
|
+
expect(body).toEqual(input);
|
|
336
|
+
});
|
|
337
|
+
it("passes API validation errors through as a tool error", async () => {
|
|
338
|
+
server.use(http.post(`${TEST_BASE_URL}/api/sessions`, () => HttpResponse.json({ error: "Project not found" }, { status: 404 })));
|
|
339
|
+
const tool = byName(defineTools(), "aximo_create_session");
|
|
340
|
+
const result = await tool.handler({ title: "t", prompt: "p", projectId });
|
|
341
|
+
expect(result.isError).toBe(true);
|
|
342
|
+
expect(parsePayload(result)).toEqual({ error: "Project not found" });
|
|
343
|
+
});
|
|
344
|
+
});
|
|
250
345
|
describe("aximo_run_case", () => {
|
|
251
346
|
const caseId = "77777777-7777-4777-9777-777777777777";
|
|
252
347
|
it("POSTs /api/triggers/cases/:id/run with the per-run overrides and returns sessionId", async () => {
|
|
@@ -297,7 +392,10 @@ describe("aximo_run_case", () => {
|
|
|
297
392
|
const tool = byName(defineTools(), "aximo_run_case");
|
|
298
393
|
const result = await tool.handler({ caseId });
|
|
299
394
|
expect(result.isError).toBe(true);
|
|
300
|
-
expect(parsePayload(result)
|
|
395
|
+
expect(parsePayload(result)).toEqual({
|
|
396
|
+
error: "Insufficient credits to start session",
|
|
397
|
+
hint: CREDIT_HINT,
|
|
398
|
+
});
|
|
301
399
|
});
|
|
302
400
|
it("maps a 429 to a rate-limit error an agent can back off on", async () => {
|
|
303
401
|
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" } })));
|
|
@@ -1037,3 +1135,53 @@ describe("409 handling parity between the CLI and MCP error mappers", () => {
|
|
|
1037
1135
|
}
|
|
1038
1136
|
});
|
|
1039
1137
|
});
|
|
1138
|
+
describe("session export tools", () => {
|
|
1139
|
+
const sessionId = "33333333-3333-4333-9333-333333333333";
|
|
1140
|
+
let dir;
|
|
1141
|
+
beforeEach(() => {
|
|
1142
|
+
dir = mkdtempSync(join(tmpdir(), "aximo-mcp-export-"));
|
|
1143
|
+
});
|
|
1144
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
1145
|
+
it("aximo_export_session_pdf writes the PDF and returns its path, not the bytes", async () => {
|
|
1146
|
+
let apiKey = null;
|
|
1147
|
+
let locale = null;
|
|
1148
|
+
server.use(http.get(`${TEST_BASE_URL}/api/sessions/${sessionId}/export/pdf`, ({ request: req }) => {
|
|
1149
|
+
apiKey = req.headers.get("x-api-key");
|
|
1150
|
+
locale = new URL(req.url).searchParams.get("locale");
|
|
1151
|
+
return new HttpResponse("%PDF-1.3\n%%EOF\n");
|
|
1152
|
+
}));
|
|
1153
|
+
const outputPath = join(dir, "result.pdf");
|
|
1154
|
+
const tool = byName(defineTools(), "aximo_export_session_pdf");
|
|
1155
|
+
const result = await tool.handler({ sessionId, outputPath, locale: "ko" });
|
|
1156
|
+
expect(result.isError).toBeUndefined();
|
|
1157
|
+
expect(parsePayload(result)).toEqual({
|
|
1158
|
+
sessionId,
|
|
1159
|
+
format: "pdf",
|
|
1160
|
+
path: outputPath,
|
|
1161
|
+
sizeBytes: 15,
|
|
1162
|
+
});
|
|
1163
|
+
expect(readFileSync(outputPath, "utf8")).toBe("%PDF-1.3\n%%EOF\n");
|
|
1164
|
+
expect([apiKey, locale]).toEqual(["test-key", "ko"]);
|
|
1165
|
+
});
|
|
1166
|
+
it("aximo_export_session_screenshots deletes a truncated archive and reports an error", async () => {
|
|
1167
|
+
server.use(http.get(`${TEST_BASE_URL}/api/sessions/${sessionId}/export/screenshots`, () => new HttpResponse(Buffer.from("PK\x03\x04 half an archive"))));
|
|
1168
|
+
const tool = byName(defineTools(), "aximo_export_session_screenshots");
|
|
1169
|
+
const result = await tool.handler({ sessionId, outputPath: join(dir, "shots.zip") });
|
|
1170
|
+
expect(result.isError).toBe(true);
|
|
1171
|
+
expect(parsePayload(result).error).toMatch(/truncated/);
|
|
1172
|
+
expect(readdirSync(dir)).toEqual([]);
|
|
1173
|
+
});
|
|
1174
|
+
it("passes on the API's 409 for a session without screenshots", async () => {
|
|
1175
|
+
server.use(http.get(`${TEST_BASE_URL}/api/sessions/${sessionId}/export/screenshots`, () => HttpResponse.json({ error: "Session has no screenshots to export" }, { status: 409 })));
|
|
1176
|
+
const tool = byName(defineTools(), "aximo_export_session_screenshots");
|
|
1177
|
+
const result = await tool.handler({ sessionId, outputPath: join(dir, "shots.zip") });
|
|
1178
|
+
expect(result.isError).toBe(true);
|
|
1179
|
+
expect(parsePayload(result)).toEqual({ error: "Session has no screenshots to export" });
|
|
1180
|
+
});
|
|
1181
|
+
it("rejects a relative outputPath", async () => {
|
|
1182
|
+
const tool = byName(defineTools(), "aximo_export_session_pdf");
|
|
1183
|
+
const result = await tool.handler({ sessionId, outputPath: "result.pdf" });
|
|
1184
|
+
expect(result.isError).toBe(true);
|
|
1185
|
+
expect(parsePayload(result)).toEqual({ error: "outputPath must be an absolute path" });
|
|
1186
|
+
});
|
|
1187
|
+
});
|
|
@@ -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 {};
|