@autifyhq/aximo-cli 0.12.0 → 0.14.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 CHANGED
@@ -76,9 +76,14 @@ Run `aximo <command> --help` for detailed usage. `aximo schema [command.path]` r
76
76
  | `aximo session save-as-case` | Save a finished session as a reusable test case |
77
77
  | `aximo case create` | Create a test case |
78
78
  | `aximo case update` | Update a test case |
79
- | `aximo case delete` | Delete a test case |
79
+ | `aximo case archive` | Archive a test case (hide it, keep its history) |
80
+ | `aximo case restore` | Restore an archived test case |
81
+ | `aximo case delete` | Permanently delete an archived test case (admin/owner) |
80
82
  | `aximo plan create` | Create a test plan |
81
83
  | `aximo plan update` | Update a test plan |
84
+ | `aximo plan archive` | Archive a test plan (hide it; schedule is kept) |
85
+ | `aximo plan restore` | Restore an archived test plan |
86
+ | `aximo plan delete` | Permanently delete an archived test plan (admin/owner) |
82
87
  | `aximo plan add-case` | Add a case to a plan |
83
88
  | `aximo plan remove-case` | Remove a case from a plan |
84
89
  | `aximo trigger case run` | Trigger a dashboard-created test case |
@@ -154,7 +159,9 @@ aximo case create --project-id <id> --scenario <text> \
154
159
 
155
160
  aximo case update <caseId> [--title <t>] [--description <d>] [--scenario <text>] \
156
161
  [--type web|mobile|desktop|multi_device] [--format gherkin|markdown|plain_text] \
157
- [--start-url <url>] [--json '<body>'] [--dry-run]
162
+ [--start-url <url>] [--mobile-config '<json>'] [--desktop-device <userDeviceId>] \
163
+ [--multi-device-config '<json>'] [--web-config '<json>'] [--json '<body>'] [--dry-run]
164
+ aximo case archive <caseId> [--dry-run]
158
165
  aximo case delete <caseId> [--dry-run]
159
166
 
160
167
  aximo session save-as-case <sessionId> \
@@ -163,7 +170,9 @@ aximo session save-as-case <sessionId> \
163
170
  [--json '<body>'] [--dry-run]
164
171
  ```
165
172
 
166
- `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. `case delete` permanently removes a case: it is dropped from any plans it belongs to, and past run sessions are kept but detached.
173
+ `case update` leaves omitted fields unchanged. Flags cannot express `null`, so clearing a description or start URL goes through the raw body: `--json '{"description": null}'`. In that raw body an empty string is stored as an empty description rather than a clear; the `--description` flag instead treats `""` as "leave unchanged".
174
+
175
+ `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. `case archive` is the everyday removal verb: the case disappears from lists, plans, and new runs, while its run history is kept, and archiving an already-archived case succeeds. `case delete` is the permanent one, and it now requires the case to be archived first: deleting an unarchived case returns a `conflict` envelope telling you to archive it. Once deleted, the case is dropped from any plans it belongs to, its rows in past plan runs go with it, and the agent sessions themselves are kept but detached.
167
176
 
168
177
  ### Test plans
169
178
 
@@ -172,11 +181,12 @@ aximo plan create --project-id <id> --title <t> [--description <d>] [--json '<bo
172
181
  aximo plan update <planId> [--title <t>] [--description <d>] \
173
182
  [--execution-mode parallel|sequential] [--stop-on-failure true|false] \
174
183
  [--json '<body>'] [--dry-run]
184
+ aximo plan archive <planId> [--dry-run]
175
185
  aximo plan add-case <planId> --case-id <caseId> [--json '<body>'] [--dry-run]
176
186
  aximo plan remove-case <planId> <caseId> [--dry-run]
177
187
  ```
178
188
 
