@autifyhq/aximo-cli 0.1.0 → 0.3.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 +87 -50
- package/dist/cli.js +15 -0
- package/dist/commands/case/case.test.d.ts +1 -0
- package/dist/commands/case/case.test.js +86 -0
- package/dist/commands/case/create.d.ts +53 -0
- package/dist/commands/case/create.js +71 -0
- package/dist/commands/case/index.d.ts +2 -0
- package/dist/commands/case/index.js +7 -0
- package/dist/commands/device/index.js +4 -1
- package/dist/commands/device/list.js +4 -1
- package/dist/commands/device/register.js +1 -1
- package/dist/commands/device/status.js +4 -1
- package/dist/commands/mcp/index.d.ts +2 -0
- package/dist/commands/mcp/index.js +29 -0
- package/dist/commands/mcp/server.d.ts +3 -0
- package/dist/commands/mcp/server.js +19 -0
- package/dist/commands/mcp/server.test.d.ts +1 -0
- package/dist/commands/mcp/server.test.js +89 -0
- package/dist/commands/mcp/tools.d.ts +18 -0
- package/dist/commands/mcp/tools.js +268 -0
- package/dist/commands/mcp/tools.test.d.ts +1 -0
- package/dist/commands/mcp/tools.test.js +269 -0
- package/dist/commands/plan/add-case.d.ts +30 -0
- package/dist/commands/plan/add-case.js +58 -0
- package/dist/commands/plan/create.d.ts +31 -0
- package/dist/commands/plan/create.js +56 -0
- package/dist/commands/plan/index.d.ts +2 -0
- package/dist/commands/plan/index.js +9 -0
- package/dist/commands/plan/plan.test.d.ts +1 -0
- package/dist/commands/plan/plan.test.js +104 -0
- package/dist/commands/plan/remove-case.d.ts +26 -0
- package/dist/commands/plan/remove-case.js +43 -0
- package/dist/commands/schema.js +42 -3
- package/dist/commands/session/index.js +1 -0
- package/dist/commands/session/save-as-case.d.ts +43 -0
- package/dist/commands/session/save-as-case.js +69 -0
- package/dist/commands/session/save-as-case.test.d.ts +1 -0
- package/dist/commands/session/save-as-case.test.js +58 -0
- package/dist/index.d.ts +12 -7
- package/dist/index.js +12 -7
- package/dist/types.d.ts +147 -0
- package/package.json +4 -2
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
3
|
+
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
4
|
+
import { HttpResponse, http, server, TEST_BASE_URL } from "../../test-utils.js";
|
|
5
|
+
import { createMcpServer } from "./server.js";
|
|
6
|
+
beforeAll(() => {
|
|
7
|
+
process.env["AXIMO_API_KEY"] = "test-key";
|
|
8
|
+
process.env["AXIMO_BASE_URL"] = TEST_BASE_URL;
|
|
9
|
+
server.listen();
|
|
10
|
+
});
|
|
11
|
+
afterEach(() => server.resetHandlers());
|
|
12
|
+
afterAll(() => server.close());
|
|
13
|
+
async function connect() {
|
|
14
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
15
|
+
const mcpServer = createMcpServer();
|
|
16
|
+
const client = new Client({ name: "test-client", version: "0.0.0" }, { capabilities: {} });
|
|
17
|
+
await Promise.all([mcpServer.connect(serverTransport), client.connect(clientTransport)]);
|
|
18
|
+
return {
|
|
19
|
+
client,
|
|
20
|
+
cleanup: async () => {
|
|
21
|
+
await client.close();
|
|
22
|
+
await mcpServer.close();
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
describe("createMcpServer wiring", () => {
|
|
27
|
+
it("exposes all 15 tools through tools/list with valid JSON Schema", async () => {
|
|
28
|
+
const { client, cleanup } = await connect();
|
|
29
|
+
try {
|
|
30
|
+
const { tools } = await client.listTools();
|
|
31
|
+
expect(tools.map((t) => t.name).sort()).toEqual([
|
|
32
|
+
"aximo_get_case",
|
|
33
|
+
"aximo_get_session",
|
|
34
|
+
"aximo_get_session_capture",
|
|
35
|
+
"aximo_get_session_snapshot",
|
|
36
|
+
"aximo_get_session_messages",
|
|
37
|
+
"aximo_get_session_screenshots",
|
|
38
|
+
"aximo_get_session_timeline",
|
|
39
|
+
"aximo_list_cases",
|
|
40
|
+
"aximo_list_projects",
|
|
41
|
+
"aximo_list_sessions",
|
|
42
|
+
"aximo_create_case",
|
|
43
|
+
"aximo_create_plan",
|
|
44
|
+
"aximo_add_case_to_plan",
|
|
45
|
+
"aximo_remove_case_from_plan",
|
|
46
|
+
"aximo_save_session_as_case",
|
|
47
|
+
].sort());
|
|
48
|
+
const timeline = tools.find((t) => t.name === "aximo_get_session_timeline");
|
|
49
|
+
expect(timeline?.inputSchema?.required).toContain("sessionId");
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
await cleanup();
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
it("dispatches a tools/call and returns the backend payload", async () => {
|
|
56
|
+
const sessionId = "77777777-7777-4777-9777-777777777777";
|
|
57
|
+
const payload = { sessionId, stepCount: 0, steps: [] };
|
|
58
|
+
server.use(http.get(`${TEST_BASE_URL}/api/sessions/${sessionId}/timeline`, () => HttpResponse.json(payload)));
|
|
59
|
+
const { client, cleanup } = await connect();
|
|
60
|
+
try {
|
|
61
|
+
const result = await client.callTool({
|
|
62
|
+
name: "aximo_get_session_timeline",
|
|
63
|
+
arguments: { sessionId },
|
|
64
|
+
});
|
|
65
|
+
expect(result.isError).toBeFalsy();
|
|
66
|
+
const content = result.content;
|
|
67
|
+
const text = content[0].text;
|
|
68
|
+
expect(JSON.parse(text)).toEqual(payload);
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
await cleanup();
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
it("returns isError with a validation message when input fails the schema", async () => {
|
|
75
|
+
const { client, cleanup } = await connect();
|
|
76
|
+
try {
|
|
77
|
+
const result = await client.callTool({
|
|
78
|
+
name: "aximo_get_session_timeline",
|
|
79
|
+
arguments: { sessionId: "not-a-uuid" },
|
|
80
|
+
});
|
|
81
|
+
expect(result.isError).toBe(true);
|
|
82
|
+
const content = result.content;
|
|
83
|
+
expect(content[0].text).toMatch(/Invalid uuid|Input validation/);
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
await cleanup();
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { type ClientDeps } from "../../client.js";
|
|
3
|
+
interface ToolResult {
|
|
4
|
+
content: Array<{
|
|
5
|
+
type: "text";
|
|
6
|
+
text: string;
|
|
7
|
+
}>;
|
|
8
|
+
isError?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export interface Tool<TInput extends z.ZodRawShape = z.ZodRawShape> {
|
|
11
|
+
name: string;
|
|
12
|
+
title: string;
|
|
13
|
+
description: string;
|
|
14
|
+
inputSchema: TInput;
|
|
15
|
+
handler: (input: z.infer<z.ZodObject<TInput>>) => Promise<ToolResult>;
|
|
16
|
+
}
|
|
17
|
+
export declare function defineTools(deps?: ClientDeps): Tool[];
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { request } from "../../client.js";
|
|
3
|
+
function ok(payload) {
|
|
4
|
+
return {
|
|
5
|
+
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
|
|
6
|
+
};
|
|
7
|
+
}
|
|
8
|
+
function err(message, hint) {
|
|
9
|
+
return {
|
|
10
|
+
content: [
|
|
11
|
+
{
|
|
12
|
+
type: "text",
|
|
13
|
+
text: JSON.stringify({ error: message, ...(hint ? { hint } : {}) }, null, 2),
|
|
14
|
+
},
|
|
15
|
+
],
|
|
16
|
+
isError: true,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
async function call(fn) {
|
|
20
|
+
try {
|
|
21
|
+
return ok(await fn());
|
|
22
|
+
}
|
|
23
|
+
catch (e) {
|
|
24
|
+
if (!(e instanceof Error))
|
|
25
|
+
return err(String(e));
|
|
26
|
+
const status = e.status;
|
|
27
|
+
if (status === 401)
|
|
28
|
+
return err("Unauthorized: check AXIMO_API_KEY", "Run `aximo configure`.");
|
|
29
|
+
if (status === 403) {
|
|
30
|
+
return err(e.message || "Forbidden", "API key may not have access to this resource.");
|
|
31
|
+
}
|
|
32
|
+
if (status === 404)
|
|
33
|
+
return err(e.message || "Not found");
|
|
34
|
+
if (status === 429)
|
|
35
|
+
return err("Rate limited", "Aximo enforces a daily session limit.");
|
|
36
|
+
if (typeof status === "number")
|
|
37
|
+
return err(`HTTP ${status}: ${e.message}`);
|
|
38
|
+
if (e.message.startsWith("No API key configured")) {
|
|
39
|
+
return err("API key not configured", "Run `aximo configure` and restart the MCP server.");
|
|
40
|
+
}
|
|
41
|
+
const cause = e.cause;
|
|
42
|
+
const networkCode = cause?.code;
|
|
43
|
+
if (networkCode) {
|
|
44
|
+
return err(`Network error (${networkCode}): ${cause?.message ?? e.message}`, "Check AXIMO_BASE_URL and your network connection.");
|
|
45
|
+
}
|
|
46
|
+
return err(e.message);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function buildQuery(params) {
|
|
50
|
+
const out = {};
|
|
51
|
+
for (const [k, v] of Object.entries(params)) {
|
|
52
|
+
if (v !== undefined && v !== "")
|
|
53
|
+
out[k] = v;
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
export function defineTools(deps = {}) {
|
|
58
|
+
return [
|
|
59
|
+
{
|
|
60
|
+
name: "aximo_list_projects",
|
|
61
|
+
title: "List workspaces",
|
|
62
|
+
description: "List workspaces (projects) available to your organization. Use a project's id when listing sessions or cases.",
|
|
63
|
+
inputSchema: {},
|
|
64
|
+
handler: async () => call(async () => {
|
|
65
|
+
const res = await request("/api/organizations/projects", {}, deps);
|
|
66
|
+
return res;
|
|
67
|
+
}),
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: "aximo_list_sessions",
|
|
71
|
+
title: "List sessions",
|
|
72
|
+
description: "List sessions visible to your organization, newest first. Filter by projectId, caseId, or status. Paginated.",
|
|
73
|
+
inputSchema: {
|
|
74
|
+
projectId: z.string().uuid().optional(),
|
|
75
|
+
caseId: z.string().uuid().optional(),
|
|
76
|
+
status: z.string().optional(),
|
|
77
|
+
page: z.number().int().positive().optional(),
|
|
78
|
+
pageSize: z.number().int().positive().max(100).optional(),
|
|
79
|
+
},
|
|
80
|
+
handler: async (input) => call(async () => {
|
|
81
|
+
return await request("/api/sessions", { query: buildQuery(input) }, deps);
|
|
82
|
+
}),
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
name: "aximo_get_session",
|
|
86
|
+
title: "Get session",
|
|
87
|
+
description: "Get session metadata, status, summary, observations, credits used, and counts (screenshots, tool calls).",
|
|
88
|
+
inputSchema: {
|
|
89
|
+
sessionId: z.string().uuid(),
|
|
90
|
+
},
|
|
91
|
+
handler: async ({ sessionId }) => call(async () => {
|
|
92
|
+
return await request(`/api/sessions/${encodeURIComponent(sessionId)}`, {}, deps);
|
|
93
|
+
}),
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: "aximo_get_session_messages",
|
|
97
|
+
title: "Get session messages",
|
|
98
|
+
description: "Get the message timeline (tool calls, assistant text, user input) for a session. Cursor-paginated.",
|
|
99
|
+
inputSchema: {
|
|
100
|
+
sessionId: z.string().uuid(),
|
|
101
|
+
cursor: z.string().optional(),
|
|
102
|
+
limit: z.number().int().positive().max(100).optional(),
|
|
103
|
+
order: z.enum(["asc", "desc"]).optional(),
|
|
104
|
+
},
|
|
105
|
+
handler: async ({ sessionId, ...rest }) => call(async () => {
|
|
106
|
+
return await request(`/api/sessions/${encodeURIComponent(sessionId)}/messages`, { query: buildQuery(rest) }, deps);
|
|
107
|
+
}),
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
name: "aximo_get_session_screenshots",
|
|
111
|
+
title: "Get session screenshots",
|
|
112
|
+
description: "Get screenshots captured during a session, ordered by capture time. URLs are presigned (S3) or relative.",
|
|
113
|
+
inputSchema: {
|
|
114
|
+
sessionId: z.string().uuid(),
|
|
115
|
+
cursor: z.string().optional(),
|
|
116
|
+
limit: z.number().int().positive().max(100).optional(),
|
|
117
|
+
order: z.enum(["asc", "desc"]).optional(),
|
|
118
|
+
},
|
|
119
|
+
handler: async ({ sessionId, ...rest }) => call(async () => {
|
|
120
|
+
return await request(`/api/sessions/${encodeURIComponent(sessionId)}/screenshots`, { query: buildQuery(rest) }, deps);
|
|
121
|
+
}),
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
name: "aximo_get_session_timeline",
|
|
125
|
+
title: "Get session timeline",
|
|
126
|
+
description: "Get the merged step timeline: each step pairs a message (tool call, assistant turn, user input) with its screenshots. This is the recommended endpoint when generating a Playwright spec from a session.",
|
|
127
|
+
inputSchema: {
|
|
128
|
+
sessionId: z.string().uuid(),
|
|
129
|
+
},
|
|
130
|
+
handler: async ({ sessionId }) => call(async () => {
|
|
131
|
+
return await request(`/api/sessions/${encodeURIComponent(sessionId)}/timeline`, {}, deps);
|
|
132
|
+
}),
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
name: "aximo_get_session_capture",
|
|
136
|
+
title: "Get session CDP capture",
|
|
137
|
+
description: "Get the CDP-derived capture metadata for a session: ordered timestamped events (actions, navigations, network XHR/fetch, console, exceptions, snapshot triggers), browser context, and post-session storage state. By default snapshots[] contains metadata only (id, url, title, domBytes, axBytes) — call aximo_get_session_snapshot to fetch a single snapshot's DOM/AX content, or pass includeSnapshots: 'full' to inline all of them (can be multi-MB). Cookie values are redacted by default — pass includeCookieValues: true only when you genuinely need them (e.g. to emit a Playwright context.storageState()). Returns 404 if the org doesn't have the CDP capture feature flag enabled or the session ran without capture.",
|
|
138
|
+
inputSchema: {
|
|
139
|
+
sessionId: z.string().uuid(),
|
|
140
|
+
includeSnapshots: z
|
|
141
|
+
.enum(["none", "metadata", "full"])
|
|
142
|
+
.optional()
|
|
143
|
+
.describe("Default 'metadata'. Use 'full' only when you need DOM/AX content inline."),
|
|
144
|
+
includeCookieValues: z
|
|
145
|
+
.boolean()
|
|
146
|
+
.optional()
|
|
147
|
+
.describe("Default false. When true, cookie 'value' fields are returned unmasked — needed for Playwright storageState export."),
|
|
148
|
+
},
|
|
149
|
+
handler: async ({ sessionId, includeSnapshots, includeCookieValues }) => call(async () => {
|
|
150
|
+
const query = {};
|
|
151
|
+
if (includeSnapshots)
|
|
152
|
+
query["includeSnapshots"] = includeSnapshots;
|
|
153
|
+
if (includeCookieValues)
|
|
154
|
+
query["includeCookieValues"] = "true";
|
|
155
|
+
return await request(`/api/sessions/${encodeURIComponent(sessionId)}/capture`, { query }, deps);
|
|
156
|
+
}),
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
name: "aximo_get_session_snapshot",
|
|
160
|
+
title: "Get one DOM+AX snapshot",
|
|
161
|
+
description: "Fetch a single DOM + accessibility-tree snapshot by id (from aximo_get_session_capture's snapshots[] list). Use this instead of pulling the whole capture blob when you only need one page's structure (a single snapshot can be 100 KB – 5 MB).",
|
|
162
|
+
inputSchema: {
|
|
163
|
+
sessionId: z.string().uuid(),
|
|
164
|
+
snapshotId: z.string().uuid(),
|
|
165
|
+
},
|
|
166
|
+
handler: async ({ sessionId, snapshotId }) => call(async () => {
|
|
167
|
+
return await request(`/api/sessions/${encodeURIComponent(sessionId)}/snapshots/${encodeURIComponent(snapshotId)}`, {}, deps);
|
|
168
|
+
}),
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
name: "aximo_list_cases",
|
|
172
|
+
title: "List test cases",
|
|
173
|
+
description: "List saved test cases for a workspace. Filter by type, search, or directory.",
|
|
174
|
+
inputSchema: {
|
|
175
|
+
projectId: z.string().uuid(),
|
|
176
|
+
type: z.enum(["web", "mobile"]).optional(),
|
|
177
|
+
search: z.string().optional(),
|
|
178
|
+
directoryId: z.string().optional(),
|
|
179
|
+
page: z.number().int().positive().optional(),
|
|
180
|
+
pageSize: z.number().int().positive().max(100).optional(),
|
|
181
|
+
},
|
|
182
|
+
handler: async (input) => call(async () => {
|
|
183
|
+
return await request("/api/cases", { query: buildQuery(input) }, deps);
|
|
184
|
+
}),
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
name: "aximo_get_case",
|
|
188
|
+
title: "Get test case",
|
|
189
|
+
description: "Get the full scenario text and metadata for a saved test case. Useful for grounding generated Playwright specs.",
|
|
190
|
+
inputSchema: {
|
|
191
|
+
caseId: z.string().uuid(),
|
|
192
|
+
},
|
|
193
|
+
handler: async ({ caseId }) => call(async () => {
|
|
194
|
+
return await request(`/api/cases/${encodeURIComponent(caseId)}`, {}, deps);
|
|
195
|
+
}),
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
name: "aximo_create_case",
|
|
199
|
+
title: "Create test case",
|
|
200
|
+
description: "Create a saved test case in a workspace. The case is API-enabled, so it can be triggered immediately. Returns the new caseId.",
|
|
201
|
+
inputSchema: {
|
|
202
|
+
projectId: z.string().uuid(),
|
|
203
|
+
scenario: z.string().min(1),
|
|
204
|
+
type: z.enum(["web", "mobile", "desktop"]).optional(),
|
|
205
|
+
title: z.string().min(1).max(500).optional(),
|
|
206
|
+
description: z.string().optional(),
|
|
207
|
+
format: z.enum(["gherkin", "markdown", "plain_text"]).optional(),
|
|
208
|
+
startUrl: z.string().url().optional(),
|
|
209
|
+
directoryId: z.string().uuid().optional(),
|
|
210
|
+
},
|
|
211
|
+
handler: async (input) => call(async () => {
|
|
212
|
+
return await request("/api/cases", { method: "POST", body: input }, deps);
|
|
213
|
+
}),
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
name: "aximo_create_plan",
|
|
217
|
+
title: "Create test plan",
|
|
218
|
+
description: "Create an empty test plan in a workspace. Add cases with aximo_add_case_to_plan, then run it. Returns the new planId.",
|
|
219
|
+
inputSchema: {
|
|
220
|
+
projectId: z.string().uuid(),
|
|
221
|
+
title: z.string().min(1).max(500),
|
|
222
|
+
description: z.string().optional(),
|
|
223
|
+
},
|
|
224
|
+
handler: async (input) => call(async () => {
|
|
225
|
+
return await request("/api/plans", { method: "POST", body: input }, deps);
|
|
226
|
+
}),
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
name: "aximo_add_case_to_plan",
|
|
230
|
+
title: "Add case to plan",
|
|
231
|
+
description: "Append a test case to the end of a plan. The case must belong to the same workspace as the plan.",
|
|
232
|
+
inputSchema: {
|
|
233
|
+
planId: z.string().uuid(),
|
|
234
|
+
caseId: z.string().uuid(),
|
|
235
|
+
},
|
|
236
|
+
handler: async ({ planId, caseId }) => call(async () => {
|
|
237
|
+
return await request(`/api/plans/${encodeURIComponent(planId)}/cases`, { method: "POST", body: { caseId } }, deps);
|
|
238
|
+
}),
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
name: "aximo_remove_case_from_plan",
|
|
242
|
+
title: "Remove case from plan",
|
|
243
|
+
description: "Remove a test case from a plan. Idempotent.",
|
|
244
|
+
inputSchema: {
|
|
245
|
+
planId: z.string().uuid(),
|
|
246
|
+
caseId: z.string().uuid(),
|
|
247
|
+
},
|
|
248
|
+
handler: async ({ planId, caseId }) => call(async () => {
|
|
249
|
+
return await request(`/api/plans/${encodeURIComponent(planId)}/cases/${encodeURIComponent(caseId)}`, { method: "DELETE" }, deps);
|
|
250
|
+
}),
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
name: "aximo_save_session_as_case",
|
|
254
|
+
title: "Save session as test case",
|
|
255
|
+
description: "Persist a finished agent session as a reusable test case and link the two. The session must be in a terminal state and not already linked to a case. The scenario defaults to the session summary. Returns the new caseId and projectId.",
|
|
256
|
+
inputSchema: {
|
|
257
|
+
sessionId: z.string().uuid(),
|
|
258
|
+
title: z.string().min(1).max(500).optional(),
|
|
259
|
+
type: z.enum(["web", "mobile", "desktop"]).optional(),
|
|
260
|
+
scenario: z.string().min(1).optional(),
|
|
261
|
+
format: z.enum(["gherkin", "markdown", "plain_text"]).optional(),
|
|
262
|
+
},
|
|
263
|
+
handler: async ({ sessionId, ...body }) => call(async () => {
|
|
264
|
+
return await request(`/api/sessions/${encodeURIComponent(sessionId)}/save-as-case`, { method: "POST", body }, deps);
|
|
265
|
+
}),
|
|
266
|
+
},
|
|
267
|
+
];
|
|
268
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
2
|
+
import { HttpResponse, http, server, TEST_BASE_URL } from "../../test-utils.js";
|
|
3
|
+
import { defineTools } from "./tools.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
|
+
function byName(tools, name) {
|
|
12
|
+
const t = tools.find((x) => x.name === name);
|
|
13
|
+
if (!t)
|
|
14
|
+
throw new Error(`tool ${name} not found`);
|
|
15
|
+
return t;
|
|
16
|
+
}
|
|
17
|
+
function parsePayload(result) {
|
|
18
|
+
return JSON.parse(result.content[0].text);
|
|
19
|
+
}
|
|
20
|
+
describe("defineTools", () => {
|
|
21
|
+
it("registers the expected tool names", () => {
|
|
22
|
+
const names = defineTools().map((t) => t.name);
|
|
23
|
+
expect(names).toEqual([
|
|
24
|
+
"aximo_list_projects",
|
|
25
|
+
"aximo_list_sessions",
|
|
26
|
+
"aximo_get_session",
|
|
27
|
+
"aximo_get_session_messages",
|
|
28
|
+
"aximo_get_session_screenshots",
|
|
29
|
+
"aximo_get_session_timeline",
|
|
30
|
+
"aximo_get_session_capture",
|
|
31
|
+
"aximo_get_session_snapshot",
|
|
32
|
+
"aximo_list_cases",
|
|
33
|
+
"aximo_get_case",
|
|
34
|
+
"aximo_create_case",
|
|
35
|
+
"aximo_create_plan",
|
|
36
|
+
"aximo_add_case_to_plan",
|
|
37
|
+
"aximo_remove_case_from_plan",
|
|
38
|
+
"aximo_save_session_as_case",
|
|
39
|
+
]);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
describe("aximo_get_session_timeline", () => {
|
|
43
|
+
it("calls /api/sessions/:id/timeline and returns the JSON payload", async () => {
|
|
44
|
+
const sessionId = "11111111-1111-4111-9111-111111111111";
|
|
45
|
+
const payload = { sessionId, stepCount: 1, steps: [] };
|
|
46
|
+
let captured = null;
|
|
47
|
+
server.use(http.get(`${TEST_BASE_URL}/api/sessions/${sessionId}/timeline`, ({ request: req }) => {
|
|
48
|
+
captured = req.headers.get("x-api-key");
|
|
49
|
+
return HttpResponse.json(payload);
|
|
50
|
+
}));
|
|
51
|
+
const tool = byName(defineTools(), "aximo_get_session_timeline");
|
|
52
|
+
const result = await tool.handler({ sessionId });
|
|
53
|
+
expect(result.isError).toBeUndefined();
|
|
54
|
+
expect(parsePayload(result)).toEqual(payload);
|
|
55
|
+
expect(captured).toBe("test-key");
|
|
56
|
+
});
|
|
57
|
+
it("returns a structured error when the API returns 404", async () => {
|
|
58
|
+
const sessionId = "22222222-2222-4222-9222-222222222222";
|
|
59
|
+
server.use(http.get(`${TEST_BASE_URL}/api/sessions/${sessionId}/timeline`, () => HttpResponse.json({ error: "Session not found" }, { status: 404 })));
|
|
60
|
+
const tool = byName(defineTools(), "aximo_get_session_timeline");
|
|
61
|
+
const result = await tool.handler({ sessionId });
|
|
62
|
+
expect(result.isError).toBe(true);
|
|
63
|
+
expect(parsePayload(result)).toMatchObject({ error: "Session not found" });
|
|
64
|
+
});
|
|
65
|
+
it("returns a structured error when the API returns 401", async () => {
|
|
66
|
+
const sessionId = "33333333-3333-4333-9333-333333333333";
|
|
67
|
+
server.use(http.get(`${TEST_BASE_URL}/api/sessions/${sessionId}/timeline`, () => HttpResponse.json({ error: "Unauthorized" }, { status: 401 })));
|
|
68
|
+
const tool = byName(defineTools(), "aximo_get_session_timeline");
|
|
69
|
+
const result = await tool.handler({ sessionId });
|
|
70
|
+
expect(result.isError).toBe(true);
|
|
71
|
+
const payload = parsePayload(result);
|
|
72
|
+
expect(payload.error).toMatch(/Unauthorized/);
|
|
73
|
+
expect(payload.hint).toMatch(/configure/);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
describe("aximo_list_sessions", () => {
|
|
77
|
+
it("passes through query parameters", async () => {
|
|
78
|
+
let url = null;
|
|
79
|
+
server.use(http.get(`${TEST_BASE_URL}/api/sessions`, ({ request: req }) => {
|
|
80
|
+
url = new URL(req.url);
|
|
81
|
+
return HttpResponse.json({
|
|
82
|
+
items: [],
|
|
83
|
+
totalCount: 0,
|
|
84
|
+
page: 1,
|
|
85
|
+
pageSize: 25,
|
|
86
|
+
totalPages: 0,
|
|
87
|
+
});
|
|
88
|
+
}));
|
|
89
|
+
const tool = byName(defineTools(), "aximo_list_sessions");
|
|
90
|
+
await tool.handler({
|
|
91
|
+
projectId: "44444444-4444-4444-8444-444444444444",
|
|
92
|
+
status: "succeeded",
|
|
93
|
+
page: 2,
|
|
94
|
+
});
|
|
95
|
+
expect(url).not.toBeNull();
|
|
96
|
+
expect(url.searchParams.get("projectId")).toBe("44444444-4444-4444-8444-444444444444");
|
|
97
|
+
expect(url.searchParams.get("status")).toBe("succeeded");
|
|
98
|
+
expect(url.searchParams.get("page")).toBe("2");
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
describe("aximo_get_case", () => {
|
|
102
|
+
it("returns the case scenario payload", async () => {
|
|
103
|
+
const caseId = "55555555-5555-4555-9555-555555555555";
|
|
104
|
+
const payload = {
|
|
105
|
+
id: caseId,
|
|
106
|
+
projectId: "p1",
|
|
107
|
+
type: "web",
|
|
108
|
+
title: "Login flow",
|
|
109
|
+
scenario: "Given I navigate to login\nThen I am logged in",
|
|
110
|
+
format: "gherkin",
|
|
111
|
+
};
|
|
112
|
+
server.use(http.get(`${TEST_BASE_URL}/api/cases/${caseId}`, () => HttpResponse.json(payload)));
|
|
113
|
+
const tool = byName(defineTools(), "aximo_get_case");
|
|
114
|
+
const result = await tool.handler({ caseId });
|
|
115
|
+
expect(parsePayload(result)).toEqual(payload);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
describe("aximo_list_cases", () => {
|
|
119
|
+
it("requires projectId at schema time", () => {
|
|
120
|
+
const tool = byName(defineTools(), "aximo_list_cases");
|
|
121
|
+
// biome-ignore lint/suspicious/noExplicitAny: zod runtime check
|
|
122
|
+
const parsed = tool.inputSchema.projectId.safeParse(undefined);
|
|
123
|
+
expect(parsed.success).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
describe("aximo_list_projects", () => {
|
|
127
|
+
it("calls /api/organizations/projects and returns the payload", async () => {
|
|
128
|
+
const payload = { projects: [{ id: "p1", name: "Default" }] };
|
|
129
|
+
let captured = null;
|
|
130
|
+
server.use(http.get(`${TEST_BASE_URL}/api/organizations/projects`, ({ request: req }) => {
|
|
131
|
+
captured = { method: req.method, url: req.url };
|
|
132
|
+
return HttpResponse.json(payload);
|
|
133
|
+
}));
|
|
134
|
+
const tool = byName(defineTools(), "aximo_list_projects");
|
|
135
|
+
const result = await tool.handler({});
|
|
136
|
+
expect(result.isError).toBeUndefined();
|
|
137
|
+
expect(parsePayload(result)).toEqual(payload);
|
|
138
|
+
expect(captured).not.toBeNull();
|
|
139
|
+
expect(captured.method).toBe("GET");
|
|
140
|
+
expect(new URL(captured.url).pathname).toBe("/api/organizations/projects");
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
describe("call() error mapping", () => {
|
|
144
|
+
const sessionId = "66666666-6666-4666-9666-666666666666";
|
|
145
|
+
it("maps 403 with the original message and a hint", async () => {
|
|
146
|
+
server.use(http.get(`${TEST_BASE_URL}/api/sessions/${sessionId}/timeline`, () => HttpResponse.json({ error: "Forbidden by policy" }, { status: 403 })));
|
|
147
|
+
const tool = byName(defineTools(), "aximo_get_session_timeline");
|
|
148
|
+
const result = await tool.handler({ sessionId });
|
|
149
|
+
expect(result.isError).toBe(true);
|
|
150
|
+
const payload = parsePayload(result);
|
|
151
|
+
expect(payload.error).toMatch(/Forbidden by policy|Forbidden/);
|
|
152
|
+
expect(payload.hint).toMatch(/access/i);
|
|
153
|
+
});
|
|
154
|
+
it("preserves the backend's 404 message instead of squashing to 'Not found'", async () => {
|
|
155
|
+
server.use(http.get(`${TEST_BASE_URL}/api/sessions/${sessionId}/timeline`, () => HttpResponse.json({ error: "Session was archived" }, { status: 404 })));
|
|
156
|
+
const tool = byName(defineTools(), "aximo_get_session_timeline");
|
|
157
|
+
const result = await tool.handler({ sessionId });
|
|
158
|
+
expect(result.isError).toBe(true);
|
|
159
|
+
const payload = parsePayload(result);
|
|
160
|
+
expect(payload.error).toBe("Session was archived");
|
|
161
|
+
});
|
|
162
|
+
it("flags 429 with the rate-limit hint", async () => {
|
|
163
|
+
server.use(http.get(`${TEST_BASE_URL}/api/sessions/${sessionId}/timeline`, () => HttpResponse.json({ error: "Too many requests" }, { status: 429 })));
|
|
164
|
+
const tool = byName(defineTools(), "aximo_get_session_timeline");
|
|
165
|
+
const result = await tool.handler({ sessionId });
|
|
166
|
+
expect(result.isError).toBe(true);
|
|
167
|
+
const payload = parsePayload(result);
|
|
168
|
+
expect(payload.error).toBe("Rate limited");
|
|
169
|
+
expect(payload.hint).toMatch(/daily/i);
|
|
170
|
+
});
|
|
171
|
+
it("distinguishes a 500 with the HTTP-prefixed message", async () => {
|
|
172
|
+
server.use(http.get(`${TEST_BASE_URL}/api/sessions/${sessionId}/timeline`, () => HttpResponse.json({ error: "Internal server error" }, { status: 500 })));
|
|
173
|
+
const tool = byName(defineTools(), "aximo_get_session_timeline");
|
|
174
|
+
const result = await tool.handler({ sessionId });
|
|
175
|
+
expect(result.isError).toBe(true);
|
|
176
|
+
const payload = parsePayload(result);
|
|
177
|
+
expect(payload.error).toMatch(/^HTTP 500/);
|
|
178
|
+
});
|
|
179
|
+
it("returns a config-error hint when the API key is missing mid-session", async () => {
|
|
180
|
+
const prev = process.env["AXIMO_API_KEY"];
|
|
181
|
+
delete process.env["AXIMO_API_KEY"];
|
|
182
|
+
try {
|
|
183
|
+
const tool = byName(defineTools(), "aximo_get_session_timeline");
|
|
184
|
+
const result = await tool.handler({ sessionId });
|
|
185
|
+
expect(result.isError).toBe(true);
|
|
186
|
+
const payload = parsePayload(result);
|
|
187
|
+
expect(payload.error).toBe("API key not configured");
|
|
188
|
+
expect(payload.hint).toMatch(/aximo configure/);
|
|
189
|
+
}
|
|
190
|
+
finally {
|
|
191
|
+
process.env["AXIMO_API_KEY"] = prev;
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
it("surfaces network failures with code + remediation hint", async () => {
|
|
195
|
+
// MSW intercepts and reshapes thrown errors, so inject a fetch that throws
|
|
196
|
+
// a fetch-style network error directly via ClientDeps.
|
|
197
|
+
const networkErr = new TypeError("fetch failed");
|
|
198
|
+
networkErr.cause = {
|
|
199
|
+
code: "ENOTFOUND",
|
|
200
|
+
message: "getaddrinfo ENOTFOUND aximo.test",
|
|
201
|
+
};
|
|
202
|
+
const fetchImpl = (() => Promise.reject(networkErr));
|
|
203
|
+
const tool = byName(defineTools({ fetchImpl }), "aximo_get_session_timeline");
|
|
204
|
+
const result = await tool.handler({ sessionId });
|
|
205
|
+
expect(result.isError).toBe(true);
|
|
206
|
+
const payload = parsePayload(result);
|
|
207
|
+
expect(payload.error).toMatch(/Network error \(ENOTFOUND\)/);
|
|
208
|
+
expect(payload.hint).toMatch(/AXIMO_BASE_URL/);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
describe("write tools", () => {
|
|
212
|
+
it("aximo_create_case POSTs the body to /api/cases", async () => {
|
|
213
|
+
let seen = null;
|
|
214
|
+
server.use(http.post(`${TEST_BASE_URL}/api/cases`, async ({ request: req }) => {
|
|
215
|
+
seen = { method: req.method, body: await req.json() };
|
|
216
|
+
return HttpResponse.json({ success: true, caseId: "c1" }, { status: 201 });
|
|
217
|
+
}));
|
|
218
|
+
const tool = byName(defineTools(), "aximo_create_case");
|
|
219
|
+
const result = await tool.handler({ projectId: "p1", scenario: "do a thing", type: "web" });
|
|
220
|
+
expect(result.isError).toBeUndefined();
|
|
221
|
+
expect(parsePayload(result)).toEqual({ success: true, caseId: "c1" });
|
|
222
|
+
expect(seen.method).toBe("POST");
|
|
223
|
+
expect(seen.body).toMatchObject({ projectId: "p1", scenario: "do a thing", type: "web" });
|
|
224
|
+
});
|
|
225
|
+
it("aximo_create_plan POSTs the body to /api/plans", async () => {
|
|
226
|
+
let body;
|
|
227
|
+
server.use(http.post(`${TEST_BASE_URL}/api/plans`, async ({ request: req }) => {
|
|
228
|
+
body = await req.json();
|
|
229
|
+
return HttpResponse.json({ success: true, planId: "pl1" }, { status: 201 });
|
|
230
|
+
}));
|
|
231
|
+
const tool = byName(defineTools(), "aximo_create_plan");
|
|
232
|
+
const result = await tool.handler({ projectId: "p1", title: "Regression" });
|
|
233
|
+
expect(parsePayload(result)).toEqual({ success: true, planId: "pl1" });
|
|
234
|
+
expect(body).toMatchObject({ projectId: "p1", title: "Regression" });
|
|
235
|
+
});
|
|
236
|
+
it("aximo_add_case_to_plan POSTs caseId to the plan-cases path", async () => {
|
|
237
|
+
let body;
|
|
238
|
+
server.use(http.post(`${TEST_BASE_URL}/api/plans/pl1/cases`, async ({ request: req }) => {
|
|
239
|
+
body = await req.json();
|
|
240
|
+
return HttpResponse.json({ success: true }, { status: 201 });
|
|
241
|
+
}));
|
|
242
|
+
const tool = byName(defineTools(), "aximo_add_case_to_plan");
|
|
243
|
+
const result = await tool.handler({ planId: "pl1", caseId: "c1" });
|
|
244
|
+
expect(parsePayload(result)).toEqual({ success: true });
|
|
245
|
+
expect(body).toEqual({ caseId: "c1" });
|
|
246
|
+
});
|
|
247
|
+
it("aximo_remove_case_from_plan DELETEs the plan-case path", async () => {
|
|
248
|
+
let method = null;
|
|
249
|
+
server.use(http.delete(`${TEST_BASE_URL}/api/plans/pl1/cases/c1`, ({ request: req }) => {
|
|
250
|
+
method = req.method;
|
|
251
|
+
return HttpResponse.json({ success: true });
|
|
252
|
+
}));
|
|
253
|
+
const tool = byName(defineTools(), "aximo_remove_case_from_plan");
|
|
254
|
+
const result = await tool.handler({ planId: "pl1", caseId: "c1" });
|
|
255
|
+
expect(parsePayload(result)).toEqual({ success: true });
|
|
256
|
+
expect(method).toBe("DELETE");
|
|
257
|
+
});
|
|
258
|
+
it("aximo_save_session_as_case POSTs to the save-as-case path without the id in the body", async () => {
|
|
259
|
+
let body;
|
|
260
|
+
server.use(http.post(`${TEST_BASE_URL}/api/sessions/s1/save-as-case`, async ({ request: req }) => {
|
|
261
|
+
body = await req.json();
|
|
262
|
+
return HttpResponse.json({ success: true, caseId: "c1", projectId: "p1" }, { status: 201 });
|
|
263
|
+
}));
|
|
264
|
+
const tool = byName(defineTools(), "aximo_save_session_as_case");
|
|
265
|
+
const result = await tool.handler({ sessionId: "s1", title: "Saved" });
|
|
266
|
+
expect(parsePayload(result)).toEqual({ success: true, caseId: "c1", projectId: "p1" });
|
|
267
|
+
expect(body).toEqual({ title: "Saved" });
|
|
268
|
+
});
|
|
269
|
+
});
|