@autifyhq/aximo-cli 0.7.0 → 0.9.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.
Files changed (75) hide show
  1. package/README.md +28 -13
  2. package/dist/client.d.ts +7 -1
  3. package/dist/client.js +21 -2
  4. package/dist/client.test.js +36 -0
  5. package/dist/commands/case/case.test.js +62 -0
  6. package/dist/commands/case/create.d.ts +1 -1
  7. package/dist/commands/case/create.js +4 -4
  8. package/dist/commands/case/index.js +1 -0
  9. package/dist/commands/case/update.d.ts +50 -0
  10. package/dist/commands/case/update.js +78 -0
  11. package/dist/commands/health/check.js +15 -3
  12. package/dist/commands/health/check.test.js +27 -3
  13. package/dist/commands/mobile/connect-core.d.ts +3 -1
  14. package/dist/commands/mobile/connect-core.js +34 -14
  15. package/dist/commands/mobile/connect.d.ts +5 -0
  16. package/dist/commands/mobile/connect.js +10 -4
  17. package/dist/commands/mobile/connect.test.js +7 -1
  18. package/dist/commands/mobile/doctor.d.ts +1 -0
  19. package/dist/commands/mobile/doctor.js +18 -2
  20. package/dist/commands/mobile/doctor.test.js +15 -1
  21. package/dist/commands/mobile/install.d.ts +1 -0
  22. package/dist/commands/mobile/install.js +14 -3
  23. package/dist/commands/mobile/list.d.ts +1 -0
  24. package/dist/commands/mobile/list.js +23 -3
  25. package/dist/commands/mobile/list.test.d.ts +1 -0
  26. package/dist/commands/mobile/list.test.js +24 -0
  27. package/dist/commands/mobile/output.d.ts +13 -0
  28. package/dist/commands/mobile/output.js +21 -0
  29. package/dist/commands/mobile/output.test.d.ts +1 -0
  30. package/dist/commands/mobile/output.test.js +54 -0
  31. package/dist/commands/mobile/register.d.ts +1 -0
  32. package/dist/commands/mobile/register.js +11 -3
  33. package/dist/commands/mobile/setup.d.ts +1 -0
  34. package/dist/commands/mobile/setup.js +7 -2
  35. package/dist/commands/mobile/signing-consent.d.ts +18 -0
  36. package/dist/commands/mobile/signing-consent.js +28 -0
  37. package/dist/commands/mobile/signing-consent.test.d.ts +1 -0
  38. package/dist/commands/mobile/signing-consent.test.js +50 -0
  39. package/dist/commands/mobile/unregister.d.ts +7 -3
  40. package/dist/commands/mobile/unregister.js +10 -4
  41. package/dist/commands/plan/index.js +1 -0
  42. package/dist/commands/plan/plan.test.js +61 -0
  43. package/dist/commands/plan/update.d.ts +42 -0
  44. package/dist/commands/plan/update.js +78 -0
  45. package/dist/commands/schema.js +1 -1
  46. package/dist/commands/session/save-as-case.js +3 -3
  47. package/dist/commands/trigger/plan/run.d.ts +6 -1
  48. package/dist/commands/trigger/plan/run.js +9 -3
  49. package/dist/commands/trigger/trigger.test.js +22 -3
  50. package/dist/commands/upload/put.d.ts +5 -0
  51. package/dist/commands/upload/put.js +44 -11
  52. package/dist/commands/upload/upload.test.js +82 -0
  53. package/dist/mobile-control/appium-controller.d.ts +4 -0
  54. package/dist/mobile-control/appium-controller.js +90 -2
  55. package/dist/mobile-control/appium-controller.test.js +156 -8
  56. package/dist/mobile-control/bundle-id-map.js +3 -0
  57. package/dist/mobile-control/controller.d.ts +3 -0
  58. package/dist/mobile-control/exec.d.ts +5 -1
  59. package/dist/mobile-control/exec.js +2 -2
  60. package/dist/mobile-control/ios-tunnel.d.ts +5 -2
  61. package/dist/mobile-control/ios-tunnel.js +25 -5
  62. package/dist/mobile-control/ios-tunnel.test.js +8 -3
  63. package/dist/mobile-control/ios-wda-preinstall.js +6 -3
  64. package/dist/mobile-control/ios-wda-preinstall.test.js +12 -8
  65. package/dist/mobile-control/tunnel.js +57 -1
  66. package/dist/mobile-control/tunnel.test.js +157 -0
  67. package/dist/mobile-control/webdriver.d.ts +7 -1
  68. package/dist/mobile-control/webdriver.js +12 -6
  69. package/dist/mutate.d.ts +1 -1
  70. package/dist/mutate.js +4 -2
  71. package/dist/types.d.ts +49 -4
  72. package/dist/validate.d.ts +6 -1
  73. package/dist/validate.js +46 -0
  74. package/dist/validate.test.js +39 -1
  75. package/package.json +1 -1