179
- 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. `plan update` changes only the flags you pass; `--execution-mode` sets the plan's stored Execution setting (sequential runs one case at a time in plan order) and `--stop-on-failure` marks the remaining cases as skipped after a failure, which only takes effect on sequential runs.
189
+ 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. `plan archive` hides a plan from lists, blocks new runs, and disables its schedule, keeping its cases and run history. `plan update` changes only the flags you pass; `--execution-mode` sets the plan's stored Execution setting (sequential runs one case at a time in plan order) and `--stop-on-failure` marks the remaining cases as skipped after a failure, which only takes effect on sequential runs.
180
190
 
181
191
  ### Triggers (test cases and plans)
182
192
 
@@ -660,7 +670,7 @@ claude mcp add aximo -- aximo mcp
660
670
 
661
671
  VS Code (Copilot agent mode) uses the same entry under a `servers` key in `.vscode/mcp.json`.
662
672
 
663
- The server exposes 22 tools: 14 read-only ones for inspecting sessions, cases, plans, and plan runs (`aximo_get_session_timeline` is the load-bearing one for Playwright export; `aximo_list_plans` and `aximo_get_plan` cover plan discovery; `aximo_list_plan_runs` and `aximo_get_plan_run` cover run-status polling) plus 8 that author content (create, update, and delete cases, create and update 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.
673
+ The server exposes 29 tools: 17 read-only ones for inspecting sessions, cases, plans, plan runs, devices, and uploads (`aximo_get_session_timeline` is the load-bearing one for Playwright export; `aximo_list_plans` and `aximo_get_plan` cover plan discovery; `aximo_list_plan_runs` and `aximo_get_plan_run` cover run-status polling; `aximo_list_devices`, `aximo_list_user_devices`, and `aximo_list_uploads` supply the device and upload tokens that case configs and run overrides take), 10 that author content (create, update, archive, and delete cases — including a case's per-target device configuration — create, update, and archive plans, save a session as a case), and 2 that trigger runs (`aximo_run_case` starts a case run and returns the `sessionId`; `aximo_run_plan` starts a plan run and returns the `planRunId` — both spend organization credits and require the case or plan to be `apiEnabled`). See the [MCP reference](./skills/aximo-cli/references/mcp.md) for per-client config paths, the full tool table, the Playwright export prompt, and a no-client smoke test.
664
674
 
665
675
  ## Agent Skills
666
676
 
package/dist/client.d.ts CHANGED
@@ -1,6 +1,11 @@
1
1
  export declare const DEFAULT_BASE_URL = "https://aximo.autify.com";
2
2
  export declare function getApiKey(): string;
3
3
  export declare function getBaseUrl(): string;
4
+ export interface HttpError extends Error {
5
+ status: number;
6
+ retryAfter?: string;
7
+ details?: unknown;
8
+ }
4
9
  export interface RequestOptions {
5
10
  method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
6
11
  query?: Record<string, string | number | boolean | undefined>;
package/dist/client.js CHANGED
@@ -15,27 +15,32 @@ export function getBaseUrl() {
15
15
  const raw = process.env["AXIMO_BASE_URL"] ?? config.baseUrl ?? DEFAULT_BASE_URL;
16
16
  return raw.replace(/\/+$/, "");
17
17
  }
18
- function httpError(status, message, retryAfter) {
19
- const err = Object.assign(new Error(message), { status });
20
- if (retryAfter !== undefined)
21
- err.retryAfter = retryAfter;
22
- return err;
23
- }
24
- async function errorMessage(res) {
18
+ async function parseErrorBody(res) {
25
19
  const text = await res.text().catch(() => "");
26
20
  if (!text)
27
- return res.statusText || `HTTP ${res.status}`;
21
+ return { message: res.statusText || `HTTP ${res.status}` };
28
22
  try {
29
23
  const parsed = JSON.parse(text);
30
- if (typeof parsed.error === "string")
31
- return parsed.error;
32
- if (typeof parsed.message === "string")
33
- return parsed.message;
24
+ const message = typeof parsed.error === "string"
25
+ ? parsed.error
26
+ : typeof parsed.message === "string"
27
+ ? parsed.message
28
+ : text;
29
+ return parsed.details === undefined ? { message } : { message, details: parsed.details };
34
30
  }
35
31
  catch {
36
- // fall through
32
+ return { message: text };
37
33
  }
38
- return text;
34
+ }
35
+ async function responseError(res) {
36
+ const { message, details } = await parseErrorBody(res);
37
+ const err = Object.assign(new Error(message), { status: res.status });
38
+ const retryAfter = res.headers.get("retry-after");
39
+ if (retryAfter !== null)
40
+ err.retryAfter = retryAfter;
41
+ if (details !== undefined)
42
+ err.details = details;
43
+ return err;
39
44
  }
40
45
  function buildUrl(baseUrl, path, query) {
41
46
  const url = new URL(path.startsWith("/") ? path : `/${path}`, `${baseUrl}/`);
@@ -67,8 +72,7 @@ export async function request(path, options = {}, deps = {}) {
67
72
  }
68
73
  const res = await fetchImpl(url, { method, headers, ...(body !== undefined ? { body } : {}) });
69
74
  if (!res.ok) {
70
- const retryAfter = res.headers.get("retry-after") ?? undefined;
71
- throw httpError(res.status, await errorMessage(res), retryAfter);
75
+ throw await responseError(res);
72
76
  }
73
77
  if (options.expectJson === false) {
74
78
  return undefined;
@@ -91,8 +95,7 @@ export async function putToPresignedUrl(url, rawBody, contentType, deps = {}) {
91
95
  body: rawBody,
92
96
  });
93
97
  if (!res.ok) {
94
- const retryAfter = res.headers.get("retry-after") ?? undefined;
95
- throw httpError(res.status, await errorMessage(res), retryAfter);
98
+ throw await responseError(res);
96
99
  }
97
100
  }
98
101
  export async function requestBuffer(path, deps = {}) {
@@ -102,8 +105,7 @@ export async function requestBuffer(path, deps = {}) {
102
105
  const url = buildUrl(baseUrl, path);
103
106
  const res = await fetchImpl(url, { method: "GET", headers: { "x-api-key": apiKey } });
104
107
  if (!res.ok) {
105
- const retryAfter = res.headers.get("retry-after") ?? undefined;
106
- throw httpError(res.status, await errorMessage(res), retryAfter);
108
+ throw await responseError(res);
107
109
  }
108
110
  return Buffer.from(await res.arrayBuffer());
109
111
  }
@@ -130,8 +132,7 @@ export async function requestToFile(path, destPath, deps = {}) {
130
132
  res = await fetchImpl(new URL(location, url).toString(), { method: "GET" });
131
133
  }
132
134
  if (!res.ok) {
133
- const retryAfter = res.headers.get("retry-after") ?? undefined;
134
- throw httpError(res.status, await errorMessage(res), retryAfter);
135
+ throw await responseError(res);
135
136
  }
136
137
  if (!res.body)
137
138
  throw new Error(`Empty response body from ${path}`);
@@ -161,3 +161,31 @@ describe("request", () => {
161
161
  expect(seenMethod).toBe("PUT");
162
162
  });
163
163
  });
164
+ describe("request error details", () => {
165
+ it("attaches the parsed details object to the thrown error", async () => {
166
+ server.use(http.get(`${TEST_BASE_URL}/api/boom`, () => HttpResponse.json({ error: "Invalid request body", details: { path: "apiMobileConfig.deviceArn" } }, { status: 400 })));
167
+ await expect(request("/api/boom")).rejects.toMatchObject({
168
+ status: 400,
169
+ message: "Invalid request body",
170
+ details: { path: "apiMobileConfig.deviceArn" },
171
+ });
172
+ });
173
+ it("leaves details unset when the body is not JSON", async () => {
174
+ server.use(http.get(`${TEST_BASE_URL}/api/boom`, () => HttpResponse.text("plain failure", { status: 502 })));
175
+ const err = (await request("/api/boom").catch((e) => e));
176
+ expect(err).toMatchObject({ status: 502, message: "plain failure" });
177
+ expect(err.details).toBeUndefined();
178
+ });
179
+ it("falls back to the raw text when a JSON body has no string error or message", async () => {
180
+ server.use(http.get(`${TEST_BASE_URL}/api/boom`, () => HttpResponse.json({ code: 12 }, { status: 400 })));
181
+ await expect(request("/api/boom")).rejects.toMatchObject({
182
+ status: 400,
183
+ message: '{"code":12}',
184
+ });
185
+ });
186
+ it("falls back to the status when the body is empty", async () => {
187
+ server.use(http.get(`${TEST_BASE_URL}/api/boom`, () => new HttpResponse(null, { status: 503 })));
188
+ const err = (await request("/api/boom").catch((e) => e));
189
+ expect(err.message).toMatch(/Service Unavailable|HTTP 503/);
190
+ });
191
+ });
@@ -0,0 +1,20 @@
1
+ import { type ClientDeps } from "../../client.js";
2
+ import { type DryRunResult } from "../../mutate.js";
3
+ import type { ArchiveCaseResult, Envelope } from "../../types.js";
4
+ export interface ArchiveCaseOptions {
5
+ caseId?: unknown;
6
+ dryRun?: unknown;
7
+ }
8
+ export declare function runCaseArchive(options: ArchiveCaseOptions, deps?: ClientDeps): Promise<Envelope<ArchiveCaseResult | DryRunResult>>;
9
+ declare const _default: import("citty").CommandDef<{
10
+ readonly caseId: {
11
+ readonly type: "positional";
12
+ readonly description: "Case 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,42 @@
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 runCaseArchive(options, deps) {
8
+ let caseId;
9
+ try {
10
+ caseId = requireId("caseId", options.caseId);
11
+ }
12
+ catch (err) {
13
+ return validationError(err);
14
+ }
15
+ const path = `/api/cases/${encodeURIComponent(caseId)}/archive`;
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: "archive",
30
+ description: "Archive a test case (hides it from lists and plans; run history is kept)",
31
+ },
32
+ args: {
33
+ caseId: { type: "positional", description: "Case ID", required: true },
34
+ "dry-run": { type: "boolean", description: "Validate and show request without calling API" },
35
+ },
36
+ async run({ args }) {
37
+ printResult(await runCaseArchive({
38
+ caseId: args.caseId,
39
+ dryRun: args["dry-run"],
40
+ }));
41
+ },
42
+ });
@@ -3,6 +3,8 @@ import { HttpResponse, http, server, TEST_BASE_URL } from "../../test-utils.js";
3
3
  import { runCaseCreate } from "./create.js";
4
4
  import { runCaseUpdate } from "./update.js";
5
5
  import { runCaseDelete } from "./delete.js";
6
+ import { runCaseArchive } from "./archive.js";
7
+ import { runCaseRestore } from "./restore.js";
6
8
  beforeAll(() => {
7
9
  process.env["AXIMO_API_KEY"] = "test-key";
8
10
  process.env["AXIMO_BASE_URL"] = TEST_BASE_URL;
@@ -110,6 +112,19 @@ describe("runCaseDelete", () => {
110
112
  if (!result.ok)
111
113
  expect(result.error.code).toBe("not_found");
112
114
  });
115
+ it("maps the archived-first 409 to conflict with an actionable hint", async () => {
116
+ server.use(http.delete(`${TEST_BASE_URL}/api/cases/c1`, () => HttpResponse.json({
117
+ error: "Test case must be archived before it can be permanently deleted. Archive it first with POST /api/cases/{caseId}/archive, then retry the delete.",
118
+ }, { status: 409 })));
119
+ const result = await runCaseDelete({ caseId: "c1" });
120
+ expect(result.ok).toBe(false);
121
+ if (!result.ok) {
122
+ expect(result.error.code).toBe("conflict");
123
+ expect(result.error.retryable).toBe(false);
124
+ expect(result.error.message).toContain("must be archived");
125
+ expect(result.error.hint).toContain("aximo case archive");
126
+ }
127
+ });
113
128
  it("--dry-run returns a DELETE request without calling API", async () => {
114
129
  const result = await runCaseDelete({ caseId: "c1", dryRun: true });
115
130
  expect(result.ok).toBe(true);
@@ -119,6 +134,41 @@ describe("runCaseDelete", () => {
119
134
  }
120
135
  });
121
136
  });
137
+ describe("runCaseArchive", () => {
138
+ it("POSTs the archive path and returns success", async () => {
139
+ let called = false;
140
+ server.use(http.post(`${TEST_BASE_URL}/api/cases/c1/archive`, () => {
141
+ called = true;
142
+ return HttpResponse.json({ archived: true });
143
+ }));
144
+ const result = await runCaseArchive({ caseId: "c1" });
145
+ expect(result.ok).toBe(true);
146
+ if (result.ok && !("dryRun" in result.data))
147
+ expect(result.data.archived).toBe(true);
148
+ expect(called).toBe(true);
149
+ });
150
+ it("rejects missing caseId", async () => {
151
+ const result = await runCaseArchive({});
152
+ expect(result.ok).toBe(false);
153
+ if (!result.ok)
154
+ expect(result.error.code).toBe("validation_error");
155
+ });
156
+ it("maps 404 to not_found", async () => {
157
+ server.use(http.post(`${TEST_BASE_URL}/api/cases/c1/archive`, () => HttpResponse.json({ error: "Test case not found" }, { status: 404 })));
158
+ const result = await runCaseArchive({ caseId: "c1" });
159
+ expect(result.ok).toBe(false);
160
+ if (!result.ok)
161
+ expect(result.error.code).toBe("not_found");
162
+ });
163
+ it("--dry-run returns a POST request without calling API", async () => {
164
+ const result = await runCaseArchive({ caseId: "c1", dryRun: true });
165
+ expect(result.ok).toBe(true);
166
+ if (result.ok && "dryRun" in result.data) {
167
+ expect(result.data.method).toBe("POST");
168
+ expect(result.data.url).toBe(`${TEST_BASE_URL}/api/cases/c1/archive`);
169
+ }
170
+ });
171
+ });
122
172
  describe("runCaseUpdate", () => {
123
173
  it("PATCHes the provided fields and returns the updated case", async () => {
124
174
  let seen;
@@ -154,6 +204,9 @@ describe("runCaseUpdate", () => {
154
204
  scenario: "Given a user",
155
205
  format: "plain_text",
156
206
  });
207
+ // GEN-2844: flags cannot express null, so an unsupplied description must
208
+ // be dropped from the payload rather than sent as a clear.
209
+ expect(seen).not.toHaveProperty("description");
157
210
  });
158
211
  it("rejects when no fields are provided", async () => {
159
212
  const result = await runCaseUpdate({ caseId: "c1" });
@@ -171,6 +224,17 @@ describe("runCaseUpdate", () => {
171
224
  if (!badFormat.ok)
172
225
  expect(badFormat.error.code).toBe("validation_error");
173
226
  });
227
+ it("--json forwards description: null verbatim so it can be cleared (GEN-2844)", async () => {
228
+ let seen;
229
+ server.use(http.patch(`${TEST_BASE_URL}/api/cases/c1`, async ({ request: req }) => {
230
+ seen = await req.json();
231
+ return HttpResponse.json({ success: true });
232
+ }));
233
+ const raw = JSON.stringify({ description: null });
234
+ const result = await runCaseUpdate({ caseId: "c1", json: raw });
235
+ expect(result.ok).toBe(true);
236
+ expect(seen).toEqual({ description: null });
237
+ });
174
238
  it("--dry-run returns the PATCH request without calling the API", async () => {
175
239
  const result = await runCaseUpdate({ caseId: "c1", title: "t", dryRun: true });
176
240
  expect(result.ok).toBe(true);
@@ -180,3 +244,120 @@ describe("runCaseUpdate", () => {
180
244
  }
181
245
  });
182
246
  });
247
+ describe("runCaseUpdate per-target config flags", () => {
248
+ const userDeviceId = "77777777-7777-4777-9777-777777777777";
249
+ it("maps the config flags onto the api*Config body keys", async () => {
250
+ let seen;
251
+ server.use(http.patch(`${TEST_BASE_URL}/api/cases/c1`, async ({ request: req }) => {
252
+ seen = await req.json();
253
+ return HttpResponse.json({ success: true });
254
+ }));
255
+ const result = await runCaseUpdate({
256
+ caseId: "c1",
257
+ type: "mobile",
258
+ mobileConfig: '{"deviceArn":"enc-d","deviceName":"Pixel 8","platform":"ANDROID"}',
259
+ desktopDevice: userDeviceId,
260
+ multiDeviceConfig: '[{"kind":"web","name":"A"},{"kind":"mobile","name":"B","mobilePlatform":"ios"}]',
261
+ webConfig: '{"hostnameOverrideIds":["h1"]}',
262
+ });
263
+ expect(result.ok).toBe(true);
264
+ expect(seen).toEqual({
265
+ type: "mobile",
266
+ apiMobileConfig: { deviceArn: "enc-d", deviceName: "Pixel 8", platform: "ANDROID" },
267
+ apiDesktopConfig: { userDeviceId },
268
+ apiMultiDeviceConfig: [
269
+ { kind: "web", name: "A" },
270
+ { kind: "mobile", name: "B", mobilePlatform: "ios" },
271
+ ],
272
+ apiWebConfig: { hostnameOverrideIds: ["h1"] },
273
+ });
274
+ });
275
+ it("--dry-run shows the PATCH body with a config flag alone", async () => {
276
+ const result = await runCaseUpdate({ caseId: "c1", desktopDevice: userDeviceId, dryRun: true });
277
+ expect(result.ok).toBe(true);
278
+ if (result.ok && "dryRun" in result.data) {
279
+ expect(result.data.method).toBe("PATCH");
280
+ expect(result.data.url).toBe(`${TEST_BASE_URL}/api/cases/c1`);
281
+ expect(result.data.body).toEqual({ apiDesktopConfig: { userDeviceId } });
282
+ }
283
+ });
284
+ it("rejects malformed or wrongly-shaped config JSON client-side", async () => {
285
+ const badJson = await runCaseUpdate({ caseId: "c1", mobileConfig: "{nope" });
286
+ expect(badJson.ok).toBe(false);
287
+ if (!badJson.ok) {
288
+ expect(badJson.error.code).toBe("validation_error");
289
+ expect(badJson.error.message).toContain("mobileConfig is not valid JSON");
290
+ }
291
+ const notArray = await runCaseUpdate({ caseId: "c1", multiDeviceConfig: '{"kind":"web"}' });
292
+ expect(notArray.ok).toBe(false);
293
+ if (!notArray.ok)
294
+ expect(notArray.error.message).toBe("multiDeviceConfig must be a JSON array");
295
+ const notObject = await runCaseUpdate({ caseId: "c1", webConfig: "[]" });
296
+ expect(notObject.ok).toBe(false);
297
+ if (!notObject.ok)
298
+ expect(notObject.error.message).toBe("webConfig must be a JSON object");
299
+ });
300
+ it("clears a config through --json", async () => {
301
+ let seen;
302
+ server.use(http.patch(`${TEST_BASE_URL}/api/cases/c1`, async ({ request: req }) => {
303
+ seen = await req.json();
304
+ return HttpResponse.json({ success: true });
305
+ }));
306
+ const result = await runCaseUpdate({ caseId: "c1", json: '{"apiMobileConfig":null}' });
307
+ expect(result.ok).toBe(true);
308
+ expect(seen).toEqual({ apiMobileConfig: null });
309
+ });
310
+ it("surfaces the feature gate from the API's error details", async () => {
311
+ server.use(http.patch(`${TEST_BASE_URL}/api/cases/c1`, () => HttpResponse.json({
312
+ error: "Desktop cases are not enabled for this organization",
313
+ details: { feature: "byodEnabled" },
314
+ }, { status: 403 })));
315
+ const result = await runCaseUpdate({ caseId: "c1", desktopDevice: userDeviceId });
316
+ expect(result.ok).toBe(false);
317
+ if (!result.ok) {
318
+ expect(result.error.message).toContain("requires feature byodEnabled");
319
+ expect(result.error.hint).toMatch(/organization admin/);
320
+ }
321
+ });
322
+ });
323
+ describe("runCaseRestore", () => {
324
+ it("POSTs the restore path and returns success", async () => {
325
+ let called = false;
326
+ server.use(http.post(`${TEST_BASE_URL}/api/cases/c1/restore`, () => {
327
+ called = true;
328
+ return HttpResponse.json({ restored: true });
329
+ }));
330
+ const result = await runCaseRestore({ caseId: "c1" });
331
+ expect(result.ok).toBe(true);
332
+ if (result.ok && !("dryRun" in result.data))
333
+ expect(result.data.restored).toBe(true);
334
+ expect(called).toBe(true);
335
+ });
336
+ it("rejects missing caseId", async () => {
337
+ const result = await runCaseRestore({});
338
+ expect(result.ok).toBe(false);
339
+ if (!result.ok)
340
+ expect(result.error.code).toBe("validation_error");
341
+ });
342
+ it("maps 404 to not_found", async () => {
343
+ server.use(http.post(`${TEST_BASE_URL}/api/cases/c1/restore`, () => HttpResponse.json({ error: "Test case not found" }, { status: 404 })));
344
+ const result = await runCaseRestore({ caseId: "c1" });
345
+ expect(result.ok).toBe(false);
346
+ if (!result.ok)
347
+ expect(result.error.code).toBe("not_found");
348
+ });
349
+ it("--dry-run returns a POST request without calling API", async () => {
350
+ let called = false;
351
+ server.use(http.post(`${TEST_BASE_URL}/api/cases/c1/restore`, () => {
352
+ called = true;
353
+ return HttpResponse.json({ restored: true });
354
+ }));
355
+ const result = await runCaseRestore({ caseId: "c1", dryRun: true });
356
+ expect(called).toBe(false);
357
+ expect(result.ok).toBe(true);
358
+ if (result.ok && "dryRun" in result.data) {
359
+ expect(result.data.method).toBe("POST");
360
+ expect(result.data.url).toBe(`${TEST_BASE_URL}/api/cases/c1/restore`);
361
+ }
362
+ });
363
+ });
@@ -25,7 +25,10 @@ export async function runCaseDelete(options, deps) {
25
25
  }
26
26
  }
27
27
  export default defineCommand({
28
- meta: { name: "delete", description: "Delete a test case" },
28
+ meta: {
29
+ name: "delete",
30
+ description: "Permanently delete an archived test case (archive it first with `aximo case archive`). Requires an org admin or owner",
31
+ },
29
32
  args: {
30
33
  caseId: { type: "positional", description: "Case ID", required: true },
31
34
  "dry-run": { type: "boolean", description: "Validate and show request without calling API" },
@@ -4,6 +4,8 @@ export default defineCommand({
4
4
  subCommands: {
5
5
  create: () => import("./create.js").then((m) => m.default),
6
6
  update: () => import("./update.js").then((m) => m.default),
7
+ archive: () => import("./archive.js").then((m) => m.default),
8
+ restore: () => import("./restore.js").then((m) => m.default),
7
9
  delete: () => import("./delete.js").then((m) => m.default),
8
10
  },
9
11
  });
@@ -0,0 +1,20 @@
1
+ import { type ClientDeps } from "../../client.js";
2
+ import { type DryRunResult } from "../../mutate.js";
3
+ import type { RestoreCaseResult, Envelope } from "../../types.js";
4
+ export interface RestoreCaseOptions {
5
+ caseId?: unknown;
6
+ dryRun?: unknown;
7
+ }
8
+ export declare function runCaseRestore(options: RestoreCaseOptions, deps?: ClientDeps): Promise<Envelope<RestoreCaseResult | DryRunResult>>;
9
+ declare const _default: import("citty").CommandDef<{
10
+ readonly caseId: {
11
+ readonly type: "positional";
12
+ readonly description: "Case 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,42 @@
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 runCaseRestore(options, deps) {
8
+ let caseId;
9
+ try {
10
+ caseId = requireId("caseId", options.caseId);
11
+ }
12
+ catch (err) {
13
+ return validationError(err);
14
+ }
15
+ const path = `/api/cases/${encodeURIComponent(caseId)}/restore`;
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: "restore",
30
+ description: "Restore an archived test case (returns it to lists and plans)",
31
+ },
32
+ args: {
33
+ caseId: { type: "positional", description: "Case ID", required: true },
34
+ "dry-run": { type: "boolean", description: "Validate and show request without calling API" },
35
+ },
36
+ async run({ args }) {
37
+ printResult(await runCaseRestore({
38
+ caseId: args.caseId,
39
+ dryRun: args["dry-run"],
40
+ }));
41
+ },
42
+ });
@@ -9,6 +9,10 @@ export interface UpdateCaseOptions {
9
9
  type?: unknown;
10
10
  format?: unknown;
11
11
  startUrl?: unknown;
12
+ mobileConfig?: unknown;
13
+ desktopDevice?: unknown;
14
+ multiDeviceConfig?: unknown;
15
+ webConfig?: unknown;
12
16
  json?: unknown;
13
17
  dryRun?: unknown;
14
18
  }
@@ -23,6 +27,7 @@ declare const _default: import("citty").CommandDef<{
23
27
  };
24
28
  readonly description: {
25
29
  readonly type: "string";
30
+ readonly description: "Cannot clear; use --json '{\"description\": null}' for that";
26
31
  };
27
32
  readonly scenario: {
28
33
  readonly type: "string";
@@ -37,6 +42,23 @@ declare const _default: import("citty").CommandDef<{
37
42
  };
38
43
  readonly "start-url": {
39
44
  readonly type: "string";
45
+ readonly description: "Cannot clear; use --json '{\"startUrl\": null}' for that";
46
+ };
47
+ readonly "mobile-config": {
48
+ readonly type: "string";
49
+ readonly description: "JSON object for apiMobileConfig ({deviceArn, deviceName, platform?, uploadArn?, uploadType?, appName?, bundleId?, apps?}); replaces the whole config. Clear with --json '{\"apiMobileConfig\":null}'";
50
+ };
51
+ readonly "desktop-device": {
52
+ readonly type: "string";
53
+ readonly description: "Registered BYOD desktop device id (aximo device status) for apiDesktopConfig. Clear with --json '{\"apiDesktopConfig\":null}'";
54
+ };
55
+ readonly "multi-device-config": {
56
+ readonly type: "string";
57
+ readonly description: "JSON array of 2-3 device entries ({kind: web|mobile, name, ...}) for apiMultiDeviceConfig; replaces the whole config. Clear with --json '{\"apiMultiDeviceConfig\":null}'";
58
+ };
59
+ readonly "web-config": {
60
+ readonly type: "string";
61
+ readonly description: "JSON object for apiWebConfig ({hostnameOverrideIds: [...]}); replaces the whole config. Clear with --json '{\"apiWebConfig\":null}'";
40
62
  };
41
63
  readonly json: {
42
64
  readonly type: "string";