@autifyhq/aximo-cli 0.14.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 +6 -1
- package/dist/commands/mcp/index.js +6 -3
- package/dist/commands/mcp/server.test.js +24 -1
- package/dist/commands/mcp/tools.js +102 -2
- package/dist/commands/mcp/tools.test.js +124 -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/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/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/package.json +1 -1
package/README.md
CHANGED
|
@@ -73,6 +73,7 @@ Run `aximo <command> --help` for detailed usage. `aximo schema [command.path]` r
|
|
|
73
73
|
| `aximo project list` | List organization projects |
|
|
74
74
|
| `aximo session create` | Create and queue a test session |
|
|
75
75
|
| `aximo session get` | Get session status |
|
|
76
|
+
| `aximo session export` | Download a session result as a PDF or a screenshot ZIP |
|
|
76
77
|
| `aximo session save-as-case` | Save a finished session as a reusable test case |
|
|
77
78
|
| `aximo case create` | Create a test case |
|
|
78
79
|
| `aximo case update` | Update a test case |
|
|
@@ -145,10 +146,14 @@ aximo session create --title <t> --prompt <p> --project-id <id> \
|
|
|
145
146
|
[--json '<body>'] [--dry-run]
|
|
146
147
|
|
|
147
148
|
aximo session get <sessionId>
|
|
149
|
+
|
|
150
|
+
aximo session export <sessionId> [--format pdf|screenshots] [--output <path>] [--locale en|ja|ko]
|
|
148
151
|
```
|
|
149
152
|
|
|
150
153
|
Desktop sessions default to `platform=desktop`, `model=sonnet`. Mobile sessions require both `--device-arn` and `--upload-arn`.
|
|
151
154
|
|
|
155
|
+
`session export` writes the test result report (`pdf`, the default) or a ZIP of the step screenshots with `metadata.csv` and `session.csv` (`screenshots`) to `--output`, defaulting to `<sessionId>.pdf` / `<sessionId>-screenshots.zip`. The download is verified complete before it replaces the output file.
|
|
156
|
+
|
|
152
157
|
### Test cases
|
|
153
158
|
|
|
154
159
|
```bash
|
|
@@ -670,7 +675,7 @@ claude mcp add aximo -- aximo mcp
|
|
|
670
675
|
|
|
671
676
|
VS Code (Copilot agent mode) uses the same entry under a `servers` key in `.vscode/mcp.json`.
|
|
672
677
|
|
|
673
|
-
The server exposes
|
|
678
|
+
The server exposes 34 tools: 17 read-only ones for inspecting sessions, cases, plans, plan runs, devices, and uploads (`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; `aximo_list_devices`, `aximo_list_user_devices`, and `aximo_list_uploads` supply the device and upload tokens that case configs and run overrides take), 2 that export a session result to a local file (`aximo_export_session_pdf` for the test result report, `aximo_export_session_screenshots` for a ZIP of the step screenshots), 13 that author content (create, update, archive, restore, and delete cases — including a case's per-target device configuration — create, update, archive, restore, and delete plans, add or remove a plan's cases, 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.
|
|
674
679
|
|
|
675
680
|
## Agent Skills
|
|
676
681
|
|
|
@@ -10,15 +10,18 @@ export default defineCommand({
|
|
|
10
10
|
},
|
|
11
11
|
args: {},
|
|
12
12
|
async run() {
|
|
13
|
-
//
|
|
13
|
+
// Serve even without a key. A server that exits before the handshake shows
|
|
14
|
+
// up in the client as an empty tool list with no explanation; a server that
|
|
15
|
+
// answers every call with the `aximo configure` hint tells the agent what
|
|
16
|
+
// to do. The key is re-read on each request, so no restart is needed after
|
|
17
|
+
// configuring. Diagnostics go to stderr only.
|
|
14
18
|
try {
|
|
15
19
|
getApiKey();
|
|
16
20
|
}
|
|
17
21
|
catch (err) {
|
|
18
22
|
const message = err instanceof Error ? err.message : String(err);
|
|
19
23
|
process.stderr.write(`aximo mcp: ${message}\n`);
|
|
20
|
-
process.
|
|
21
|
-
return;
|
|
24
|
+
process.stderr.write("aximo mcp: serving anyway; tools return this error until a key is set\n");
|
|
22
25
|
}
|
|
23
26
|
const baseUrl = getBaseUrl();
|
|
24
27
|
process.stderr.write(`aximo mcp: serving stdio MCP against ${baseUrl}\n`);
|
|
@@ -24,15 +24,18 @@ async function connect() {
|
|
|
24
24
|
};
|
|
25
25
|
}
|
|
26
26
|
describe("createMcpServer wiring", () => {
|
|
27
|
-
it("exposes all
|
|
27
|
+
it("exposes all 36 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();
|
|
31
31
|
expect(tools.map((t) => t.name).sort()).toEqual([
|
|
32
32
|
"aximo_get_case",
|
|
33
33
|
"aximo_get_session",
|
|
34
|
+
"aximo_wait_for_session",
|
|
34
35
|
"aximo_get_session_capture",
|
|
35
36
|
"aximo_get_session_snapshot",
|
|
37
|
+
"aximo_export_session_pdf",
|
|
38
|
+
"aximo_export_session_screenshots",
|
|
36
39
|
"aximo_get_session_messages",
|
|
37
40
|
"aximo_get_session_screenshots",
|
|
38
41
|
"aximo_get_session_timeline",
|
|
@@ -48,6 +51,7 @@ describe("createMcpServer wiring", () => {
|
|
|
48
51
|
"aximo_restore_case",
|
|
49
52
|
"aximo_delete_case",
|
|
50
53
|
"aximo_run_case",
|
|
54
|
+
"aximo_create_session",
|
|
51
55
|
"aximo_create_plan",
|
|
52
56
|
"aximo_add_case_to_plan",
|
|
53
57
|
"aximo_remove_case_from_plan",
|
|
@@ -88,6 +92,25 @@ describe("createMcpServer wiring", () => {
|
|
|
88
92
|
await cleanup();
|
|
89
93
|
}
|
|
90
94
|
});
|
|
95
|
+
it("serves the full tool list without an API key and answers calls with the configure hint", async () => {
|
|
96
|
+
const prev = process.env["AXIMO_API_KEY"];
|
|
97
|
+
delete process.env["AXIMO_API_KEY"];
|
|
98
|
+
const { client, cleanup } = await connect();
|
|
99
|
+
try {
|
|
100
|
+
const { tools } = await client.listTools();
|
|
101
|
+
expect(tools.length).toBe(36);
|
|
102
|
+
const result = await client.callTool({ name: "aximo_list_projects", arguments: {} });
|
|
103
|
+
expect(result.isError).toBe(true);
|
|
104
|
+
const content = result.content;
|
|
105
|
+
const payload = JSON.parse(content[0].text);
|
|
106
|
+
expect(payload.error).toBe("API key not configured");
|
|
107
|
+
expect(payload.hint).toBe("Run `aximo configure`, then call the tool again.");
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
process.env["AXIMO_API_KEY"] = prev;
|
|
111
|
+
await cleanup();
|
|
112
|
+
}
|
|
113
|
+
});
|
|
91
114
|
it("returns isError with a validation message when input fails the schema", async () => {
|
|
92
115
|
const { client, cleanup } = await connect();
|
|
93
116
|
try {
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import { isAbsolute } from "node:path";
|
|
1
2
|
import { z } from "zod";
|
|
2
3
|
import { request } from "../../client.js";
|
|
4
|
+
import { WAIT_DEFAULT_SECONDS, WAIT_MAX_SECONDS, waitForSession } from "./wait.js";
|
|
5
|
+
import { exportSession, SESSION_EXPORT_LOCALES, } from "../../session-export.js";
|
|
3
6
|
import { ARCHIVE_FIRST_HINT, featureHint, isArchiveFirstConflict, withDetails, } from "../../errors.js";
|
|
4
7
|
function ok(payload) {
|
|
5
8
|
return {
|
|
@@ -50,7 +53,7 @@ async function call(fn) {
|
|
|
50
53
|
if (typeof status === "number")
|
|
51
54
|
return err(`HTTP ${status}: ${message}`);
|
|
52
55
|
if (e.message.startsWith("No API key configured")) {
|
|
53
|
-
return err("API key not configured", "Run `aximo configure
|
|
56
|
+
return err("API key not configured", "Run `aximo configure`, then call the tool again.");
|
|
54
57
|
}
|
|
55
58
|
const cause = e.cause;
|
|
56
59
|
const networkCode = cause?.code;
|
|
@@ -80,6 +83,22 @@ const runAppsSchema = z
|
|
|
80
83
|
bundleId: z.string().optional(),
|
|
81
84
|
}))
|
|
82
85
|
.max(3);
|
|
86
|
+
const exportOutputPathSchema = z
|
|
87
|
+
.string()
|
|
88
|
+
.min(1)
|
|
89
|
+
.describe("Absolute file path to write, e.g. /tmp/result.pdf. Parent directories are created and an existing file is replaced.");
|
|
90
|
+
const exportLocaleSchema = z
|
|
91
|
+
.enum(SESSION_EXPORT_LOCALES)
|
|
92
|
+
.optional()
|
|
93
|
+
.describe("Language of the report labels. Default en.");
|
|
94
|
+
// The MCP server's working directory is wherever the client launched it, so a
|
|
95
|
+
// relative path would land somewhere the caller cannot predict.
|
|
96
|
+
function exportSessionTo(input, deps) {
|
|
97
|
+
if (!isAbsolute(input.outputPath)) {
|
|
98
|
+
throw new Error("outputPath must be an absolute path");
|
|
99
|
+
}
|
|
100
|
+
return exportSession(input, deps);
|
|
101
|
+
}
|
|
83
102
|
export function defineTools(deps = {}) {
|
|
84
103
|
return [
|
|
85
104
|
{
|
|
@@ -165,6 +184,22 @@ export function defineTools(deps = {}) {
|
|
|
165
184
|
return await request(`/api/sessions/${encodeURIComponent(sessionId)}`, {}, deps);
|
|
166
185
|
}),
|
|
167
186
|
},
|
|
187
|
+
{
|
|
188
|
+
name: "aximo_wait_for_session",
|
|
189
|
+
title: "Wait for session",
|
|
190
|
+
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.",
|
|
191
|
+
inputSchema: {
|
|
192
|
+
sessionId: z.string().uuid(),
|
|
193
|
+
timeoutSeconds: z
|
|
194
|
+
.number()
|
|
195
|
+
.int()
|
|
196
|
+
.min(1)
|
|
197
|
+
.max(WAIT_MAX_SECONDS)
|
|
198
|
+
.optional()
|
|
199
|
+
.describe(`Seconds to wait before returning. Default ${WAIT_DEFAULT_SECONDS}.`),
|
|
200
|
+
},
|
|
201
|
+
handler: async ({ sessionId, timeoutSeconds }) => call(() => waitForSession(sessionId, timeoutSeconds ?? WAIT_DEFAULT_SECONDS, deps)),
|
|
202
|
+
},
|
|
168
203
|
{
|
|
169
204
|
name: "aximo_get_session_messages",
|
|
170
205
|
title: "Get session messages",
|
|
@@ -240,6 +275,28 @@ export function defineTools(deps = {}) {
|
|
|
240
275
|
return await request(`/api/sessions/${encodeURIComponent(sessionId)}/snapshots/${encodeURIComponent(snapshotId)}`, {}, deps);
|
|
241
276
|
}),
|
|
242
277
|
},
|
|
278
|
+
{
|
|
279
|
+
name: "aximo_export_session_pdf",
|
|
280
|
+
title: "Export session result as PDF",
|
|
281
|
+
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.",
|
|
282
|
+
inputSchema: {
|
|
283
|
+
sessionId: z.string().uuid(),
|
|
284
|
+
outputPath: exportOutputPathSchema,
|
|
285
|
+
locale: exportLocaleSchema,
|
|
286
|
+
},
|
|
287
|
+
handler: async ({ sessionId, outputPath, locale }) => call(() => exportSessionTo({ sessionId, format: "pdf", outputPath, locale }, deps)),
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
name: "aximo_export_session_screenshots",
|
|
291
|
+
title: "Export session screenshots as ZIP",
|
|
292
|
+
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.",
|
|
293
|
+
inputSchema: {
|
|
294
|
+
sessionId: z.string().uuid(),
|
|
295
|
+
outputPath: exportOutputPathSchema,
|
|
296
|
+
locale: exportLocaleSchema,
|
|
297
|
+
},
|
|
298
|
+
handler: async ({ sessionId, outputPath, locale }) => call(() => exportSessionTo({ sessionId, format: "screenshots", outputPath, locale }, deps)),
|
|
299
|
+
},
|
|
243
300
|
{
|
|
244
301
|
name: "aximo_list_cases",
|
|
245
302
|
title: "List test cases",
|
|
@@ -398,6 +455,49 @@ export function defineTools(deps = {}) {
|
|
|
398
455
|
return await request(`/api/triggers/cases/${encodeURIComponent(caseId)}/run`, { method: "POST", body }, deps);
|
|
399
456
|
}),
|
|
400
457
|
},
|
|
458
|
+
{
|
|
459
|
+
name: "aximo_create_session",
|
|
460
|
+
title: "Create session",
|
|
461
|
+
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.",
|
|
462
|
+
inputSchema: {
|
|
463
|
+
title: z.string().min(1).max(500).describe("Shown in the session list."),
|
|
464
|
+
prompt: z
|
|
465
|
+
.string()
|
|
466
|
+
.min(1)
|
|
467
|
+
.max(10000)
|
|
468
|
+
.describe("What to test, in plain language: the steps and what counts as a pass."),
|
|
469
|
+
projectId: z.string().uuid(),
|
|
470
|
+
platform: z.enum(["desktop", "android", "ios"]).optional().describe("Default desktop."),
|
|
471
|
+
model: z
|
|
472
|
+
.string()
|
|
473
|
+
.optional()
|
|
474
|
+
.describe("Agent model (e.g. 'sonnet', 'haiku', 'auto'). Server-validated."),
|
|
475
|
+
startUrl: z
|
|
476
|
+
.string()
|
|
477
|
+
.url()
|
|
478
|
+
.optional()
|
|
479
|
+
.describe("Web sessions on cloud browsers: first URL to open."),
|
|
480
|
+
tags: z.array(z.string().max(64)).max(10).optional(),
|
|
481
|
+
hostnameOverrideIds: z.array(z.string().uuid()).optional(),
|
|
482
|
+
userDeviceId: z
|
|
483
|
+
.string()
|
|
484
|
+
.uuid()
|
|
485
|
+
.optional()
|
|
486
|
+
.describe("A user-owned (BYOD) device id; runs there instead of the cloud."),
|
|
487
|
+
deviceArn: z.string().optional().describe("Mobile: encrypted Device Farm device token."),
|
|
488
|
+
deviceName: z.string().optional(),
|
|
489
|
+
uploadArn: z.string().optional().describe("Mobile: encrypted app upload token."),
|
|
490
|
+
uploadType: z.enum(["own_app", "existing", "sample"]).optional(),
|
|
491
|
+
appName: z.string().optional(),
|
|
492
|
+
bundleId: z.string().optional(),
|
|
493
|
+
apps: runAppsSchema
|
|
494
|
+
.optional()
|
|
495
|
+
.describe("Mobile multi-app sessions: up to 3 apps; takes precedence over the single-app fields."),
|
|
496
|
+
},
|
|
497
|
+
handler: async (body) => call(async () => {
|
|
498
|
+
return await request("/api/sessions", { method: "POST", body }, deps);
|
|
499
|
+
}),
|
|
500
|
+
},
|
|
401
501
|
{
|
|
402
502
|
name: "aximo_create_plan",
|
|
403
503
|
title: "Create test plan",
|
|
@@ -600,7 +700,7 @@ export function defineTools(deps = {}) {
|
|
|
600
700
|
{
|
|
601
701
|
name: "aximo_get_plan_run",
|
|
602
702
|
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.",
|
|
703
|
+
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
704
|
inputSchema: {
|
|
605
705
|
planId: z.string().uuid(),
|
|
606
706
|
planRunId: z.string().uuid(),
|
|
@@ -1,4 +1,7 @@
|
|
|
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
6
|
import { ARCHIVE_FIRST_HINT, handleApiError, isArchiveFirstConflict } from "../../errors.js";
|
|
4
7
|
import { HttpResponse, http, server, TEST_BASE_URL } from "../../test-utils.js";
|
|
@@ -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,7 @@ describe("defineTools", () => {
|
|
|
42
48
|
"aximo_restore_case",
|
|
43
49
|
"aximo_delete_case",
|
|
44
50
|
"aximo_run_case",
|
|
51
|
+
"aximo_create_session",
|
|
45
52
|
"aximo_create_plan",
|
|
46
53
|
"aximo_add_case_to_plan",
|
|
47
54
|
"aximo_remove_case_from_plan",
|
|
@@ -247,6 +254,72 @@ describe("aximo_update_case", () => {
|
|
|
247
254
|
expect(parsed).not.toHaveProperty("startUrl");
|
|
248
255
|
});
|
|
249
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
|
+
});
|
|
322
|
+
});
|
|
250
323
|
describe("aximo_run_case", () => {
|
|
251
324
|
const caseId = "77777777-7777-4777-9777-777777777777";
|
|
252
325
|
it("POSTs /api/triggers/cases/:id/run with the per-run overrides and returns sessionId", async () => {
|
|
@@ -1037,3 +1110,53 @@ describe("409 handling parity between the CLI and MCP error mappers", () => {
|
|
|
1037
1110
|
}
|
|
1038
1111
|
});
|
|
1039
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,30 @@
|
|
|
1
|
+
import type { ClientDeps } from "../../client.js";
|
|
2
|
+
import { type SessionExportResult } from "../../session-export.js";
|
|
3
|
+
import type { Envelope } from "../../types.js";
|
|
4
|
+
export interface SessionExportOptions {
|
|
5
|
+
sessionId?: unknown;
|
|
6
|
+
format?: unknown;
|
|
7
|
+
output?: unknown;
|
|
8
|
+
locale?: unknown;
|
|
9
|
+
}
|
|
10
|
+
export declare function runSessionExport(options: SessionExportOptions, deps?: ClientDeps): Promise<Envelope<SessionExportResult>>;
|
|
11
|
+
declare const _default: import("citty").CommandDef<{
|
|
12
|
+
readonly sessionId: {
|
|
13
|
+
readonly type: "positional";
|
|
14
|
+
readonly description: "Session ID";
|
|
15
|
+
readonly required: true;
|
|
16
|
+
};
|
|
17
|
+
readonly format: {
|
|
18
|
+
readonly type: "string";
|
|
19
|
+
readonly description: "pdf | screenshots (default: pdf)";
|
|
20
|
+
};
|
|
21
|
+
readonly output: {
|
|
22
|
+
readonly type: "string";
|
|
23
|
+
readonly description: "File to write (default: <sessionId>.pdf or <sessionId>-screenshots.zip)";
|
|
24
|
+
};
|
|
25
|
+
readonly locale: {
|
|
26
|
+
readonly type: "string";
|
|
27
|
+
readonly description: "Report language: en | ja | ko (default: en)";
|
|
28
|
+
};
|
|
29
|
+
}>;
|
|
30
|
+
export default _default;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { defineCommand } from "citty";
|
|
2
|
+
import { handleApiError } from "../../errors.js";
|
|
3
|
+
import { formatSuccess, printResult, validationError } from "../../output.js";
|
|
4
|
+
import { defaultExportFileName, exportSession, SESSION_EXPORT_FORMATS, SESSION_EXPORT_LOCALES, } from "../../session-export.js";
|
|
5
|
+
import { optionalString, requireId } from "../../validate.js";
|
|
6
|
+
function parseOneOf(name, allowed, value) {
|
|
7
|
+
const raw = optionalString(name, value);
|
|
8
|
+
if (raw === undefined)
|
|
9
|
+
return undefined;
|
|
10
|
+
if (!allowed.includes(raw)) {
|
|
11
|
+
throw new Error(`${name} must be one of: ${allowed.join(", ")}`);
|
|
12
|
+
}
|
|
13
|
+
return raw;
|
|
14
|
+
}
|
|
15
|
+
export async function runSessionExport(options, deps) {
|
|
16
|
+
let sessionId;
|
|
17
|
+
let format;
|
|
18
|
+
let locale;
|
|
19
|
+
let output;
|
|
20
|
+
try {
|
|
21
|
+
sessionId = requireId("sessionId", options.sessionId);
|
|
22
|
+
format = parseOneOf("format", SESSION_EXPORT_FORMATS, options.format) ?? "pdf";
|
|
23
|
+
locale = parseOneOf("locale", SESSION_EXPORT_LOCALES, options.locale);
|
|
24
|
+
output = optionalString("output", options.output) ?? defaultExportFileName(sessionId, format);
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
return validationError(err);
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
return formatSuccess(await exportSession({ sessionId, format, outputPath: output, locale }, deps));
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
return handleApiError(err);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export default defineCommand({
|
|
37
|
+
meta: {
|
|
38
|
+
name: "export",
|
|
39
|
+
description: "Download a session's result as a PDF report or a ZIP of its screenshots",
|
|
40
|
+
},
|
|
41
|
+
args: {
|
|
42
|
+
sessionId: { type: "positional", description: "Session ID", required: true },
|
|
43
|
+
format: { type: "string", description: "pdf | screenshots (default: pdf)" },
|
|
44
|
+
output: {
|
|
45
|
+
type: "string",
|
|
46
|
+
description: "File to write (default: <sessionId>.pdf or <sessionId>-screenshots.zip)",
|
|
47
|
+
},
|
|
48
|
+
locale: { type: "string", description: "Report language: en | ja | ko (default: en)" },
|
|
49
|
+
},
|
|
50
|
+
async run({ args }) {
|
|
51
|
+
printResult(await runSessionExport({
|
|
52
|
+
sessionId: args.sessionId,
|
|
53
|
+
format: args.format,
|
|
54
|
+
output: args.output,
|
|
55
|
+
locale: args.locale,
|
|
56
|
+
}));
|
|
57
|
+
},
|
|
58
|
+
});
|
|
@@ -4,6 +4,7 @@ export default defineCommand({
|
|
|
4
4
|
subCommands: {
|
|
5
5
|
create: () => import("./create.js").then((m) => m.default),
|
|
6
6
|
get: () => import("./get.js").then((m) => m.default),
|
|
7
|
+
export: () => import("./export.js").then((m) => m.default),
|
|
7
8
|
"save-as-case": () => import("./save-as-case.js").then((m) => m.default),
|
|
8
9
|
},
|
|
9
10
|
});
|
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
1
4
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
2
5
|
import { server, http, HttpResponse, TEST_BASE_URL } from "../../test-utils.js";
|
|
3
6
|
import { runSessionCreate } from "./create.js";
|
|
7
|
+
import { runSessionExport } from "./export.js";
|
|
4
8
|
import { runSessionGet } from "./get.js";
|
|
5
9
|
beforeAll(() => {
|
|
6
10
|
process.env["AXIMO_API_KEY"] = "test-key";
|
|
@@ -126,3 +130,61 @@ describe("runSessionGet", () => {
|
|
|
126
130
|
expect(result.error.code).toBe("not_found");
|
|
127
131
|
});
|
|
128
132
|
});
|
|
133
|
+
describe("runSessionExport", () => {
|
|
134
|
+
const sessionId = "44444444-4444-4444-9444-444444444444";
|
|
135
|
+
const defaultPdf = resolve(`${sessionId}.pdf`);
|
|
136
|
+
const dir = mkdtempSync(join(tmpdir(), "aximo-session-export-"));
|
|
137
|
+
afterEach(() => rmSync(defaultPdf, { force: true }));
|
|
138
|
+
afterAll(() => rmSync(dir, { recursive: true, force: true }));
|
|
139
|
+
it("defaults to a PDF named after the session in the working directory", async () => {
|
|
140
|
+
server.use(http.get(`${TEST_BASE_URL}/api/sessions/${sessionId}/export/pdf`, () => new HttpResponse("%PDF-1.3\n%%EOF\n")));
|
|
141
|
+
const result = await runSessionExport({ sessionId });
|
|
142
|
+
expect(result).toEqual({
|
|
143
|
+
ok: true,
|
|
144
|
+
data: { sessionId, format: "pdf", path: defaultPdf, sizeBytes: 15 },
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
it("downloads the screenshots archive in the requested locale to --output", async () => {
|
|
148
|
+
let locale = null;
|
|
149
|
+
server.use(http.get(`${TEST_BASE_URL}/api/sessions/${sessionId}/export/screenshots`, ({ request }) => {
|
|
150
|
+
locale = new URL(request.url).searchParams.get("locale");
|
|
151
|
+
const eocd = Buffer.alloc(22);
|
|
152
|
+
eocd.writeUInt32LE(0x06054b50, 0);
|
|
153
|
+
return new HttpResponse(eocd);
|
|
154
|
+
}));
|
|
155
|
+
const output = join(dir, "shots.zip");
|
|
156
|
+
const result = await runSessionExport({
|
|
157
|
+
sessionId,
|
|
158
|
+
format: "screenshots",
|
|
159
|
+
output,
|
|
160
|
+
locale: "ja",
|
|
161
|
+
});
|
|
162
|
+
expect(result.ok).toBe(true);
|
|
163
|
+
if (result.ok)
|
|
164
|
+
expect(result.data.path).toBe(output);
|
|
165
|
+
expect(locale).toBe("ja");
|
|
166
|
+
});
|
|
167
|
+
it.each([
|
|
168
|
+
[{ format: "docx" }, /format must be one of: pdf, screenshots/],
|
|
169
|
+
[{ locale: "fr" }, /locale must be one of: en, ja, ko/],
|
|
170
|
+
[{ sessionId: "" }, /sessionId/],
|
|
171
|
+
])("rejects %o", async (overrides, message) => {
|
|
172
|
+
const result = await runSessionExport({ sessionId, ...overrides });
|
|
173
|
+
expect(result.ok).toBe(false);
|
|
174
|
+
if (!result.ok) {
|
|
175
|
+
expect(result.error.code).toBe("validation_error");
|
|
176
|
+
expect(result.error.message).toMatch(message);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
it("maps the API's 409 to a conflict envelope", async () => {
|
|
180
|
+
server.use(http.get(`${TEST_BASE_URL}/api/sessions/${sessionId}/export/screenshots`, () => HttpResponse.json({ error: "Session has no screenshots to export" }, { status: 409 })));
|
|
181
|
+
const result = await runSessionExport({
|
|
182
|
+
sessionId,
|
|
183
|
+
format: "screenshots",
|
|
184
|
+
output: join(dir, "x.zip"),
|
|
185
|
+
});
|
|
186
|
+
expect(result.ok).toBe(false);
|
|
187
|
+
if (!result.ok)
|
|
188
|
+
expect(result.error.code).toBe("conflict");
|
|
189
|
+
});
|
|
190
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type ClientDeps } from "./client.js";
|
|
2
|
+
export declare const SESSION_EXPORT_FORMATS: readonly ["pdf", "screenshots"];
|
|
3
|
+
export type SessionExportFormat = (typeof SESSION_EXPORT_FORMATS)[number];
|
|
4
|
+
export declare const SESSION_EXPORT_LOCALES: readonly ["en", "ja", "ko"];
|
|
5
|
+
export type SessionExportLocale = (typeof SESSION_EXPORT_LOCALES)[number];
|
|
6
|
+
export interface SessionExportInput {
|
|
7
|
+
sessionId: string;
|
|
8
|
+
format: SessionExportFormat;
|
|
9
|
+
outputPath: string;
|
|
10
|
+
locale?: SessionExportLocale | undefined;
|
|
11
|
+
}
|
|
12
|
+
export interface SessionExportResult {
|
|
13
|
+
sessionId: string;
|
|
14
|
+
format: SessionExportFormat;
|
|
15
|
+
path: string;
|
|
16
|
+
sizeBytes: number;
|
|
17
|
+
}
|
|
18
|
+
export declare function defaultExportFileName(sessionId: string, format: SessionExportFormat): string;
|
|
19
|
+
/**
|
|
20
|
+
* Both exports stream, so a server failure after the first bytes are flushed
|
|
21
|
+
* reaches the client as HTTP 200 and a truncated body. Each format's trailer is
|
|
22
|
+
* written last, so its presence is what proves the download is whole.
|
|
23
|
+
*/
|
|
24
|
+
export declare function isCompleteExport(path: string, format: SessionExportFormat): Promise<boolean>;
|
|
25
|
+
/**
|
|
26
|
+
* Downloads a session export to `outputPath`. The bytes land in a sibling
|
|
27
|
+
* `.part` file that is renamed into place only once verified complete, so a
|
|
28
|
+
* failed export neither leaves a corrupt file behind nor replaces an existing one.
|
|
29
|
+
*/
|
|
30
|
+
export declare function exportSession(input: SessionExportInput, deps?: ClientDeps): Promise<SessionExportResult>;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, open, rename, rm, stat } from "node:fs/promises";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { requestToFile } from "./client.js";
|
|
5
|
+
export const SESSION_EXPORT_FORMATS = ["pdf", "screenshots"];
|
|
6
|
+
export const SESSION_EXPORT_LOCALES = ["en", "ja", "ko"];
|
|
7
|
+
export function defaultExportFileName(sessionId, format) {
|
|
8
|
+
return format === "pdf" ? `${sessionId}.pdf` : `${sessionId}-screenshots.zip`;
|
|
9
|
+
}
|
|
10
|
+
const EOCD_SIGNATURE = 0x06054b50;
|
|
11
|
+
const EOCD_MIN_LENGTH = 22;
|
|
12
|
+
const EOCD_MAX_LENGTH = EOCD_MIN_LENGTH + 0xffff;
|
|
13
|
+
// PDF readers look for %%EOF within the last 1024 bytes.
|
|
14
|
+
const PDF_TRAILER_WINDOW = 1024;
|
|
15
|
+
async function readTail(path, maxBytes) {
|
|
16
|
+
const handle = await open(path, "r");
|
|
17
|
+
try {
|
|
18
|
+
const { size } = await handle.stat();
|
|
19
|
+
const length = Math.min(size, maxBytes);
|
|
20
|
+
const buffer = Buffer.alloc(length);
|
|
21
|
+
await handle.read(buffer, 0, length, size - length);
|
|
22
|
+
return buffer;
|
|
23
|
+
}
|
|
24
|
+
finally {
|
|
25
|
+
await handle.close();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Both exports stream, so a server failure after the first bytes are flushed
|
|
30
|
+
* reaches the client as HTTP 200 and a truncated body. Each format's trailer is
|
|
31
|
+
* written last, so its presence is what proves the download is whole.
|
|
32
|
+
*/
|
|
33
|
+
export async function isCompleteExport(path, format) {
|
|
34
|
+
if (format === "pdf") {
|
|
35
|
+
return (await readTail(path, PDF_TRAILER_WINDOW)).includes("%%EOF");
|
|
36
|
+
}
|
|
37
|
+
// The end-of-central-directory record must end the file exactly: its comment
|
|
38
|
+
// length has to account for every byte after it, which a signature that
|
|
39
|
+
// happens to appear inside compressed image data will not.
|
|
40
|
+
const tail = await readTail(path, EOCD_MAX_LENGTH);
|
|
41
|
+
for (let at = tail.length - EOCD_MIN_LENGTH; at >= 0; at--) {
|
|
42
|
+
if (tail.readUInt32LE(at) === EOCD_SIGNATURE &&
|
|
43
|
+
tail.readUInt16LE(at + 20) === tail.length - at - EOCD_MIN_LENGTH) {
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Downloads a session export to `outputPath`. The bytes land in a sibling
|
|
51
|
+
* `.part` file that is renamed into place only once verified complete, so a
|
|
52
|
+
* failed export neither leaves a corrupt file behind nor replaces an existing one.
|
|
53
|
+
*/
|
|
54
|
+
export async function exportSession(input, deps = {}) {
|
|
55
|
+
const path = resolve(input.outputPath);
|
|
56
|
+
await mkdir(dirname(path), { recursive: true });
|
|
57
|
+
const partialPath = `${path}.${randomUUID().slice(0, 8)}.part`;
|
|
58
|
+
const query = input.locale ? `?locale=${input.locale}` : "";
|
|
59
|
+
try {
|
|
60
|
+
await requestToFile(`/api/sessions/${encodeURIComponent(input.sessionId)}/export/${input.format}${query}`, partialPath, deps);
|
|
61
|
+
if (!(await isCompleteExport(partialPath, input.format))) {
|
|
62
|
+
throw new Error(`The ${input.format === "pdf" ? "PDF" : "screenshot archive"} download was truncated before it finished. Retry the export.`);
|
|
63
|
+
}
|
|
64
|
+
await rename(partialPath, path);
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
await rm(partialPath, { force: true });
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
const { size } = await stat(path);
|
|
71
|
+
return { sessionId: input.sessionId, format: input.format, path, sizeBytes: size };
|
|
72
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { mkdtempSync, readdirSync, rmSync, writeFileSync } 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 { exportSession, isCompleteExport } from "./session-export.js";
|
|
6
|
+
import { HttpResponse, http, server, TEST_BASE_URL } from "./test-utils.js";
|
|
7
|
+
const SESSION_ID = "11111111-1111-4111-9111-111111111111";
|
|
8
|
+
function eocd(comment = "") {
|
|
9
|
+
const record = Buffer.alloc(22);
|
|
10
|
+
record.writeUInt32LE(0x06054b50, 0);
|
|
11
|
+
record.writeUInt16LE(comment.length, 20);
|
|
12
|
+
return Buffer.concat([record, Buffer.from(comment)]);
|
|
13
|
+
}
|
|
14
|
+
let dir;
|
|
15
|
+
beforeAll(() => {
|
|
16
|
+
process.env["AXIMO_API_KEY"] = "test-key";
|
|
17
|
+
process.env["AXIMO_BASE_URL"] = TEST_BASE_URL;
|
|
18
|
+
server.listen();
|
|
19
|
+
});
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
dir = mkdtempSync(join(tmpdir(), "aximo-export-"));
|
|
22
|
+
});
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
server.resetHandlers();
|
|
25
|
+
rmSync(dir, { recursive: true, force: true });
|
|
26
|
+
});
|
|
27
|
+
afterAll(() => server.close());
|
|
28
|
+
function fileWith(bytes) {
|
|
29
|
+
const path = join(dir, "file");
|
|
30
|
+
writeFileSync(path, bytes);
|
|
31
|
+
return path;
|
|
32
|
+
}
|
|
33
|
+
describe("isCompleteExport", () => {
|
|
34
|
+
it("accepts a ZIP ending in its end-of-central-directory record", async () => {
|
|
35
|
+
const body = Buffer.concat([Buffer.from("PK\x03\x04 entries"), eocd()]);
|
|
36
|
+
expect(await isCompleteExport(fileWith(body), "screenshots")).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
it("accepts a ZIP whose record carries a comment", async () => {
|
|
39
|
+
const body = Buffer.concat([Buffer.from("PK\x03\x04 entries"), eocd("made by aximo")]);
|
|
40
|
+
expect(await isCompleteExport(fileWith(body), "screenshots")).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
it("rejects a ZIP cut off before its record", async () => {
|
|
43
|
+
const body = Buffer.concat([Buffer.from("PK\x03\x04 entries"), eocd()]).subarray(0, -1);
|
|
44
|
+
expect(await isCompleteExport(fileWith(body), "screenshots")).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
it("rejects a signature inside entry data that does not end the file", async () => {
|
|
47
|
+
const body = Buffer.concat([
|
|
48
|
+
Buffer.from("PK\x03\x04"),
|
|
49
|
+
eocd(),
|
|
50
|
+
Buffer.from("more entry bytes"),
|
|
51
|
+
]);
|
|
52
|
+
expect(await isCompleteExport(fileWith(body), "screenshots")).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
it("rejects a file that starts with the signature but is shorter than the record", async () => {
|
|
55
|
+
expect(await isCompleteExport(fileWith(eocd().subarray(0, -1)), "screenshots")).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
it("rejects an empty file", async () => {
|
|
58
|
+
expect(await isCompleteExport(fileWith(Buffer.alloc(0)), "screenshots")).toBe(false);
|
|
59
|
+
expect(await isCompleteExport(fileWith(Buffer.alloc(0)), "pdf")).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
it("checks a PDF for its %%EOF trailer", async () => {
|
|
62
|
+
expect(await isCompleteExport(fileWith(Buffer.from("%PDF-1.3\n...\n%%EOF\n")), "pdf")).toBe(true);
|
|
63
|
+
expect(await isCompleteExport(fileWith(Buffer.from("%PDF-1.3\n...stream")), "pdf")).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
describe("exportSession", () => {
|
|
67
|
+
it("streams the export to the path, creating parent directories", async () => {
|
|
68
|
+
let url;
|
|
69
|
+
server.use(http.get(`${TEST_BASE_URL}/api/sessions/${SESSION_ID}/export/pdf`, ({ request }) => {
|
|
70
|
+
url = new URL(request.url);
|
|
71
|
+
return new HttpResponse("%PDF-1.3\n%%EOF\n", {
|
|
72
|
+
headers: { "Content-Type": "application/pdf" },
|
|
73
|
+
});
|
|
74
|
+
}));
|
|
75
|
+
const outputPath = join(dir, "nested", "report.pdf");
|
|
76
|
+
const result = await exportSession({
|
|
77
|
+
sessionId: SESSION_ID,
|
|
78
|
+
format: "pdf",
|
|
79
|
+
outputPath,
|
|
80
|
+
locale: "ja",
|
|
81
|
+
});
|
|
82
|
+
expect(result).toEqual({
|
|
83
|
+
sessionId: SESSION_ID,
|
|
84
|
+
format: "pdf",
|
|
85
|
+
path: outputPath,
|
|
86
|
+
sizeBytes: 15,
|
|
87
|
+
});
|
|
88
|
+
expect(url?.searchParams.get("locale")).toBe("ja");
|
|
89
|
+
expect(readdirSync(join(dir, "nested"))).toEqual(["report.pdf"]);
|
|
90
|
+
});
|
|
91
|
+
it("deletes a truncated download and keeps the file it would have replaced", async () => {
|
|
92
|
+
server.use(http.get(`${TEST_BASE_URL}/api/sessions/${SESSION_ID}/export/screenshots`, () => new HttpResponse(Buffer.from("PK\x03\x04 half an archive"))));
|
|
93
|
+
const outputPath = join(dir, "shots.zip");
|
|
94
|
+
writeFileSync(outputPath, "previous export");
|
|
95
|
+
await expect(exportSession({ sessionId: SESSION_ID, format: "screenshots", outputPath })).rejects.toThrow(/truncated/);
|
|
96
|
+
expect(readdirSync(dir)).toEqual(["shots.zip"]);
|
|
97
|
+
});
|
|
98
|
+
it("leaves nothing behind when the API refuses the export", async () => {
|
|
99
|
+
server.use(http.get(`${TEST_BASE_URL}/api/sessions/${SESSION_ID}/export/screenshots`, () => HttpResponse.json({ error: "Session has no screenshots to export" }, { status: 409 })));
|
|
100
|
+
await expect(exportSession({
|
|
101
|
+
sessionId: SESSION_ID,
|
|
102
|
+
format: "screenshots",
|
|
103
|
+
outputPath: join(dir, "s.zip"),
|
|
104
|
+
})).rejects.toMatchObject({ status: 409, message: "Session has no screenshots to export" });
|
|
105
|
+
expect(readdirSync(dir)).toEqual([]);
|
|
106
|
+
});
|
|
107
|
+
});
|