@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.
- package/README.md +28 -13
- package/dist/client.d.ts +7 -1
- package/dist/client.js +21 -2
- package/dist/client.test.js +36 -0
- package/dist/commands/case/case.test.js +62 -0
- package/dist/commands/case/create.d.ts +1 -1
- package/dist/commands/case/create.js +4 -4
- package/dist/commands/case/index.js +1 -0
- package/dist/commands/case/update.d.ts +50 -0
- package/dist/commands/case/update.js +78 -0
- package/dist/commands/health/check.js +15 -3
- package/dist/commands/health/check.test.js +27 -3
- package/dist/commands/mobile/connect-core.d.ts +3 -1
- package/dist/commands/mobile/connect-core.js +34 -14
- package/dist/commands/mobile/connect.d.ts +5 -0
- package/dist/commands/mobile/connect.js +10 -4
- package/dist/commands/mobile/connect.test.js +7 -1
- package/dist/commands/mobile/doctor.d.ts +1 -0
- package/dist/commands/mobile/doctor.js +18 -2
- package/dist/commands/mobile/doctor.test.js +15 -1
- package/dist/commands/mobile/install.d.ts +1 -0
- package/dist/commands/mobile/install.js +14 -3
- package/dist/commands/mobile/list.d.ts +1 -0
- package/dist/commands/mobile/list.js +23 -3
- package/dist/commands/mobile/list.test.d.ts +1 -0
- package/dist/commands/mobile/list.test.js +24 -0
- package/dist/commands/mobile/output.d.ts +13 -0
- package/dist/commands/mobile/output.js +21 -0
- package/dist/commands/mobile/output.test.d.ts +1 -0
- package/dist/commands/mobile/output.test.js +54 -0
- package/dist/commands/mobile/register.d.ts +1 -0
- package/dist/commands/mobile/register.js +11 -3
- package/dist/commands/mobile/setup.d.ts +1 -0
- package/dist/commands/mobile/setup.js +7 -2
- package/dist/commands/mobile/signing-consent.d.ts +18 -0
- package/dist/commands/mobile/signing-consent.js +28 -0
- package/dist/commands/mobile/signing-consent.test.d.ts +1 -0
- package/dist/commands/mobile/signing-consent.test.js +50 -0
- package/dist/commands/mobile/unregister.d.ts +7 -3
- package/dist/commands/mobile/unregister.js +10 -4
- package/dist/commands/plan/index.js +1 -0
- package/dist/commands/plan/plan.test.js +61 -0
- package/dist/commands/plan/update.d.ts +42 -0
- package/dist/commands/plan/update.js +78 -0
- package/dist/commands/schema.js +1 -1
- package/dist/commands/session/save-as-case.js +3 -3
- package/dist/commands/trigger/plan/run.d.ts +6 -1
- package/dist/commands/trigger/plan/run.js +9 -3
- package/dist/commands/trigger/trigger.test.js +22 -3
- package/dist/commands/upload/put.d.ts +5 -0
- package/dist/commands/upload/put.js +44 -11
- package/dist/commands/upload/upload.test.js +82 -0
- package/dist/mobile-control/appium-controller.d.ts +4 -0
- package/dist/mobile-control/appium-controller.js +90 -2
- package/dist/mobile-control/appium-controller.test.js +156 -8
- package/dist/mobile-control/bundle-id-map.js +3 -0
- package/dist/mobile-control/controller.d.ts +3 -0
- package/dist/mobile-control/exec.d.ts +5 -1
- package/dist/mobile-control/exec.js +2 -2
- package/dist/mobile-control/ios-tunnel.d.ts +5 -2
- package/dist/mobile-control/ios-tunnel.js +25 -5
- package/dist/mobile-control/ios-tunnel.test.js +8 -3
- package/dist/mobile-control/ios-wda-preinstall.js +6 -3
- package/dist/mobile-control/ios-wda-preinstall.test.js +12 -8
- package/dist/mobile-control/tunnel.js +57 -1
- package/dist/mobile-control/tunnel.test.js +157 -0
- package/dist/mobile-control/webdriver.d.ts +7 -1
- package/dist/mobile-control/webdriver.js +12 -6
- package/dist/mutate.d.ts +1 -1
- package/dist/mutate.js +4 -2
- package/dist/types.d.ts +49 -4
- package/dist/validate.d.ts +6 -1
- package/dist/validate.js +46 -0
- package/dist/validate.test.js +39 -1
- 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`)
|
|
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` → `
|
|
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
|
|
433
|
-
|
|
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 (
|
|
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
|
-
|
|
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 =
|
|
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();
|
package/dist/client.test.js
CHANGED
|
@@ -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:
|
|
20
|
+
type: parseCaseType(options.type),
|
|
21
21
|
title: optionalString("title", options.title),
|
|
22
22
|
description: optionalString("description", options.description),
|
|
23
|
-
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
|
-
//
|
|
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
|
|
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
|
|
12
|
+
export declare function connectWithConfig(config: MobileDeviceConfig, options?: {
|
|
13
|
+
assumeYes?: boolean;
|
|
14
|
+
}): Promise<void>;
|
|
13
15
|
export {};
|