package/README.md CHANGED
@@ -34,9 +34,11 @@ aximo session get SESSION_ID
34
34
 
35
35
  Get your API key from **Settings → API Keys** in the [Aximo dashboard](https://aximo.autify.com).
36
36
 
37
+ Want to run tests on **your own hardware** instead of the cloud? See [Device (BYOD)](#device-byod--bring-your-own-device) to register this desktop (Windows/macOS/Linux), or [Mobile](#mobile-local-android-phone-or-iphone) to drive a USB-connected Android phone or iPhone — each section starts with the exact prerequisites for a fresh machine.
38
+
37
39
  ## Configuration
38
40
 
39
- Each credential resolves independently: the value in `$AXIMO_HOME/config.json` (written by `aximo configure`) wins, then the environment variable, then the default.
41
+ Each credential resolves independently: the environment variable wins, then the value in `$AXIMO_HOME/config.json` (written by `aximo configure`), then the default.
40
42
 
41
43
  | Variable | Required | Default | Notes |
42
44
  | ---------------- | -------- | -------------------------- | ------------------------------------------------------------ |
@@ -58,7 +60,7 @@ export AXIMO_API_KEY=your-api-key
58
60
  export AXIMO_BASE_URL=https://aximo-sbx.autify.dev # optional, prod is the default
59
61
  ```
60
62
 
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`.
63
+ 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`, or export `AXIMO_BASE_URL`/`AXIMO_API_KEY`, which override the saved config.
62
64
 
63
65
  ## Commands
64
66
 
@@ -73,8 +75,10 @@ Run `aximo <command> --help` for detailed usage. `aximo schema [command.path]` r
73
75
  | `aximo session get` | Get session status |
74
76
  | `aximo session save-as-case` | Save a finished session as a reusable test case |
75
77
  | `aximo case create` | Create a test case |
78
+ | `aximo case update` | Update a test case |
76
79
  | `aximo case delete` | Delete a test case |
77
80
  | `aximo plan create` | Create a test plan |
81
+ | `aximo plan update` | Update a test plan |
78
82
  | `aximo plan add-case` | Add a case to a plan |
79
83
  | `aximo plan remove-case` | Remove a case from a plan |
80
84
  | `aximo trigger case run` | Trigger a dashboard-created test case |
@@ -114,7 +118,7 @@ Mutating commands support `--json '<raw body>'` for direct OpenAPI payloads and
114
118
  aximo health check
115
119
  ```
116
120
 
117
- Returns `{ok: true, data: {status: "ok"}}` when the API is reachable and your API key is valid.
121
+ Returns `{ok: true, data: {status: "ok", auth: "ok"}}` when the API is reachable and your API key is valid — after the health endpoint responds, the command verifies the key with an authenticated probe. If the API is reachable but the key is rejected, it exits non-zero with an `unauthorized` error envelope.
118
122
 
119
123
  ### Projects
120
124
 
@@ -148,6 +152,9 @@ aximo case create --project-id <id> --scenario <text> \
148
152
  [--format gherkin|markdown|plain_text] [--start-url <url>] \
149
153
  [--directory-id <id>] [--json '<body>'] [--dry-run]
150
154
 
155
+ aximo case update <caseId> [--title <t>] [--description <d>] [--scenario <text>] \
156
+ [--type web|mobile|desktop|multi_device] [--format gherkin|markdown|plain_text] \
157
+ [--start-url <url>] [--json '<body>'] [--dry-run]
151
158
  aximo case delete <caseId> [--dry-run]
152
159
 
153
160
  aximo session save-as-case <sessionId> \
@@ -162,11 +169,14 @@ aximo session save-as-case <sessionId> \
162
169
 
163
170
  ```bash
164
171
  aximo plan create --project-id <id> --title <t> [--description <d>] [--json '<body>'] [--dry-run]
172
+ aximo plan update <planId> [--title <t>] [--description <d>] \
173
+ [--execution-mode parallel|sequential] [--stop-on-failure true|false] \
174
+ [--json '<body>'] [--dry-run]
165
175
  aximo plan add-case <planId> --case-id <caseId> [--json '<body>'] [--dry-run]
166
176
  aximo plan remove-case <planId> <caseId> [--dry-run]
167
177
  ```
168
178
 
169
- 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.
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.
170
180
 
171
181
  ### Triggers (test cases and plans)
172
182
 
@@ -179,17 +189,21 @@ aximo trigger plan get <planId> <planRunId>
179
189
 
180
190
  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.
181
191
 
192
+ `trigger plan run` accepts `--execution-mode parallel|sequential` to override the plan's stored Execution setting for that run only (omitted = plan setting; plans default to parallel). Sequential runs one case at a time in plan order, and the plan's stop-on-failure option applies only when the effective mode is sequential.
193
+
182
194
  ### Uploads
183
195
 
184
196
  ```bash
185
197
  aximo upload create --file-name <name> [--file-size <bytes>] [--platform ANDROID|IOS]
186
- aximo upload put <uploadId> --file <path>
198
+ aximo upload put <uploadId> --file <path> [--url <presignedUrl>]
187
199
  aximo upload submit <uploadId>
188
200
  aximo upload get <uploadId>
189
201
  aximo upload list [--file-type ANDROID_APP|IOS_APP]
190
202
  ```
191
203
 
192
- Typical flow: `create` → `put` (sends binary to the presigned URL) → `submit` → poll `get` until `status=succeeded` → extract `uploadArn` for mobile sessions. `upload list` auto-re-uploads expired Device Farm entries.
204
+ Typical flow: `create` → take `presignedUrl` from the output → `put <uploadId> --file app.apk --url "<presignedUrl>"` → `submit` → poll `get` until `status=succeeded` → extract `uploadArn` for mobile sessions. `upload list` auto-re-uploads expired Device Farm entries.
205
+
206
+ When `presignedUrl` is an absolute S3 URL (cloud deployments) the CLI PUTs the bytes straight to it with no API-key header. When it is a relative API path (local-storage deployments) — or `--url` is omitted — the CLI falls back to an authenticated PUT through the API. If that fallback fails with HTTP 405, the deployment is S3-backed: re-run with `--url`.
193
207
 
194
208
  ### Schema
195
209
 
@@ -208,9 +222,9 @@ Terminology note: **`aximo device list` returns mobile devices** from Device Far
208
222
 
209
223
  ### Prerequisites
210
224
 
211
- - **Node.js 20+**, plus `aximo-cli` installed globally (see [Install](#install)).
225
+ - **Node.js 20.18+**, plus `aximo-cli` installed globally (see [Install](#install)).
212
226
  - **An Aximo API key**, configured via `aximo configure` or env vars.
213
- - **Active desktop session** — the agent uses `pyautogui` for screenshots and input, so the screen must be unlocked and awake while tests run.
227
+ - **Active desktop session** — the agent uses `pyautogui` for screenshots and input, so the screen must be unlocked and awake while tests run. On **macOS**, grant the terminal **Accessibility** and **Screen Recording** permissions (System Settings → Privacy & Security) — without them actions silently do nothing.
214
228
  - **Outbound WebSocket to port 443** — most corporate firewalls allow this; if yours doesn't, see [Troubleshooting](#byod-troubleshooting).
215
229
 
216
230
  > Python is **not** a prerequisite. On first `connect` (or via `device setup`), the CLI downloads a self-contained CPython build into `$AXIMO_HOME/python/` (~35 MB download, ~130 MB installed) and installs the required packages. Your system Python is untouched.
@@ -411,7 +425,7 @@ Terminology note: **`aximo mobile list`** shows phones plugged into _this_ machi
411
425
 
412
426
  ### Prerequisites
413
427
 
414
- - **Node.js 20+**, plus `aximo-cli` installed globally (see [Install](#install)).
428
+ - **Node.js 20.18+**, plus `aximo-cli` installed globally (see [Install](#install)).
415
429
  - **An Aximo API key**, configured via `aximo configure` or env vars.
416
430
  - **A USB-connected phone**:
417
431
  - **Android** — **USB debugging** enabled and authorized — see [Enable USB debugging on the phone](#enable-usb-debugging-on-the-phone).
@@ -429,12 +443,13 @@ You only need **`adb`** and the **Android SDK** on the machine running `aximo mo
429
443
  **macOS**
430
444
 
431
445
  ```bash
432
- # Option A — standalone platform-tools (just adb):
433
- brew install --cask android-platform-tools
446
+ # Option A — standalone SDK Platform-Tools (no Android Studio): download the zip from
447
+ # https://developer.android.com/tools/releases/platform-tools and unzip it so adb ends up at
448
+ # ~/Library/Android/sdk/platform-tools/adb (the driver needs this SDK layout, not just adb on PATH)
434
449
  # Option B — full Android Studio (also installs the SDK):
435
450
  brew install --cask android-studio
436
451
 
437
- # Point ANDROID_HOME at the SDK (Android Studio's default location shown):
452
+ # Point ANDROID_HOME at the SDK (both options above use this location):
438
453
  echo 'export ANDROID_HOME="$HOME/Library/Android/sdk"' >> ~/.zshrc
439
454
  echo 'export PATH="$PATH:$ANDROID_HOME/platform-tools"' >> ~/.zshrc
440
455
  source ~/.zshrc
@@ -496,7 +511,7 @@ Run `aximo mobile doctor --platform ios` to see what was discovered (identity, t
496
511
 
497
512
  > **On-device steps you can't skip** (Apple requires them once per phone): enable **Developer Mode** (Settings → Privacy & Security → Developer Mode; the phone restarts), tap **Trust This Computer**, and — after the first install of an app signed by your team — **trust the developer certificate** (Settings → General → VPN & Device Management → your "Apple Development" profile → Trust; the phone needs internet). On **free/personal teams** profiles expire every ~7 days; the CLI re-mints them automatically, but a re-mint **resets the certificate trust**, so expect that trust tap to recur weekly. Free teams are also capped at 10 App IDs per week.
498
513
  >
499
- > On **iOS 17+**, `aximo mobile connect` also starts a small **device tunnel that needs admin rights** — it prompts once for your macOS password / Touch ID (reused on later runs, so at most once per boot). Devices below iOS 17 skip the tunnel entirely. If anything mentions "iOS X is not installed", run `xcodebuild -downloadPlatform iOS` (only the classic build flow needs it).
514
+ > On **iOS 17+**, `aximo mobile connect` also starts a small **device tunnel that needs admin rights** — it prompts once for your macOS password / Touch ID (reused on later runs, so at most once per boot). The tunnel deliberately keeps running after `connect` exits; stop it anytime with `sudo pkill -f tunnel-creation`. Devices below iOS 17 skip the tunnel entirely, and Android never uses it. If anything mentions "iOS X is not installed", run `xcodebuild -downloadPlatform iOS` (only the classic build flow needs it).
500
515
 
501
516
  ### Enable USB debugging on the phone
502
517
 
package/dist/client.d.ts CHANGED
@@ -2,7 +2,7 @@ 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
4
  export interface RequestOptions {
5
- method?: "GET" | "POST" | "PUT" | "DELETE";
5
+ method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
6
6
  query?: Record<string, string | number | boolean | undefined>;
7
7
  body?: unknown;
8
8
  rawBody?: BodyInit | undefined;
@@ -15,6 +15,12 @@ export interface ClientDeps {
15
15
  fetchImpl?: typeof fetch;
16
16
  }
17
17
  export declare function request<T>(path: string, options?: RequestOptions, deps?: ClientDeps): Promise<T>;
18
+ /**
19
+ * Unauthenticated PUT to an absolute URL (e.g. an S3 presigned URL). No
20
+ * x-api-key/Authorization header — presigned URLs carry their auth in the
21
+ * query string, and extra auth headers can break S3 signature validation.
22
+ */
23
+ export declare function putToPresignedUrl(url: string, rawBody: BodyInit, contentType: string, deps?: ClientDeps): Promise<void>;
18
24
  export declare function requestBuffer(path: string, deps?: ClientDeps): Promise<Buffer>;
19
25
  /** Authenticated GET that streams the response body to a file (no full-buffering). */
20
26
  export declare function requestToFile(path: string, destPath: string, deps?: ClientDeps): Promise<void>;
package/dist/client.js CHANGED
@@ -2,7 +2,9 @@ import { loadConfig } from "./config.js";
2
2
  export const DEFAULT_BASE_URL = "https://aximo.autify.com";
3
3
  export function getApiKey() {
4
4
  const config = loadConfig();
5
- const key = config.apiKey ?? process.env["AXIMO_API_KEY"];
5
+ // Explicit env vars win over saved config so an exported key is never
6
+ // silently overridden by a stale config.json from another environment.
7
+ const key = process.env["AXIMO_API_KEY"] ?? config.apiKey;
6
8
  if (!key) {
7
9
  throw new Error("No API key configured. Run `aximo configure`, set AXIMO_API_KEY in your environment, or use `node --env-file=.env`.");
8
10
  }
@@ -10,7 +12,7 @@ export function getApiKey() {
10
12
  }
11
13
  export function getBaseUrl() {
12
14
  const config = loadConfig();
13
- const raw = config.baseUrl ?? process.env["AXIMO_BASE_URL"] ?? DEFAULT_BASE_URL;
15
+ const raw = process.env["AXIMO_BASE_URL"] ?? config.baseUrl ?? DEFAULT_BASE_URL;
14
16
  return raw.replace(/\/+$/, "");
15
17
  }
16
18
  function httpError(status, message, retryAfter) {
@@ -76,6 +78,23 @@ export async function request(path, options = {}, deps = {}) {
76
78
  return undefined;
77
79
  return JSON.parse(text);
78
80
  }
81
+ /**
82
+ * Unauthenticated PUT to an absolute URL (e.g. an S3 presigned URL). No
83
+ * x-api-key/Authorization header — presigned URLs carry their auth in the
84
+ * query string, and extra auth headers can break S3 signature validation.
85
+ */
86
+ export async function putToPresignedUrl(url, rawBody, contentType, deps = {}) {
87
+ const fetchImpl = deps.fetchImpl ?? fetch;
88
+ const res = await fetchImpl(url, {
89
+ method: "PUT",
90
+ headers: { "Content-Type": contentType },
91
+ body: rawBody,
92
+ });
93
+ if (!res.ok) {
94
+ const retryAfter = res.headers.get("retry-after") ?? undefined;
95
+ throw httpError(res.status, await errorMessage(res), retryAfter);
96
+ }
97
+ }
79
98
  export async function requestBuffer(path, deps = {}) {
80
99
  const apiKey = deps.apiKey ?? getApiKey();
81
100
  const baseUrl = deps.baseUrl ?? getBaseUrl();
@@ -1,3 +1,6 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
1
4
  import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
2
5
  import { server, http, HttpResponse, TEST_BASE_URL } from "./test-utils.js";
3
6
  import { getApiKey, getBaseUrl, request } from "./client.js";
@@ -48,6 +51,39 @@ describe("getBaseUrl", () => {
48
51
  process.env["AXIMO_BASE_URL"] = TEST_BASE_URL;
49
52
  });
50
53
  });
54
+ describe("credential precedence", () => {
55
+ let tempHome;
56
+ let prevHome;
57
+ beforeAll(() => {
58
+ prevHome = process.env["AXIMO_HOME"];
59
+ tempHome = mkdtempSync(join(tmpdir(), "aximo-client-"));
60
+ writeFileSync(join(tempHome, "config.json"), JSON.stringify({ apiKey: "config-key", baseUrl: "https://config.example.test" }));
61
+ process.env["AXIMO_HOME"] = tempHome;
62
+ });
63
+ afterAll(() => {
64
+ if (prevHome !== undefined)
65
+ process.env["AXIMO_HOME"] = prevHome;
66
+ else
67
+ delete process.env["AXIMO_HOME"];
68
+ rmSync(tempHome, { recursive: true, force: true });
69
+ });
70
+ it("prefers env vars over config.json values", () => {
71
+ expect(getApiKey()).toBe("test-key");
72
+ expect(getBaseUrl()).toBe(TEST_BASE_URL);
73
+ });
74
+ it("falls back to config.json when env vars are unset", () => {
75
+ delete process.env["AXIMO_API_KEY"];
76
+ delete process.env["AXIMO_BASE_URL"];
77
+ try {
78
+ expect(getApiKey()).toBe("config-key");
79
+ expect(getBaseUrl()).toBe("https://config.example.test");
80
+ }
81
+ finally {
82
+ process.env["AXIMO_API_KEY"] = "test-key";
83
+ process.env["AXIMO_BASE_URL"] = TEST_BASE_URL;
84
+ }
85
+ });
86
+ });
51
87
  describe("request", () => {
52
88
  it("sends x-api-key header and parses JSON", async () => {
53
89
  let seenKey = null;
@@ -1,6 +1,7 @@
1
1
  import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
2
2
  import { HttpResponse, http, server, TEST_BASE_URL } from "../../test-utils.js";
3
3
  import { runCaseCreate } from "./create.js";
4
+ import { runCaseUpdate } from "./update.js";
4
5
  import { runCaseDelete } from "./delete.js";
5
6
  beforeAll(() => {
6
7
  process.env["AXIMO_API_KEY"] = "test-key";
@@ -118,3 +119,64 @@ describe("runCaseDelete", () => {
118
119
  }
119
120
  });
120
121
  });
122
+ describe("runCaseUpdate", () => {
123
+ it("PATCHes the provided fields and returns the updated case", async () => {
124
+ let seen;
125
+ server.use(http.patch(`${TEST_BASE_URL}/api/cases/c1`, async ({ request: req }) => {
126
+ seen = await req.json();
127
+ return HttpResponse.json({
128
+ success: true,
129
+ case: {
130
+ id: "c1",
131
+ projectId: "p1",
132
+ type: "web",
133
+ title: "Renamed case",
134
+ description: null,
135
+ scenario: "Given a user",
136
+ format: "plain_text",
137
+ startUrl: null,
138
+ updatedAt: "2026-07-16T00:00:00Z",
139
+ },
140
+ });
141
+ }));
142
+ const result = await runCaseUpdate({
143
+ caseId: "c1",
144
+ title: "Renamed case",
145
+ scenario: "Given a user",
146
+ format: "plain_text",
147
+ });
148
+ expect(result.ok).toBe(true);
149
+ if (result.ok && !("dryRun" in result.data)) {
150
+ expect(result.data.case.title).toBe("Renamed case");
151
+ }
152
+ expect(seen).toMatchObject({
153
+ title: "Renamed case",
154
+ scenario: "Given a user",
155
+ format: "plain_text",
156
+ });
157
+ });
158
+ it("rejects when no fields are provided", async () => {
159
+ const result = await runCaseUpdate({ caseId: "c1" });
160
+ expect(result.ok).toBe(false);
161
+ if (!result.ok)
162
+ expect(result.error.code).toBe("validation_error");
163
+ });
164
+ it("rejects invalid type and format values client-side", async () => {
165
+ const badType = await runCaseUpdate({ caseId: "c1", type: "banana" });
166
+ expect(badType.ok).toBe(false);
167
+ if (!badType.ok)
168
+ expect(badType.error.code).toBe("validation_error");
169
+ const badFormat = await runCaseUpdate({ caseId: "c1", format: "klingon" });
170
+ expect(badFormat.ok).toBe(false);
171
+ if (!badFormat.ok)
172
+ expect(badFormat.error.code).toBe("validation_error");
173
+ });
174
+ it("--dry-run returns the PATCH request without calling the API", async () => {
175
+ const result = await runCaseUpdate({ caseId: "c1", title: "t", dryRun: true });
176
+ expect(result.ok).toBe(true);
177
+ if (result.ok && "dryRun" in result.data) {
178
+ expect(result.data.method).toBe("PATCH");
179
+ expect(result.data.url).toBe(`${TEST_BASE_URL}/api/cases/c1`);
180
+ }
181
+ });
182
+ });
@@ -23,7 +23,7 @@ declare const _default: import("citty").CommandDef<{
23
23
  };
24
24
  readonly type: {
25
25
  readonly type: "string";
26
- readonly description: "web | mobile | desktop";
26
+ readonly description: "web | mobile | desktop | multi_device";
27
27
  };
28
28
  readonly title: {
29
29
  readonly type: "string";
@@ -3,7 +3,7 @@ import { request } from "../../client.js";
3
3
  import { handleApiError } from "../../errors.js";
4
4
  import { dryRunEnvelope, resolveJsonOverride } from "../../mutate.js";
5
5
  import { formatSuccess, printResult, validationError } from "../../output.js";
6
- import { optionalString, requireString } from "../../validate.js";
6
+ import { optionalString, requireString, parseCaseFormat, parseCaseType } from "../../validate.js";
7
7
  export async function runCaseCreate(options, deps) {
8
8
  let body;
9
9
  const override = resolveJsonOverride(options.json);
@@ -17,10 +17,10 @@ export async function runCaseCreate(options, deps) {
17
17
  body = {
18
18
  projectId: requireString("projectId", options.projectId),
19
19
  scenario: requireString("scenario", options.scenario),
20
- type: optionalString("type", options.type),
20
+ type: parseCaseType(options.type),
21
21
  title: optionalString("title", options.title),
22
22
  description: optionalString("description", options.description),
23
- format: optionalString("format", options.format),
23
+ format: parseCaseFormat(options.format),
24
24
  startUrl: optionalString("startUrl", options.startUrl),
25
25
  directoryId: optionalString("directoryId", options.directoryId),
26
26
  };
@@ -45,7 +45,7 @@ export default defineCommand({
45
45
  args: {
46
46
  "project-id": { type: "string" },
47
47
  scenario: { type: "string" },
48
- type: { type: "string", description: "web | mobile | desktop" },
48
+ type: { type: "string", description: "web | mobile | desktop | multi_device" },
49
49
  title: { type: "string" },
50
50
  description: { type: "string" },
51
51
  format: { type: "string", description: "gherkin | markdown | plain_text" },
@@ -3,6 +3,7 @@ export default defineCommand({
3
3
  meta: { name: "case", description: "Test case commands" },
4
4
  subCommands: {
5
5
  create: () => import("./create.js").then((m) => m.default),
6
+ update: () => import("./update.js").then((m) => m.default),
6
7
  delete: () => import("./delete.js").then((m) => m.default),
7
8
  },
8
9
  });
@@ -0,0 +1,50 @@
1
+ import { type ClientDeps } from "../../client.js";
2
+ import { type DryRunResult } from "../../mutate.js";
3
+ import type { Envelope, UpdateCaseResult } from "../../types.js";
4
+ export interface UpdateCaseOptions {
5
+ caseId?: unknown;
6
+ title?: unknown;
7
+ description?: unknown;
8
+ scenario?: unknown;
9
+ type?: unknown;
10
+ format?: unknown;
11
+ startUrl?: unknown;
12
+ json?: unknown;
13
+ dryRun?: unknown;
14
+ }
15
+ export declare function runCaseUpdate(options: UpdateCaseOptions, deps?: ClientDeps): Promise<Envelope<UpdateCaseResult | DryRunResult>>;
16
+ declare const _default: import("citty").CommandDef<{
17
+ readonly caseId: {
18
+ readonly type: "positional";
19
+ readonly required: true;
20
+ };
21
+ readonly title: {
22
+ readonly type: "string";
23
+ };
24
+ readonly description: {
25
+ readonly type: "string";
26
+ };
27
+ readonly scenario: {
28
+ readonly type: "string";
29
+ };
30
+ readonly type: {
31
+ readonly type: "string";
32
+ readonly description: "web | mobile | desktop | multi_device";
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 json: {
42
+ readonly type: "string";
43
+ readonly description: "Raw JSON request body (replaces individual flags)";
44
+ };
45
+ readonly "dry-run": {
46
+ readonly type: "boolean";
47
+ readonly description: "Validate and show request without calling API";
48
+ };
49
+ }>;
50
+ export default _default;
@@ -0,0 +1,78 @@
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, parseCaseFormat, parseCaseType, requireId } from "../../validate.js";
7
+ export async function runCaseUpdate(options, deps) {
8
+ let caseId;
9
+ let body;
10
+ const override = resolveJsonOverride(options.json);
11
+ if (override && "error" in override)
12
+ return override.error;
13
+ try {
14
+ caseId = requireId("caseId", options.caseId);
15
+ }
16
+ catch (err) {
17
+ return validationError(err);
18
+ }
19
+ if (override) {
20
+ body = override.body;
21
+ }
22
+ else {
23
+ try {
24
+ body = {
25
+ title: optionalString("title", options.title),
26
+ description: optionalString("description", options.description),
27
+ scenario: optionalString("scenario", options.scenario),
28
+ type: parseCaseType(options.type),
29
+ format: parseCaseFormat(options.format),
30
+ startUrl: optionalString("startUrl", options.startUrl),
31
+ };
32
+ }
33
+ catch (err) {
34
+ return validationError(err);
35
+ }
36
+ if (Object.values(body).every((v) => v === undefined)) {
37
+ return validationError(new Error("at least one field to update is required"));
38
+ }
39
+ }
40
+ const path = `/api/cases/${encodeURIComponent(caseId)}`;
41
+ if (options.dryRun) {
42
+ return dryRunEnvelope("PATCH", path, body);
43
+ }
44
+ try {
45
+ const res = await request(path, { method: "PATCH", body }, deps ?? {});
46
+ return formatSuccess(res);
47
+ }
48
+ catch (err) {
49
+ return handleApiError(err);
50
+ }
51
+ }
52
+ export default defineCommand({
53
+ meta: { name: "update", description: "Update a test case" },
54
+ args: {
55
+ caseId: { type: "positional", required: true },
56
+ title: { type: "string" },
57
+ description: { type: "string" },
58
+ scenario: { type: "string" },
59
+ type: { type: "string", description: "web | mobile | desktop | multi_device" },
60
+ format: { type: "string", description: "gherkin | markdown | plain_text" },
61
+ "start-url": { type: "string" },
62
+ json: { type: "string", description: "Raw JSON request body (replaces individual flags)" },
63
+ "dry-run": { type: "boolean", description: "Validate and show request without calling API" },
64
+ },
65
+ async run({ args }) {
66
+ printResult(await runCaseUpdate({
67
+ caseId: args.caseId,
68
+ title: args.title,
69
+ description: args.description,
70
+ scenario: args.scenario,
71
+ type: args.type,
72
+ format: args.format,
73
+ startUrl: args["start-url"],
74
+ json: args.json,
75
+ dryRun: args["dry-run"],
76
+ }));
77
+ },
78
+ });
@@ -4,13 +4,25 @@ import { handleApiError } from "../../errors.js";
4
4
  import { request } from "../../client.js";
5
5
  export async function runHealthCheck(deps) {
6
6
  try {
7
- // /api/health may return empty body; we normalize to { status: "ok" }
8
- await request("/api/health", {}, deps ?? {});
9
- return formatSuccess({ status: "ok" });
7
+ // Only reachability matters; skip body parsing so a non-JSON body can't fail the check.
8
+ await request("/api/health", { expectJson: false }, deps ?? {});
10
9
  }
11
10
  catch (err) {
12
11
  return handleApiError(err);
13
12
  }
13
+ try {
14
+ // /api/health requires no auth, so verify the API key with an authenticated probe.
15
+ await request("/api/organizations/projects", { expectJson: false }, deps ?? {});
16
+ return formatSuccess({ status: "ok", auth: "ok" });
17
+ }
18
+ catch (err) {
19
+ const envelope = handleApiError(err);
20
+ const status = err.status;
21
+ if (status === 401 || status === 403) {
22
+ envelope.error.message = `API is reachable but the API key was rejected: ${envelope.error.message}`;
23
+ }
24
+ return envelope;
25
+ }
14
26
  }
15
27
  export default defineCommand({
16
28
  meta: { name: "check", description: "Check Aximo API health" },
@@ -9,10 +9,34 @@ beforeAll(() => {
9
9
  afterEach(() => server.resetHandlers());
10
10
  afterAll(() => server.close());
11
11
  describe("runHealthCheck", () => {
12
- it("returns ok envelope on 200", async () => {
13
- server.use(http.get(`${TEST_BASE_URL}/api/health`, () => new HttpResponse(null, { status: 200 })));
12
+ it("returns ok envelope with auth when the API key is valid", async () => {
13
+ server.use(http.get(`${TEST_BASE_URL}/api/health`, () => new HttpResponse(null, { status: 200 })), http.get(`${TEST_BASE_URL}/api/organizations/projects`, () => HttpResponse.json({ projects: [] })));
14
14
  const result = await runHealthCheck();
15
- expect(result).toEqual({ ok: true, data: { status: "ok" } });
15
+ expect(result).toEqual({ ok: true, data: { status: "ok", auth: "ok" } });
16
+ });
17
+ it("tolerates non-JSON bodies from both probes", async () => {
18
+ server.use(http.get(`${TEST_BASE_URL}/api/health`, () => new HttpResponse("OK", { status: 200 })), http.get(`${TEST_BASE_URL}/api/organizations/projects`, () => new HttpResponse("<html>projects</html>", { status: 200 })));
19
+ const result = await runHealthCheck();
20
+ expect(result).toEqual({ ok: true, data: { status: "ok", auth: "ok" } });
21
+ });
22
+ it("maps a rejected API key to unauthorized when the API is healthy", async () => {
23
+ server.use(http.get(`${TEST_BASE_URL}/api/health`, () => new HttpResponse(null, { status: 200 })), http.get(`${TEST_BASE_URL}/api/organizations/projects`, () => HttpResponse.json({ error: "Invalid API key" }, { status: 401 })));
24
+ const result = await runHealthCheck();
25
+ expect(result.ok).toBe(false);
26
+ if (!result.ok) {
27
+ expect(result.error.code).toBe("unauthorized");
28
+ expect(result.error.message).toContain("API is reachable but the API key was rejected");
29
+ expect(result.error.message).toContain("Invalid API key");
30
+ }
31
+ });
32
+ it("maps an auth-probe 500 to api_error without the rejected-key prefix", async () => {
33
+ server.use(http.get(`${TEST_BASE_URL}/api/health`, () => new HttpResponse(null, { status: 200 })), http.get(`${TEST_BASE_URL}/api/organizations/projects`, () => HttpResponse.json({ error: "Internal" }, { status: 500 })));
34
+ const result = await runHealthCheck();
35
+ expect(result.ok).toBe(false);
36
+ if (!result.ok) {
37
+ expect(result.error.code).toBe("api_error");
38
+ expect(result.error.message).not.toContain("rejected");
39
+ }
16
40
  });
17
41
  it("maps 500 to api_error retryable", async () => {
18
42
  server.use(http.get(`${TEST_BASE_URL}/api/health`, () => HttpResponse.json({ error: "Internal" }, { status: 500 })));
@@ -9,5 +9,7 @@ interface ConfigOk {
9
9
  }
10
10
  export declare function resolveDeviceConfig(deviceId: string | undefined): ConfigOk | ErrorEnvelope;
11
11
  export declare function buildController(config: MobileDeviceConfig, endpoint: string, iosSigning?: IosSigningConfig, wdaLocalPort?: number | (() => Promise<number>)): MobileController;
12
- export declare function connectWithConfig(config: MobileDeviceConfig): Promise<void>;
12
+ export declare function connectWithConfig(config: MobileDeviceConfig, options?: {
13
+ assumeYes?: boolean;
14
+ }): Promise<void>;
13
15
  export {};