@autifyhq/aximo-cli 0.2.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 +3 -1
- 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/mcp/server.test.js +8 -3
- package/dist/commands/mcp/tools.js +70 -0
- package/dist/commands/mcp/tools.test.js +65 -1
- 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 +40 -1
- 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 +37 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -36,11 +36,7 @@ Get your API key from **Settings → API Keys** in the [Aximo dashboard](https:/
|
|
|
36
36
|
|
|
37
37
|
## Configuration
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
1. Value in `$AXIMO_HOME/config.json` (written by `aximo configure`)
|
|
42
|
-
2. Environment variable
|
|
43
|
-
3. Default (for base URL only: `https://aximo.autify.com`)
|
|
39
|
+
Each credential resolves independently: the value in `$AXIMO_HOME/config.json` (written by `aximo configure`) wins, then the environment variable, then the default.
|
|
44
40
|
|
|
45
41
|
| Variable | Required | Default | Notes |
|
|
46
42
|
| ---------------- | -------- | -------------------------- | ------------------------------------------------------------ |
|
|
@@ -48,60 +44,59 @@ The CLI resolves each credential independently in this order:
|
|
|
48
44
|
| `AXIMO_BASE_URL` | No | `https://aximo.autify.com` | Override for sandbox / self-hosted |
|
|
49
45
|
| `AXIMO_HOME` | No | `~/.aximo` | Data directory (config, device credentials, embedded Python) |
|
|
50
46
|
|
|
51
|
-
|
|
47
|
+
Set them interactively or pass flags — config is written to `$AXIMO_HOME/config.json` (mode `0600` where the OS supports it; Windows falls back to file ACLs), and an empty prompt keeps the existing value:
|
|
52
48
|
|
|
53
49
|
```bash
|
|
54
50
|
aximo configure # interactive prompts
|
|
55
51
|
aximo configure --base-url URL --api-key KEY # non-interactive
|
|
56
52
|
```
|
|
57
53
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
### Using environment variables
|
|
61
|
-
|
|
62
|
-
If you'd rather keep credentials out of a file (CI, ephemeral shells), set env vars:
|
|
54
|
+
Or keep credentials out of a file (CI, ephemeral shells) with env vars:
|
|
63
55
|
|
|
64
56
|
```bash
|
|
65
|
-
# macOS / Linux
|
|
66
57
|
export AXIMO_API_KEY=your-api-key
|
|
67
58
|
export AXIMO_BASE_URL=https://aximo-sbx.autify.dev # optional, prod is the default
|
|
68
|
-
|
|
69
|
-
# Windows (PowerShell, persistent)
|
|
70
|
-
[Environment]::SetEnvironmentVariable("AXIMO_API_KEY", "your-api-key", "User")
|
|
71
|
-
[Environment]::SetEnvironmentVariable("AXIMO_BASE_URL", "https://aximo-sbx.autify.dev", "User")
|
|
72
59
|
```
|
|
73
60
|
|
|
61
|
+
Credentials and device registrations are **per-environment** — to switch between production and sandbox (`https://aximo-sbx.autify.dev`), re-run `aximo configure` with the other `--base-url`.
|
|
62
|
+
|
|
74
63
|
## Commands
|
|
75
64
|
|
|
76
65
|
Run `aximo <command> --help` for detailed usage. `aximo schema [command.path]` returns machine-readable argument introspection (e.g. `aximo schema session.create`).
|
|
77
66
|
|
|
78
|
-
| Command
|
|
79
|
-
|
|
|
80
|
-
| `aximo configure`
|
|
81
|
-
| `aximo health check`
|
|
82
|
-
| `aximo project list`
|
|
83
|
-
| `aximo session create`
|
|
84
|
-
| `aximo session get`
|
|
85
|
-
| `aximo
|
|
86
|
-
| `aximo
|
|
87
|
-
| `aximo
|
|
88
|
-
| `aximo
|
|
89
|
-
| `aximo
|
|
90
|
-
| `aximo
|
|
91
|
-
| `aximo
|
|
92
|
-
| `aximo
|
|
93
|
-
| `aximo
|
|
94
|
-
| `aximo
|
|
95
|
-
| `aximo
|
|
96
|
-
| `aximo
|
|
97
|
-
| `aximo
|
|
98
|
-
| `aximo
|
|
99
|
-
| `aximo device
|
|
100
|
-
| `aximo device
|
|
101
|
-
| `aximo device
|
|
102
|
-
| `aximo device
|
|
103
|
-
| `aximo device
|
|
104
|
-
| `aximo
|
|
67
|
+
| Command | Description |
|
|
68
|
+
| ---------------------------- | ---------------------------------------------------- |
|
|
69
|
+
| `aximo configure` | Save credentials to `$AXIMO_HOME/config.json` |
|
|
70
|
+
| `aximo health check` | Check API reachability |
|
|
71
|
+
| `aximo project list` | List organization projects |
|
|
72
|
+
| `aximo session create` | Create and queue a test session |
|
|
73
|
+
| `aximo session get` | Get session status |
|
|
74
|
+
| `aximo session save-as-case` | Save a finished session as a reusable test case |
|
|
75
|
+
| `aximo case create` | Create a test case |
|
|
76
|
+
| `aximo plan create` | Create a test plan |
|
|
77
|
+
| `aximo plan add-case` | Add a case to a plan |
|
|
78
|
+
| `aximo plan remove-case` | Remove a case from a plan |
|
|
79
|
+
| `aximo trigger case run` | Trigger a dashboard-created test case |
|
|
80
|
+
| `aximo trigger case get` | Get triggered case status |
|
|
81
|
+
| `aximo trigger plan run` | Trigger a test plan |
|
|
82
|
+
| `aximo trigger plan get` | Get plan run status |
|
|
83
|
+
| `aximo upload create` | Create an upload slot for an APK/IPA |
|
|
84
|
+
| `aximo upload list` | List previously uploaded apps |
|
|
85
|
+
| `aximo upload get` | Poll upload processing status |
|
|
86
|
+
| `aximo upload put` | Upload binary file to the slot |
|
|
87
|
+
| `aximo upload submit` | Finalize upload and trigger processing |
|
|
88
|
+
| `aximo device list` | List mobile devices from Device Farm |
|
|
89
|
+
| `aximo device setup` | First-time BYOD onboarding (API + Python + register) |
|
|
90
|
+
| `aximo device register` | Register this machine as a BYOD device |
|
|
91
|
+
| `aximo device connect` | Start the BYOD agent and connect to the relay |
|
|
92
|
+
| `aximo device status` | List your organization's BYOD devices |
|
|
93
|
+
| `aximo device ping` | Check your BYOD device's server-side status |
|
|
94
|
+
| `aximo device doctor` | Diagnose BYOD setup (env, Python, relay, status) |
|
|
95
|
+
| `aximo device logs` | Show the BYOD agent log |
|
|
96
|
+
| `aximo device unregister` | Remove a BYOD device server-side + clear local creds |
|
|
97
|
+
| `aximo device reset` | Remove BYOD local state (Python, deps, credentials) |
|
|
98
|
+
| `aximo schema` | Show CLI surface as JSON |
|
|
99
|
+
| `aximo mcp` | Run a stdio MCP server for agents (see below) |
|
|
105
100
|
|
|
106
101
|
Mutating commands support `--json '<raw body>'` for direct OpenAPI payloads and `--dry-run` to preview requests without executing.
|
|
107
102
|
|
|
@@ -137,6 +132,32 @@ aximo session get <sessionId>
|
|
|
137
132
|
|
|
138
133
|
Desktop sessions default to `platform=desktop`, `model=sonnet`. Mobile sessions require both `--device-arn` and `--upload-arn`.
|
|
139
134
|
|
|
135
|
+
### Test cases
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
aximo case create --project-id <id> --scenario <text> \
|
|
139
|
+
[--type web|mobile|desktop] [--title <t>] [--description <d>] \
|
|
140
|
+
[--format gherkin|markdown|plain_text] [--start-url <url>] \
|
|
141
|
+
[--directory-id <id>] [--json '<body>'] [--dry-run]
|
|
142
|
+
|
|
143
|
+
aximo session save-as-case <sessionId> \
|
|
144
|
+
[--title <t>] [--type web|mobile|desktop] \
|
|
145
|
+
[--scenario <text>] [--format gherkin|markdown|plain_text] \
|
|
146
|
+
[--json '<body>'] [--dry-run]
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
`case create` returns a `caseId` you can run with `aximo trigger case run`. `session save-as-case` persists a finished session (terminal status, not yet linked to a case) as a reusable case, carrying over its scenario and run config. Cases created either way have API access enabled by default, so they are triggerable immediately.
|
|
150
|
+
|
|
151
|
+
### Test plans
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
aximo plan create --project-id <id> --title <t> [--description <d>] [--json '<body>'] [--dry-run]
|
|
155
|
+
aximo plan add-case <planId> --case-id <caseId> [--json '<body>'] [--dry-run]
|
|
156
|
+
aximo plan remove-case <planId> <caseId> [--dry-run]
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Build a runnable plan: `plan create` → `plan add-case` (repeat per case) → `aximo trigger plan run`. Plans created this way have API access enabled by default. Cases must belong to the same workspace as the plan.
|
|
160
|
+
|
|
140
161
|
### Triggers (test cases and plans)
|
|
141
162
|
|
|
142
163
|
```bash
|
|
@@ -146,7 +167,7 @@ aximo trigger plan run <planId> [...]
|
|
|
146
167
|
aximo trigger plan get <planId> <planRunId>
|
|
147
168
|
```
|
|
148
169
|
|
|
149
|
-
Triggers
|
|
170
|
+
Triggers a test case or plan. API access must be enabled on the resource — on by default for cases/plans created via this CLI; for dashboard-created ones, enable it in the Aximo dashboard settings. `trigger case run` returns a `sessionId` you can poll; `trigger plan run` returns a per-case breakdown.
|
|
150
171
|
|
|
151
172
|
### Uploads
|
|
152
173
|
|
|
@@ -335,17 +356,33 @@ The agent disables DPI-awareness and uses a calibrated scroll amount — if you
|
|
|
335
356
|
|
|
336
357
|
Default `$AXIMO_HOME` is `~/.aximo` on macOS/Linux, `%USERPROFILE%\.aximo` on Windows. Override via the `AXIMO_HOME` env var.
|
|
337
358
|
|
|
338
|
-
|
|
359
|
+
> Devices are registered per-environment (see [Configuration](#configuration)) — a device registered against sandbox can't connect to production and vice versa.
|
|
360
|
+
|
|
361
|
+
## MCP server
|
|
362
|
+
|
|
363
|
+
`aximo mcp` runs a [Model Context Protocol](https://modelcontextprotocol.io) server over stdio, exposing your recorded sessions, screenshots, and test cases to MCP-capable agents (Claude Code, Claude Desktop, Cursor, Windsurf, VS Code, …). The motivating use: let an agent read a finished session and generate a Playwright spec without you piping JSON between commands.
|
|
364
|
+
|
|
365
|
+
It reuses the same credentials as every other command, so run `aximo configure` (or set `AXIMO_API_KEY`) first.
|
|
366
|
+
|
|
367
|
+
**Claude Code** — one command:
|
|
339
368
|
|
|
340
369
|
```bash
|
|
341
|
-
|
|
342
|
-
|
|
370
|
+
claude mcp add aximo -- aximo mcp
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
**Other clients** — point them at the `aximo mcp` stdio command. Claude Desktop, Cursor, and Windsurf use the `mcpServers` key:
|
|
343
374
|
|
|
344
|
-
|
|
345
|
-
|
|
375
|
+
```json
|
|
376
|
+
{
|
|
377
|
+
"mcpServers": {
|
|
378
|
+
"aximo": { "command": "aximo", "args": ["mcp"] }
|
|
379
|
+
}
|
|
380
|
+
}
|
|
346
381
|
```
|
|
347
382
|
|
|
348
|
-
|
|
383
|
+
VS Code (Copilot agent mode) uses the same entry under a `servers` key in `.vscode/mcp.json`.
|
|
384
|
+
|
|
385
|
+
The server exposes 15 tools: 10 read-only ones for inspecting sessions and cases (`aximo_get_session_timeline` is the load-bearing one for Playwright export) plus 5 that author content (create cases and plans, save a session as a case). 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.
|
|
349
386
|
|
|
350
387
|
## Agent Skills
|
|
351
388
|
|
package/dist/cli.js
CHANGED
|
@@ -5,7 +5,7 @@ import pkg from "../package.json" with { type: "json" };
|
|
|
5
5
|
// is JSON-RPC.
|
|
6
6
|
const args = process.argv.slice(2);
|
|
7
7
|
if (args[0] === "mcp" && args.some((a) => a === "--help" || a === "-h")) {
|
|
8
|
-
process.stderr.write(`aximo mcp — stdio MCP server exposing
|
|
8
|
+
process.stderr.write(`aximo mcp — stdio MCP server exposing Aximo data plus create operations.\n` +
|
|
9
9
|
`\n` +
|
|
10
10
|
`Wire into an MCP client (e.g. Claude Desktop, Claude Code) by pointing it\n` +
|
|
11
11
|
`at this binary. Auth uses ~/.aximo/config.json (run \`aximo configure\`).\n` +
|
|
@@ -23,6 +23,8 @@ const main = defineCommand({
|
|
|
23
23
|
configure: () => import("./commands/configure.js").then((m) => m.default),
|
|
24
24
|
health: () => import("./commands/health/index.js").then((m) => m.default),
|
|
25
25
|
session: () => import("./commands/session/index.js").then((m) => m.default),
|
|
26
|
+
case: () => import("./commands/case/index.js").then((m) => m.default),
|
|
27
|
+
plan: () => import("./commands/plan/index.js").then((m) => m.default),
|
|
26
28
|
trigger: () => import("./commands/trigger/index.js").then((m) => m.default),
|
|
27
29
|
device: () => import("./commands/device/index.js").then((m) => m.default),
|
|
28
30
|
upload: () => import("./commands/upload/index.js").then((m) => m.default),
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
2
|
+
import { HttpResponse, http, server, TEST_BASE_URL } from "../../test-utils.js";
|
|
3
|
+
import { runCaseCreate } from "./create.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 MIN = {
|
|
12
|
+
projectId: "proj-1",
|
|
13
|
+
scenario: "Open the login page and verify it loads",
|
|
14
|
+
};
|
|
15
|
+
describe("runCaseCreate", () => {
|
|
16
|
+
it("POSTs body and returns success envelope", async () => {
|
|
17
|
+
let seen;
|
|
18
|
+
server.use(http.post(`${TEST_BASE_URL}/api/cases`, async ({ request: req }) => {
|
|
19
|
+
seen = await req.json();
|
|
20
|
+
return HttpResponse.json({ success: true, caseId: "c1" }, { status: 201 });
|
|
21
|
+
}));
|
|
22
|
+
const result = await runCaseCreate({ ...MIN, type: "web", title: "Login" });
|
|
23
|
+
expect(result.ok).toBe(true);
|
|
24
|
+
if (result.ok && !("dryRun" in result.data))
|
|
25
|
+
expect(result.data.caseId).toBe("c1");
|
|
26
|
+
expect(seen).toMatchObject({
|
|
27
|
+
projectId: "proj-1",
|
|
28
|
+
scenario: "Open the login page and verify it loads",
|
|
29
|
+
type: "web",
|
|
30
|
+
title: "Login",
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
it("rejects missing projectId", async () => {
|
|
34
|
+
const result = await runCaseCreate({ scenario: "x" });
|
|
35
|
+
expect(result.ok).toBe(false);
|
|
36
|
+
if (!result.ok)
|
|
37
|
+
expect(result.error.code).toBe("validation_error");
|
|
38
|
+
});
|
|
39
|
+
it("rejects missing scenario", async () => {
|
|
40
|
+
const result = await runCaseCreate({ projectId: "p1" });
|
|
41
|
+
expect(result.ok).toBe(false);
|
|
42
|
+
if (!result.ok)
|
|
43
|
+
expect(result.error.code).toBe("validation_error");
|
|
44
|
+
});
|
|
45
|
+
it("--json replaces individual flags and POSTs raw body", async () => {
|
|
46
|
+
let seen;
|
|
47
|
+
server.use(http.post(`${TEST_BASE_URL}/api/cases`, async ({ request: req }) => {
|
|
48
|
+
seen = await req.json();
|
|
49
|
+
return HttpResponse.json({ success: true, caseId: "c2" }, { status: 201 });
|
|
50
|
+
}));
|
|
51
|
+
const raw = JSON.stringify({ projectId: "p1", scenario: "raw", extra: true });
|
|
52
|
+
const result = await runCaseCreate({ json: raw });
|
|
53
|
+
expect(result.ok).toBe(true);
|
|
54
|
+
expect(seen).toEqual({ projectId: "p1", scenario: "raw", extra: true });
|
|
55
|
+
});
|
|
56
|
+
it("--dry-run returns request without calling API", async () => {
|
|
57
|
+
const result = await runCaseCreate({ ...MIN, dryRun: true });
|
|
58
|
+
expect(result.ok).toBe(true);
|
|
59
|
+
if (result.ok && "dryRun" in result.data) {
|
|
60
|
+
expect(result.data.dryRun).toBe(true);
|
|
61
|
+
expect(result.data.method).toBe("POST");
|
|
62
|
+
expect(result.data.url).toBe(`${TEST_BASE_URL}/api/cases`);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
it("returns unauthorized envelope when AXIMO_API_KEY unset", async () => {
|
|
66
|
+
const prev = process.env["AXIMO_API_KEY"];
|
|
67
|
+
delete process.env["AXIMO_API_KEY"];
|
|
68
|
+
try {
|
|
69
|
+
const result = await runCaseCreate(MIN);
|
|
70
|
+
expect(result.ok).toBe(false);
|
|
71
|
+
if (!result.ok)
|
|
72
|
+
expect(result.error.code).toBe("unauthorized");
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
if (prev !== undefined)
|
|
76
|
+
process.env["AXIMO_API_KEY"] = prev;
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
it("maps 404 to not_found", async () => {
|
|
80
|
+
server.use(http.post(`${TEST_BASE_URL}/api/cases`, () => HttpResponse.json({ error: "Project not found" }, { status: 404 })));
|
|
81
|
+
const result = await runCaseCreate(MIN);
|
|
82
|
+
expect(result.ok).toBe(false);
|
|
83
|
+
if (!result.ok)
|
|
84
|
+
expect(result.error.code).toBe("not_found");
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type ClientDeps } from "../../client.js";
|
|
2
|
+
import { type DryRunResult } from "../../mutate.js";
|
|
3
|
+
import type { CreateCaseResult, Envelope } from "../../types.js";
|
|
4
|
+
export interface CreateCaseOptions {
|
|
5
|
+
projectId?: unknown;
|
|
6
|
+
scenario?: unknown;
|
|
7
|
+
type?: unknown;
|
|
8
|
+
title?: unknown;
|
|
9
|
+
description?: unknown;
|
|
10
|
+
format?: unknown;
|
|
11
|
+
startUrl?: unknown;
|
|
12
|
+
directoryId?: unknown;
|
|
13
|
+
json?: unknown;
|
|
14
|
+
dryRun?: unknown;
|
|
15
|
+
}
|
|
16
|
+
export declare function runCaseCreate(options: CreateCaseOptions, deps?: ClientDeps): Promise<Envelope<CreateCaseResult | DryRunResult>>;
|
|
17
|
+
declare const _default: import("citty").CommandDef<{
|
|
18
|
+
readonly "project-id": {
|
|
19
|
+
readonly type: "string";
|
|
20
|
+
};
|
|
21
|
+
readonly scenario: {
|
|
22
|
+
readonly type: "string";
|
|
23
|
+
};
|
|
24
|
+
readonly type: {
|
|
25
|
+
readonly type: "string";
|
|
26
|
+
readonly description: "web | mobile | desktop";
|
|
27
|
+
};
|
|
28
|
+
readonly title: {
|
|
29
|
+
readonly type: "string";
|
|
30
|
+
};
|
|
31
|
+
readonly description: {
|
|
32
|
+
readonly type: "string";
|
|
33
|
+
};
|
|
34
|
+
readonly format: {
|
|
35
|
+
readonly type: "string";
|
|
36
|
+
readonly description: "gherkin | markdown | plain_text";
|
|
37
|
+
};
|
|
38
|
+
readonly "start-url": {
|
|
39
|
+
readonly type: "string";
|
|
40
|
+
};
|
|
41
|
+
readonly "directory-id": {
|
|
42
|
+
readonly type: "string";
|
|
43
|
+
};
|
|
44
|
+
readonly json: {
|
|
45
|
+
readonly type: "string";
|
|
46
|
+
readonly description: "Raw JSON request body (replaces individual flags)";
|
|
47
|
+
};
|
|
48
|
+
readonly "dry-run": {
|
|
49
|
+
readonly type: "boolean";
|
|
50
|
+
readonly description: "Validate and show request without calling API";
|
|
51
|
+
};
|
|
52
|
+
}>;
|
|
53
|
+
export default _default;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { defineCommand } from "citty";
|
|
2
|
+
import { request } from "../../client.js";
|
|
3
|
+
import { handleApiError } from "../../errors.js";
|
|
4
|
+
import { dryRunEnvelope, resolveJsonOverride } from "../../mutate.js";
|
|
5
|
+
import { formatSuccess, printResult, validationError } from "../../output.js";
|
|
6
|
+
import { optionalString, requireString } from "../../validate.js";
|
|
7
|
+
export async function runCaseCreate(options, deps) {
|
|
8
|
+
let body;
|
|
9
|
+
const override = resolveJsonOverride(options.json);
|
|
10
|
+
if (override && "error" in override)
|
|
11
|
+
return override.error;
|
|
12
|
+
if (override) {
|
|
13
|
+
body = override.body;
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
try {
|
|
17
|
+
body = {
|
|
18
|
+
projectId: requireString("projectId", options.projectId),
|
|
19
|
+
scenario: requireString("scenario", options.scenario),
|
|
20
|
+
type: optionalString("type", options.type),
|
|
21
|
+
title: optionalString("title", options.title),
|
|
22
|
+
description: optionalString("description", options.description),
|
|
23
|
+
format: optionalString("format", options.format),
|
|
24
|
+
startUrl: optionalString("startUrl", options.startUrl),
|
|
25
|
+
directoryId: optionalString("directoryId", options.directoryId),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
return validationError(err);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (options.dryRun) {
|
|
33
|
+
return dryRunEnvelope("POST", "/api/cases", body);
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
const res = await request("/api/cases", { method: "POST", body }, deps ?? {});
|
|
37
|
+
return formatSuccess(res);
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
return handleApiError(err);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export default defineCommand({
|
|
44
|
+
meta: { name: "create", description: "Create a test case" },
|
|
45
|
+
args: {
|
|
46
|
+
"project-id": { type: "string" },
|
|
47
|
+
scenario: { type: "string" },
|
|
48
|
+
type: { type: "string", description: "web | mobile | desktop" },
|
|
49
|
+
title: { type: "string" },
|
|
50
|
+
description: { type: "string" },
|
|
51
|
+
format: { type: "string", description: "gherkin | markdown | plain_text" },
|
|
52
|
+
"start-url": { type: "string" },
|
|
53
|
+
"directory-id": { type: "string" },
|
|
54
|
+
json: { type: "string", description: "Raw JSON request body (replaces individual flags)" },
|
|
55
|
+
"dry-run": { type: "boolean", description: "Validate and show request without calling API" },
|
|
56
|
+
},
|
|
57
|
+
async run({ args }) {
|
|
58
|
+
printResult(await runCaseCreate({
|
|
59
|
+
projectId: args["project-id"],
|
|
60
|
+
scenario: args.scenario,
|
|
61
|
+
type: args.type,
|
|
62
|
+
title: args.title,
|
|
63
|
+
description: args.description,
|
|
64
|
+
format: args.format,
|
|
65
|
+
startUrl: args["start-url"],
|
|
66
|
+
directoryId: args["directory-id"],
|
|
67
|
+
json: args.json,
|
|
68
|
+
dryRun: args["dry-run"],
|
|
69
|
+
}));
|
|
70
|
+
},
|
|
71
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
2
1
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
3
2
|
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
4
|
-
import {
|
|
3
|
+
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
4
|
+
import { HttpResponse, http, server, TEST_BASE_URL } from "../../test-utils.js";
|
|
5
5
|
import { createMcpServer } from "./server.js";
|
|
6
6
|
beforeAll(() => {
|
|
7
7
|
process.env["AXIMO_API_KEY"] = "test-key";
|
|
@@ -24,7 +24,7 @@ async function connect() {
|
|
|
24
24
|
};
|
|
25
25
|
}
|
|
26
26
|
describe("createMcpServer wiring", () => {
|
|
27
|
-
it("exposes all
|
|
27
|
+
it("exposes all 15 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();
|
|
@@ -39,6 +39,11 @@ describe("createMcpServer wiring", () => {
|
|
|
39
39
|
"aximo_list_cases",
|
|
40
40
|
"aximo_list_projects",
|
|
41
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",
|
|
42
47
|
].sort());
|
|
43
48
|
const timeline = tools.find((t) => t.name === "aximo_get_session_timeline");
|
|
44
49
|
expect(timeline?.inputSchema?.required).toContain("sessionId");
|
|
@@ -194,5 +194,75 @@ export function defineTools(deps = {}) {
|
|
|
194
194
|
return await request(`/api/cases/${encodeURIComponent(caseId)}`, {}, deps);
|
|
195
195
|
}),
|
|
196
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
|
+
},
|
|
197
267
|
];
|
|
198
268
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
2
|
-
import { HttpResponse,
|
|
2
|
+
import { HttpResponse, http, server, TEST_BASE_URL } from "../../test-utils.js";
|
|
3
3
|
import { defineTools } from "./tools.js";
|
|
4
4
|
beforeAll(() => {
|
|
5
5
|
process.env["AXIMO_API_KEY"] = "test-key";
|
|
@@ -31,6 +31,11 @@ describe("defineTools", () => {
|
|
|
31
31
|
"aximo_get_session_snapshot",
|
|
32
32
|
"aximo_list_cases",
|
|
33
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",
|
|
34
39
|
]);
|
|
35
40
|
});
|
|
36
41
|
});
|
|
@@ -203,3 +208,62 @@ describe("call() error mapping", () => {
|
|
|
203
208
|
expect(payload.hint).toMatch(/AXIMO_BASE_URL/);
|
|
204
209
|
});
|
|
205
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
|
+
});
|