@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
|
@@ -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
|
+
});
|
package/dist/commands/schema.js
CHANGED
|
@@ -42,6 +42,10 @@ export function getSchema() {
|
|
|
42
42
|
cmd("get", "GET /api/sessions/{sessionId}", [
|
|
43
43
|
arg("sessionId", "positional", "Session ID", true),
|
|
44
44
|
]),
|
|
45
|
+
cmd("cancel", "POST /api/sessions/{sessionId}/cancel", [
|
|
46
|
+
arg("sessionId", "positional", "Session ID", true),
|
|
47
|
+
arg("dry-run", "boolean", "Validate and show request without calling API"),
|
|
48
|
+
]),
|
|
45
49
|
cmd("save-as-case", "POST /api/sessions/{sessionId}/save-as-case", [
|
|
46
50
|
arg("sessionId", "positional", "Session ID", true),
|
|
47
51
|
arg("title", "string", ""),
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type ClientDeps } from "../../client.js";
|
|
2
|
+
import { type DryRunResult } from "../../mutate.js";
|
|
3
|
+
import type { CancelSessionResult, Envelope } from "../../types.js";
|
|
4
|
+
export interface SessionCancelOptions {
|
|
5
|
+
sessionId?: unknown;
|
|
6
|
+
dryRun?: unknown;
|
|
7
|
+
}
|
|
8
|
+
export declare function runSessionCancel(options: SessionCancelOptions, deps?: ClientDeps): Promise<Envelope<CancelSessionResult | DryRunResult>>;
|
|
9
|
+
declare const _default: import("citty").CommandDef<{
|
|
10
|
+
readonly sessionId: {
|
|
11
|
+
readonly type: "positional";
|
|
12
|
+
readonly description: "Session ID";
|
|
13
|
+
readonly required: true;
|
|
14
|
+
};
|
|
15
|
+
readonly "dry-run": {
|
|
16
|
+
readonly type: "boolean";
|
|
17
|
+
readonly description: "Validate and show request without calling API";
|
|
18
|
+
};
|
|
19
|
+
}>;
|
|
20
|
+
export default _default;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { defineCommand } from "citty";
|
|
2
|
+
import { request } from "../../client.js";
|
|
3
|
+
import { handleApiError } from "../../errors.js";
|
|
4
|
+
import { dryRunEnvelope } from "../../mutate.js";
|
|
5
|
+
import { formatSuccess, printResult, validationError } from "../../output.js";
|
|
6
|
+
import { requireId } from "../../validate.js";
|
|
7
|
+
export async function runSessionCancel(options, deps) {
|
|
8
|
+
let sessionId;
|
|
9
|
+
try {
|
|
10
|
+
sessionId = requireId("sessionId", options.sessionId);
|
|
11
|
+
}
|
|
12
|
+
catch (err) {
|
|
13
|
+
return validationError(err);
|
|
14
|
+
}
|
|
15
|
+
const path = `/api/sessions/${encodeURIComponent(sessionId)}/cancel`;
|
|
16
|
+
if (options.dryRun) {
|
|
17
|
+
return dryRunEnvelope("POST", path);
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
const res = await request(path, { method: "POST" }, deps ?? {});
|
|
21
|
+
return formatSuccess(res);
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
return handleApiError(err);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export default defineCommand({
|
|
28
|
+
meta: {
|
|
29
|
+
name: "cancel",
|
|
30
|
+
description: "Cancel a queued or running session (stops it on its device)",
|
|
31
|
+
},
|
|
32
|
+
args: {
|
|
33
|
+
sessionId: { type: "positional", description: "Session ID", required: true },
|
|
34
|
+
"dry-run": { type: "boolean", description: "Validate and show request without calling API" },
|
|
35
|
+
},
|
|
36
|
+
async run({ args }) {
|
|
37
|
+
printResult(await runSessionCancel({ sessionId: args.sessionId, dryRun: args["dry-run"] }));
|
|
38
|
+
},
|
|
39
|
+
});
|
|
@@ -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,8 @@ 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
|
+
cancel: () => import("./cancel.js").then((m) => m.default),
|
|
8
|
+
export: () => import("./export.js").then((m) => m.default),
|
|
7
9
|
"save-as-case": () => import("./save-as-case.js").then((m) => m.default),
|
|
8
10
|
},
|
|
9
11
|
});
|
|
@@ -1,6 +1,11 @@
|
|
|
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";
|
|
6
|
+
import { runSessionCancel } from "./cancel.js";
|
|
3
7
|
import { runSessionCreate } from "./create.js";
|
|
8
|
+
import { runSessionExport } from "./export.js";
|
|
4
9
|
import { runSessionGet } from "./get.js";
|
|
5
10
|
beforeAll(() => {
|
|
6
11
|
process.env["AXIMO_API_KEY"] = "test-key";
|
|
@@ -126,3 +131,98 @@ describe("runSessionGet", () => {
|
|
|
126
131
|
expect(result.error.code).toBe("not_found");
|
|
127
132
|
});
|
|
128
133
|
});
|
|
134
|
+
describe("runSessionCancel", () => {
|
|
135
|
+
it("POSTs the cancel endpoint and returns the terminated session", async () => {
|
|
136
|
+
let method;
|
|
137
|
+
server.use(http.post(`${TEST_BASE_URL}/api/sessions/s1/cancel`, ({ request: req }) => {
|
|
138
|
+
method = req.method;
|
|
139
|
+
return HttpResponse.json({ success: true, sessionId: "s1", status: "terminated" });
|
|
140
|
+
}));
|
|
141
|
+
const result = await runSessionCancel({ sessionId: "s1" });
|
|
142
|
+
expect(method).toBe("POST");
|
|
143
|
+
expect(result).toEqual({
|
|
144
|
+
ok: true,
|
|
145
|
+
data: { success: true, sessionId: "s1", status: "terminated" },
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
it("shows the request without calling the API on --dry-run", async () => {
|
|
149
|
+
const result = await runSessionCancel({ sessionId: "s1", dryRun: true });
|
|
150
|
+
expect(result).toEqual({
|
|
151
|
+
ok: true,
|
|
152
|
+
data: { dryRun: true, method: "POST", url: `${TEST_BASE_URL}/api/sessions/s1/cancel` },
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
it("rejects an empty id", async () => {
|
|
156
|
+
const result = await runSessionCancel({ sessionId: "" });
|
|
157
|
+
expect(result.ok).toBe(false);
|
|
158
|
+
if (!result.ok)
|
|
159
|
+
expect(result.error.code).toBe("validation_error");
|
|
160
|
+
});
|
|
161
|
+
it("maps a finished session to conflict without the archive hint", async () => {
|
|
162
|
+
server.use(http.post(`${TEST_BASE_URL}/api/sessions/s1/cancel`, () => HttpResponse.json({ error: "Cannot cancel a session that has already finished" }, { status: 409 })));
|
|
163
|
+
const result = await runSessionCancel({ sessionId: "s1" });
|
|
164
|
+
expect(result.ok).toBe(false);
|
|
165
|
+
if (!result.ok) {
|
|
166
|
+
expect(result.error.code).toBe("conflict");
|
|
167
|
+
expect(result.error.hint).toBeUndefined();
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
describe("runSessionExport", () => {
|
|
172
|
+
const sessionId = "44444444-4444-4444-9444-444444444444";
|
|
173
|
+
const defaultPdf = resolve(`${sessionId}.pdf`);
|
|
174
|
+
const dir = mkdtempSync(join(tmpdir(), "aximo-session-export-"));
|
|
175
|
+
afterEach(() => rmSync(defaultPdf, { force: true }));
|
|
176
|
+
afterAll(() => rmSync(dir, { recursive: true, force: true }));
|
|
177
|
+
it("defaults to a PDF named after the session in the working directory", async () => {
|
|
178
|
+
server.use(http.get(`${TEST_BASE_URL}/api/sessions/${sessionId}/export/pdf`, () => new HttpResponse("%PDF-1.3\n%%EOF\n")));
|
|
179
|
+
const result = await runSessionExport({ sessionId });
|
|
180
|
+
expect(result).toEqual({
|
|
181
|
+
ok: true,
|
|
182
|
+
data: { sessionId, format: "pdf", path: defaultPdf, sizeBytes: 15 },
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
it("downloads the screenshots archive in the requested locale to --output", async () => {
|
|
186
|
+
let locale = null;
|
|
187
|
+
server.use(http.get(`${TEST_BASE_URL}/api/sessions/${sessionId}/export/screenshots`, ({ request }) => {
|
|
188
|
+
locale = new URL(request.url).searchParams.get("locale");
|
|
189
|
+
const eocd = Buffer.alloc(22);
|
|
190
|
+
eocd.writeUInt32LE(0x06054b50, 0);
|
|
191
|
+
return new HttpResponse(eocd);
|
|
192
|
+
}));
|
|
193
|
+
const output = join(dir, "shots.zip");
|
|
194
|
+
const result = await runSessionExport({
|
|
195
|
+
sessionId,
|
|
196
|
+
format: "screenshots",
|
|
197
|
+
output,
|
|
198
|
+
locale: "ja",
|
|
199
|
+
});
|
|
200
|
+
expect(result.ok).toBe(true);
|
|
201
|
+
if (result.ok)
|
|
202
|
+
expect(result.data.path).toBe(output);
|
|
203
|
+
expect(locale).toBe("ja");
|
|
204
|
+
});
|
|
205
|
+
it.each([
|
|
206
|
+
[{ format: "docx" }, /format must be one of: pdf, screenshots/],
|
|
207
|
+
[{ locale: "fr" }, /locale must be one of: en, ja, ko/],
|
|
208
|
+
[{ sessionId: "" }, /sessionId/],
|
|
209
|
+
])("rejects %o", async (overrides, message) => {
|
|
210
|
+
const result = await runSessionExport({ sessionId, ...overrides });
|
|
211
|
+
expect(result.ok).toBe(false);
|
|
212
|
+
if (!result.ok) {
|
|
213
|
+
expect(result.error.code).toBe("validation_error");
|
|
214
|
+
expect(result.error.message).toMatch(message);
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
it("maps the API's 409 to a conflict envelope", async () => {
|
|
218
|
+
server.use(http.get(`${TEST_BASE_URL}/api/sessions/${sessionId}/export/screenshots`, () => HttpResponse.json({ error: "Session has no screenshots to export" }, { status: 409 })));
|
|
219
|
+
const result = await runSessionExport({
|
|
220
|
+
sessionId,
|
|
221
|
+
format: "screenshots",
|
|
222
|
+
output: join(dir, "x.zip"),
|
|
223
|
+
});
|
|
224
|
+
expect(result.ok).toBe(false);
|
|
225
|
+
if (!result.ok)
|
|
226
|
+
expect(result.error.code).toBe("conflict");
|
|
227
|
+
});
|
|
228
|
+
});
|
package/dist/errors.d.ts
CHANGED
|
@@ -2,6 +2,8 @@ import type { ErrorEnvelope } from "./types.js";
|
|
|
2
2
|
export declare function describeDetails(details: unknown): string | undefined;
|
|
3
3
|
export declare function withDetails(message: string, details: unknown): string;
|
|
4
4
|
export declare function featureHint(message: string, details: unknown): string | undefined;
|
|
5
|
+
export declare const CREDIT_HINT = "The organization is out of credits. Ask an Aximo organization admin to add credits, then retry.";
|
|
6
|
+
export declare function creditHint(message: string): string | undefined;
|
|
5
7
|
export declare const ARCHIVE_FIRST_HINT = "Archive the resource first, then retry: `aximo case archive <caseId>` / `aximo plan archive <planId>` (MCP: aximo_archive_case / aximo_archive_plan).";
|
|
6
8
|
export declare function isArchiveFirstConflict(message: string): boolean;
|
|
7
9
|
export declare function handleApiError(error: unknown): ErrorEnvelope;
|
package/dist/errors.js
CHANGED
|
@@ -85,6 +85,10 @@ export function featureHint(message, details) {
|
|
|
85
85
|
}
|
|
86
86
|
return undefined;
|
|
87
87
|
}
|
|
88
|
+
export const CREDIT_HINT = "The organization is out of credits. Ask an Aximo organization admin to add credits, then retry.";
|
|
89
|
+
export function creditHint(message) {
|
|
90
|
+
return /insufficient credits/i.test(message) ? CREDIT_HINT : undefined;
|
|
91
|
+
}
|
|
88
92
|
const NOT_FOUND_HINT = "Check the ID exists and you have access. List resources: aximo project list / aximo device list / aximo upload list";
|
|
89
93
|
const AUTH_HINT = "Run `aximo configure` to save credentials, or set AXIMO_API_KEY via `export AXIMO_API_KEY=your-key` or `node --env-file=.env`.";
|
|
90
94
|
// 403 is a permission outcome, not a credentials problem, so it no longer routes
|
|
@@ -142,6 +146,7 @@ export function handleApiError(error) {
|
|
|
142
146
|
// when the server says which role is missing. Never the credentials hint: a
|
|
143
147
|
// 403 means the key is valid.
|
|
144
148
|
const hint = featureHint(error.message, details) ??
|
|
149
|
+
creditHint(error.message) ??
|
|
145
150
|
(/admin|owner/i.test(message) ? ADMIN_ROLE_HINT : undefined);
|
|
146
151
|
return formatError("forbidden", message, false, hint);
|
|
147
152
|
}
|
package/dist/errors.test.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { describeDetails, featureHint, handleApiError, withDetails } from "./errors.js";
|
|
2
|
+
import { CREDIT_HINT, describeDetails, featureHint, handleApiError, withDetails, } from "./errors.js";
|
|
3
3
|
function httpErr(status, message = `status ${status}`, retryAfter) {
|
|
4
4
|
const err = Object.assign(new Error(message), { status });
|
|
5
5
|
if (retryAfter !== undefined)
|
|
@@ -27,6 +27,11 @@ describe("handleApiError", () => {
|
|
|
27
27
|
expect(r.error.code).toBe("forbidden");
|
|
28
28
|
expect(r.error.hint).toContain("admin or owner");
|
|
29
29
|
});
|
|
30
|
+
it("adds the credit hint when the organization is out of credits", () => {
|
|
31
|
+
const r = handleApiError(httpErr(403, "Insufficient credits to start session"));
|
|
32
|
+
expect(r.error.code).toBe("forbidden");
|
|
33
|
+
expect(r.error.hint).toBe(CREDIT_HINT);
|
|
34
|
+
});
|
|
30
35
|
it("maps 404 to not_found with list hint", () => {
|
|
31
36
|
const r = handleApiError(httpErr(404));
|
|
32
37
|
expect(r.error.code).toBe("not_found");
|
package/dist/index.d.ts
CHANGED
|
@@ -12,6 +12,7 @@ export { runPlanRemoveCase } from "./commands/plan/remove-case.js";
|
|
|
12
12
|
export { runProjectList } from "./commands/project/list.js";
|
|
13
13
|
export { runSessionCreate } from "./commands/session/create.js";
|
|
14
14
|
export { runSessionGet } from "./commands/session/get.js";
|
|
15
|
+
export { runSessionCancel } from "./commands/session/cancel.js";
|
|
15
16
|
export { runSessionSaveAsCase } from "./commands/session/save-as-case.js";
|
|
16
17
|
export { runTriggerCaseGet } from "./commands/trigger/case/get.js";
|
|
17
18
|
export { runTriggerCaseRun } from "./commands/trigger/case/run.js";
|
package/dist/index.js
CHANGED
|
@@ -11,6 +11,7 @@ export { runPlanRemoveCase } from "./commands/plan/remove-case.js";
|
|
|
11
11
|
export { runProjectList } from "./commands/project/list.js";
|
|
12
12
|
export { runSessionCreate } from "./commands/session/create.js";
|
|
13
13
|
export { runSessionGet } from "./commands/session/get.js";
|
|
14
|
+
export { runSessionCancel } from "./commands/session/cancel.js";
|
|
14
15
|
export { runSessionSaveAsCase } from "./commands/session/save-as-case.js";
|
|
15
16
|
export { runTriggerCaseGet } from "./commands/trigger/case/get.js";
|
|
16
17
|
export { runTriggerCaseRun } from "./commands/trigger/case/run.js";
|
|
@@ -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
|
+
});
|