@autifyhq/aximo-cli 0.8.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 +18 -6
- 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 +25 -10
- package/dist/commands/mobile/connect.d.ts +5 -0
- package/dist/commands/mobile/connect.js +6 -1
- 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/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/tunnel.js +23 -0
- package/dist/mobile-control/tunnel.test.js +75 -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
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
function defaultAsk(question) {
|
|
3
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
4
|
+
return rl.question(question).finally(() => rl.close());
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Informed consent before using the user's Apple signing identity: connecting
|
|
8
|
+
* can re-sign apps/WDA with it, register the device to the team's Apple
|
|
9
|
+
* Developer account, and create aximo App IDs in it. Defaults to yes (Enter
|
|
10
|
+
* proceeds); only an explicit n/no declines. Unattended connects (no TTY)
|
|
11
|
+
* proceed after printing the notice, so daemonized devices keep working.
|
|
12
|
+
*/
|
|
13
|
+
export async function confirmIosSigning(options) {
|
|
14
|
+
const { signing } = options;
|
|
15
|
+
const log = options.log ?? ((message) => console.error(message));
|
|
16
|
+
log(`Code signing: team ${signing.teamId} (WDA id ${signing.wdaBundleId}). Connecting may ` +
|
|
17
|
+
"re-sign apps and WebDriverAgent with this identity, register this device to the " +
|
|
18
|
+
"team's Apple Developer account, and create aximo App IDs in it. " +
|
|
19
|
+
"Set AXIMO_IOS_TEAM_ID to sign with a different team.");
|
|
20
|
+
if (options.assumeYes)
|
|
21
|
+
return true;
|
|
22
|
+
const interactive = options.interactive ?? process.stdin.isTTY === true;
|
|
23
|
+
if (!interactive)
|
|
24
|
+
return true;
|
|
25
|
+
const ask = options.ask ?? defaultAsk;
|
|
26
|
+
const answer = (await ask("Proceed with this signing identity? [Y/n] ")).trim().toLowerCase();
|
|
27
|
+
return answer !== "n" && answer !== "no";
|
|
28
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { confirmIosSigning } from "./signing-consent.js";
|
|
3
|
+
const SIGNING = {
|
|
4
|
+
teamId: "TEAM123456",
|
|
5
|
+
signingId: "Apple Development",
|
|
6
|
+
wdaBundleId: "com.aximo.wda.TEAM123456",
|
|
7
|
+
};
|
|
8
|
+
describe("confirmIosSigning", () => {
|
|
9
|
+
it("prints a notice naming the team and its account-facing effects", async () => {
|
|
10
|
+
const log = vi.fn();
|
|
11
|
+
await confirmIosSigning({ signing: SIGNING, assumeYes: true, log });
|
|
12
|
+
const notice = log.mock.calls.map((c) => c[0]).join("\n");
|
|
13
|
+
expect(notice).toContain("TEAM123456");
|
|
14
|
+
expect(notice).toContain("Apple Developer account");
|
|
15
|
+
expect(notice).toContain("AXIMO_IOS_TEAM_ID");
|
|
16
|
+
});
|
|
17
|
+
it("skips the prompt with --yes", async () => {
|
|
18
|
+
const ask = vi.fn();
|
|
19
|
+
await expect(confirmIosSigning({
|
|
20
|
+
signing: SIGNING,
|
|
21
|
+
assumeYes: true,
|
|
22
|
+
interactive: true,
|
|
23
|
+
ask,
|
|
24
|
+
log: vi.fn(),
|
|
25
|
+
})).resolves.toBe(true);
|
|
26
|
+
expect(ask).not.toHaveBeenCalled();
|
|
27
|
+
});
|
|
28
|
+
it("proceeds without prompting when not interactive (daemonized connect)", async () => {
|
|
29
|
+
const ask = vi.fn();
|
|
30
|
+
await expect(confirmIosSigning({ signing: SIGNING, interactive: false, ask, log: vi.fn() })).resolves.toBe(true);
|
|
31
|
+
expect(ask).not.toHaveBeenCalled();
|
|
32
|
+
});
|
|
33
|
+
it("defaults to yes on Enter", async () => {
|
|
34
|
+
const ask = vi.fn(async () => "");
|
|
35
|
+
await expect(confirmIosSigning({ signing: SIGNING, interactive: true, ask, log: vi.fn() })).resolves.toBe(true);
|
|
36
|
+
expect(ask).toHaveBeenCalledWith("Proceed with this signing identity? [Y/n] ");
|
|
37
|
+
});
|
|
38
|
+
it("accepts y/yes and arbitrary input as yes", async () => {
|
|
39
|
+
for (const answer of ["y", "Y", "yes", " sure "]) {
|
|
40
|
+
const ask = vi.fn(async () => answer);
|
|
41
|
+
await expect(confirmIosSigning({ signing: SIGNING, interactive: true, ask, log: vi.fn() })).resolves.toBe(true);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
it("declines only on an explicit n/no", async () => {
|
|
45
|
+
for (const answer of ["n", "N", "no", " NO "]) {
|
|
46
|
+
const ask = vi.fn(async () => answer);
|
|
47
|
+
await expect(confirmIosSigning({ signing: SIGNING, interactive: true, ask, log: vi.fn() })).resolves.toBe(false);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -3,6 +3,7 @@ export default defineCommand({
|
|
|
3
3
|
meta: { name: "plan", description: "Test plan 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
|
"add-case": () => import("./add-case.js").then((m) => m.default),
|
|
7
8
|
"remove-case": () => import("./remove-case.js").then((m) => m.default),
|
|
8
9
|
},
|
|
@@ -2,6 +2,7 @@ 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 { runPlanAddCase } from "./add-case.js";
|
|
4
4
|
import { runPlanCreate } from "./create.js";
|
|
5
|
+
import { runPlanUpdate } from "./update.js";
|
|
5
6
|
import { runPlanRemoveCase } from "./remove-case.js";
|
|
6
7
|
beforeAll(() => {
|
|
7
8
|
process.env["AXIMO_API_KEY"] = "test-key";
|
|
@@ -102,3 +103,63 @@ describe("runPlanRemoveCase", () => {
|
|
|
102
103
|
}
|
|
103
104
|
});
|
|
104
105
|
});
|
|
106
|
+
describe("runPlanUpdate", () => {
|
|
107
|
+
it("PATCHes the provided fields and returns the updated plan", async () => {
|
|
108
|
+
let seen;
|
|
109
|
+
server.use(http.patch(`${TEST_BASE_URL}/api/plans/pl1`, async ({ request: req }) => {
|
|
110
|
+
seen = await req.json();
|
|
111
|
+
return HttpResponse.json({
|
|
112
|
+
success: true,
|
|
113
|
+
plan: {
|
|
114
|
+
id: "pl1",
|
|
115
|
+
title: "Renamed",
|
|
116
|
+
description: null,
|
|
117
|
+
executionMode: "sequential",
|
|
118
|
+
stopOnFailure: true,
|
|
119
|
+
updatedAt: "2026-07-16T00:00:00Z",
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
}));
|
|
123
|
+
const result = await runPlanUpdate({
|
|
124
|
+
planId: "pl1",
|
|
125
|
+
title: "Renamed",
|
|
126
|
+
executionMode: "sequential",
|
|
127
|
+
stopOnFailure: "true",
|
|
128
|
+
});
|
|
129
|
+
expect(result.ok).toBe(true);
|
|
130
|
+
if (result.ok && !("dryRun" in result.data)) {
|
|
131
|
+
expect(result.data.plan.executionMode).toBe("sequential");
|
|
132
|
+
}
|
|
133
|
+
expect(seen).toMatchObject({
|
|
134
|
+
title: "Renamed",
|
|
135
|
+
executionMode: "sequential",
|
|
136
|
+
stopOnFailure: true,
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
it("rejects when no fields are provided", async () => {
|
|
140
|
+
const result = await runPlanUpdate({ planId: "pl1" });
|
|
141
|
+
expect(result.ok).toBe(false);
|
|
142
|
+
if (!result.ok)
|
|
143
|
+
expect(result.error.code).toBe("validation_error");
|
|
144
|
+
});
|
|
145
|
+
it("rejects an invalid executionMode", async () => {
|
|
146
|
+
const result = await runPlanUpdate({ planId: "pl1", executionMode: "fastest" });
|
|
147
|
+
expect(result.ok).toBe(false);
|
|
148
|
+
if (!result.ok)
|
|
149
|
+
expect(result.error.code).toBe("validation_error");
|
|
150
|
+
});
|
|
151
|
+
it("rejects a non-boolean stop-on-failure", async () => {
|
|
152
|
+
const result = await runPlanUpdate({ planId: "pl1", stopOnFailure: "yes" });
|
|
153
|
+
expect(result.ok).toBe(false);
|
|
154
|
+
if (!result.ok)
|
|
155
|
+
expect(result.error.code).toBe("validation_error");
|
|
156
|
+
});
|
|
157
|
+
it("--dry-run returns the PATCH request without calling the API", async () => {
|
|
158
|
+
const result = await runPlanUpdate({ planId: "pl1", title: "t", dryRun: true });
|
|
159
|
+
expect(result.ok).toBe(true);
|
|
160
|
+
if (result.ok && "dryRun" in result.data) {
|
|
161
|
+
expect(result.data.method).toBe("PATCH");
|
|
162
|
+
expect(result.data.url).toBe(`${TEST_BASE_URL}/api/plans/pl1`);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type ClientDeps } from "../../client.js";
|
|
2
|
+
import { type DryRunResult } from "../../mutate.js";
|
|
3
|
+
import type { Envelope, UpdatePlanResult } from "../../types.js";
|
|
4
|
+
export interface UpdatePlanOptions {
|
|
5
|
+
planId?: unknown;
|
|
6
|
+
title?: unknown;
|
|
7
|
+
description?: unknown;
|
|
8
|
+
executionMode?: unknown;
|
|
9
|
+
stopOnFailure?: unknown;
|
|
10
|
+
json?: unknown;
|
|
11
|
+
dryRun?: unknown;
|
|
12
|
+
}
|
|
13
|
+
export declare function runPlanUpdate(options: UpdatePlanOptions, deps?: ClientDeps): Promise<Envelope<UpdatePlanResult | DryRunResult>>;
|
|
14
|
+
declare const _default: import("citty").CommandDef<{
|
|
15
|
+
readonly planId: {
|
|
16
|
+
readonly type: "positional";
|
|
17
|
+
readonly required: true;
|
|
18
|
+
};
|
|
19
|
+
readonly title: {
|
|
20
|
+
readonly type: "string";
|
|
21
|
+
};
|
|
22
|
+
readonly description: {
|
|
23
|
+
readonly type: "string";
|
|
24
|
+
};
|
|
25
|
+
readonly "execution-mode": {
|
|
26
|
+
readonly type: "string";
|
|
27
|
+
readonly description: "parallel or sequential";
|
|
28
|
+
};
|
|
29
|
+
readonly "stop-on-failure": {
|
|
30
|
+
readonly type: "string";
|
|
31
|
+
readonly description: "true or false; sequential runs only";
|
|
32
|
+
};
|
|
33
|
+
readonly json: {
|
|
34
|
+
readonly type: "string";
|
|
35
|
+
readonly description: "Raw JSON request body (replaces individual flags)";
|
|
36
|
+
};
|
|
37
|
+
readonly "dry-run": {
|
|
38
|
+
readonly type: "boolean";
|
|
39
|
+
readonly description: "Validate and show request without calling API";
|
|
40
|
+
};
|
|
41
|
+
}>;
|
|
42
|
+
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, parseExecutionMode, parseOptionalBoolean, requireId, } from "../../validate.js";
|
|
7
|
+
export async function runPlanUpdate(options, deps) {
|
|
8
|
+
let planId;
|
|
9
|
+
let body;
|
|
10
|
+
const override = resolveJsonOverride(options.json);
|
|
11
|
+
if (override && "error" in override)
|
|
12
|
+
return override.error;
|
|
13
|
+
try {
|
|
14
|
+
planId = requireId("planId", options.planId);
|
|
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
|
+
executionMode: parseExecutionMode(options.executionMode),
|
|
28
|
+
stopOnFailure: parseOptionalBoolean("stopOnFailure", options.stopOnFailure),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
return validationError(err);
|
|
33
|
+
}
|
|
34
|
+
if (Object.values(body).every((v) => v === undefined)) {
|
|
35
|
+
return validationError(new Error("at least one field to update is required"));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const path = `/api/plans/${encodeURIComponent(planId)}`;
|
|
39
|
+
if (options.dryRun) {
|
|
40
|
+
return dryRunEnvelope("PATCH", path, body);
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
const res = await request(path, { method: "PATCH", body }, deps ?? {});
|
|
44
|
+
return formatSuccess(res);
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
return handleApiError(err);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export default defineCommand({
|
|
51
|
+
meta: { name: "update", description: "Update a test plan" },
|
|
52
|
+
args: {
|
|
53
|
+
planId: { type: "positional", required: true },
|
|
54
|
+
title: { type: "string" },
|
|
55
|
+
description: { type: "string" },
|
|
56
|
+
"execution-mode": {
|
|
57
|
+
type: "string",
|
|
58
|
+
description: "parallel or sequential",
|
|
59
|
+
},
|
|
60
|
+
"stop-on-failure": {
|
|
61
|
+
type: "string",
|
|
62
|
+
description: "true or false; sequential runs only",
|
|
63
|
+
},
|
|
64
|
+
json: { type: "string", description: "Raw JSON request body (replaces individual flags)" },
|
|
65
|
+
"dry-run": { type: "boolean", description: "Validate and show request without calling API" },
|
|
66
|
+
},
|
|
67
|
+
async run({ args }) {
|
|
68
|
+
printResult(await runPlanUpdate({
|
|
69
|
+
planId: args.planId,
|
|
70
|
+
title: args.title,
|
|
71
|
+
description: args.description,
|
|
72
|
+
executionMode: args["execution-mode"],
|
|
73
|
+
stopOnFailure: args["stop-on-failure"],
|
|
74
|
+
json: args.json,
|
|
75
|
+
dryRun: args["dry-run"],
|
|
76
|
+
}));
|
|
77
|
+
},
|
|
78
|
+
});
|
package/dist/commands/schema.js
CHANGED
|
@@ -19,7 +19,7 @@ export function getSchema() {
|
|
|
19
19
|
name: "aximo",
|
|
20
20
|
version: pkg.version,
|
|
21
21
|
commands: [
|
|
22
|
-
cmd("health", "Health commands", [], [cmd("check", "GET /api/health")]),
|
|
22
|
+
cmd("health", "Health commands", [], [cmd("check", "GET /api/health + authenticated API-key probe")]),
|
|
23
23
|
cmd("session", "Session commands", [], [
|
|
24
24
|
cmd("create", "POST /api/sessions", [
|
|
25
25
|
arg("title", "string", "Session title"),
|
|
@@ -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, requireId } from "../../validate.js";
|
|
6
|
+
import { optionalString, requireId, parseCaseFormat, parseSaveAsCaseType } from "../../validate.js";
|
|
7
7
|
export async function runSessionSaveAsCase(options, deps) {
|
|
8
8
|
let sessionId;
|
|
9
9
|
let body;
|
|
@@ -23,9 +23,9 @@ export async function runSessionSaveAsCase(options, deps) {
|
|
|
23
23
|
try {
|
|
24
24
|
body = {
|
|
25
25
|
title: optionalString("title", options.title),
|
|
26
|
-
type:
|
|
26
|
+
type: parseSaveAsCaseType(options.type),
|
|
27
27
|
scenario: optionalString("scenario", options.scenario),
|
|
28
|
-
format:
|
|
28
|
+
format: parseCaseFormat(options.format),
|
|
29
29
|
};
|
|
30
30
|
}
|
|
31
31
|
catch (err) {
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import type { Envelope, PlanRunResult } from "../../../types.js";
|
|
2
1
|
import { type ClientDeps } from "../../../client.js";
|
|
3
2
|
import { type DryRunResult } from "../../../mutate.js";
|
|
3
|
+
import type { Envelope, PlanRunResult } from "../../../types.js";
|
|
4
4
|
export interface TriggerPlanRunOptions {
|
|
5
5
|
planId?: unknown;
|
|
6
6
|
model?: unknown;
|
|
7
7
|
mobileConfig?: unknown;
|
|
8
|
+
executionMode?: unknown;
|
|
8
9
|
json?: unknown;
|
|
9
10
|
dryRun?: unknown;
|
|
10
11
|
}
|
|
@@ -21,6 +22,10 @@ declare const _default: import("citty").CommandDef<{
|
|
|
21
22
|
readonly type: "string";
|
|
22
23
|
readonly description: "JSON object string";
|
|
23
24
|
};
|
|
25
|
+
readonly "execution-mode": {
|
|
26
|
+
readonly type: "string";
|
|
27
|
+
readonly description: "parallel or sequential (defaults to the plan's stored setting)";
|
|
28
|
+
};
|
|
24
29
|
readonly json: {
|
|
25
30
|
readonly type: "string";
|
|
26
31
|
readonly description: "Raw JSON request body";
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { defineCommand } from "citty";
|
|
2
|
-
import { formatSuccess, printResult, validationError } from "../../../output.js";
|
|
3
|
-
import { handleApiError } from "../../../errors.js";
|
|
4
2
|
import { request } from "../../../client.js";
|
|
5
|
-
import {
|
|
3
|
+
import { handleApiError } from "../../../errors.js";
|
|
6
4
|
import { dryRunEnvelope, resolveJsonOverride } from "../../../mutate.js";
|
|
5
|
+
import { formatSuccess, printResult, validationError } from "../../../output.js";
|
|
6
|
+
import { optionalString, parseExecutionMode, parseJsonObject, requireId, } from "../../../validate.js";
|
|
7
7
|
export async function runTriggerPlanRun(options, deps) {
|
|
8
8
|
let planId;
|
|
9
9
|
let body;
|
|
@@ -24,6 +24,7 @@ export async function runTriggerPlanRun(options, deps) {
|
|
|
24
24
|
body = {
|
|
25
25
|
model: optionalString("model", options.model),
|
|
26
26
|
mobileConfig: parseJsonObject("mobileConfig", options.mobileConfig),
|
|
27
|
+
executionMode: parseExecutionMode(options.executionMode),
|
|
27
28
|
};
|
|
28
29
|
}
|
|
29
30
|
catch (err) {
|
|
@@ -48,6 +49,10 @@ export default defineCommand({
|
|
|
48
49
|
planId: { type: "positional", required: true },
|
|
49
50
|
model: { type: "string" },
|
|
50
51
|
"mobile-config": { type: "string", description: "JSON object string" },
|
|
52
|
+
"execution-mode": {
|
|
53
|
+
type: "string",
|
|
54
|
+
description: "parallel or sequential (defaults to the plan's stored setting)",
|
|
55
|
+
},
|
|
51
56
|
json: { type: "string", description: "Raw JSON request body" },
|
|
52
57
|
"dry-run": { type: "boolean", description: "Validate and show request without calling API" },
|
|
53
58
|
},
|
|
@@ -56,6 +61,7 @@ export default defineCommand({
|
|
|
56
61
|
planId: args.planId,
|
|
57
62
|
model: args.model,
|
|
58
63
|
mobileConfig: args["mobile-config"],
|
|
64
|
+
executionMode: args["execution-mode"],
|
|
59
65
|
json: args.json,
|
|
60
66
|
dryRun: args["dry-run"],
|
|
61
67
|
}));
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
3
|
-
import { runTriggerCaseRun } from "./case/run.js";
|
|
2
|
+
import { HttpResponse, http, server, TEST_BASE_URL } from "../../test-utils.js";
|
|
4
3
|
import { runTriggerCaseGet } from "./case/get.js";
|
|
5
|
-
import {
|
|
4
|
+
import { runTriggerCaseRun } from "./case/run.js";
|
|
6
5
|
import { runTriggerPlanGet } from "./plan/get.js";
|
|
6
|
+
import { runTriggerPlanRun } from "./plan/run.js";
|
|
7
7
|
beforeAll(() => {
|
|
8
8
|
process.env["AXIMO_API_KEY"] = "test-key";
|
|
9
9
|
process.env["AXIMO_BASE_URL"] = TEST_BASE_URL;
|
|
@@ -90,6 +90,25 @@ describe("trigger plan", () => {
|
|
|
90
90
|
if (!result.ok)
|
|
91
91
|
expect(result.error.code).toBe("validation_error");
|
|
92
92
|
});
|
|
93
|
+
it("sends executionMode in the body and surfaces the echoed effective mode", async () => {
|
|
94
|
+
let seen;
|
|
95
|
+
server.use(http.post(`${TEST_BASE_URL}/api/triggers/plans/p1/run`, async ({ request: req }) => {
|
|
96
|
+
seen = await req.json();
|
|
97
|
+
return HttpResponse.json({ success: true, planRunId: "pr1", totalCases: 2, executionMode: "sequential" }, { status: 201 });
|
|
98
|
+
}));
|
|
99
|
+
const result = await runTriggerPlanRun({ planId: "p1", executionMode: "sequential" });
|
|
100
|
+
expect(result.ok).toBe(true);
|
|
101
|
+
if (result.ok && !("dryRun" in result.data)) {
|
|
102
|
+
expect(result.data.executionMode).toBe("sequential");
|
|
103
|
+
}
|
|
104
|
+
expect(seen).toMatchObject({ executionMode: "sequential" });
|
|
105
|
+
});
|
|
106
|
+
it("rejects an invalid executionMode", async () => {
|
|
107
|
+
const result = await runTriggerPlanRun({ planId: "p1", executionMode: "fastest" });
|
|
108
|
+
expect(result.ok).toBe(false);
|
|
109
|
+
if (!result.ok)
|
|
110
|
+
expect(result.error.code).toBe("validation_error");
|
|
111
|
+
});
|
|
93
112
|
it("gets plan run", async () => {
|
|
94
113
|
server.use(http.get(`${TEST_BASE_URL}/api/triggers/plans/p1/runs/pr1`, () => HttpResponse.json({ id: "pr1", status: "running" })));
|
|
95
114
|
const result = await runTriggerPlanGet("p1", "pr1");
|
|
@@ -4,6 +4,7 @@ import { type DryRunResult } from "../../mutate.js";
|
|
|
4
4
|
export interface UploadPutOptions {
|
|
5
5
|
uploadId?: unknown;
|
|
6
6
|
bytes?: Uint8Array | undefined;
|
|
7
|
+
presignedUrl?: unknown;
|
|
7
8
|
dryRun?: unknown;
|
|
8
9
|
}
|
|
9
10
|
export declare function runUploadPut(options: UploadPutOptions, deps?: ClientDeps): Promise<Envelope<UploadPutResult | DryRunResult>>;
|
|
@@ -16,6 +17,10 @@ declare const _default: import("citty").CommandDef<{
|
|
|
16
17
|
readonly type: "string";
|
|
17
18
|
readonly description: "Local path to the file";
|
|
18
19
|
};
|
|
20
|
+
readonly url: {
|
|
21
|
+
readonly type: "string";
|
|
22
|
+
readonly description: "presignedUrl from `upload create` output (required for S3-backed deployments)";
|
|
23
|
+
};
|
|
19
24
|
readonly "dry-run": {
|
|
20
25
|
readonly type: "boolean";
|
|
21
26
|
readonly description: "Validate and show request without calling API";
|
|
@@ -2,34 +2,63 @@ import { readFile } from "node:fs/promises";
|
|
|
2
2
|
import { defineCommand } from "citty";
|
|
3
3
|
import { formatSuccess, formatError, printResult, validationError } from "../../output.js";
|
|
4
4
|
import { handleApiError } from "../../errors.js";
|
|
5
|
-
import { request } from "../../client.js";
|
|
6
|
-
import { requireId } from "../../validate.js";
|
|
5
|
+
import { request, putToPresignedUrl } from "../../client.js";
|
|
6
|
+
import { requireId, optionalString } from "../../validate.js";
|
|
7
7
|
import { dryRunEnvelope } from "../../mutate.js";
|
|
8
|
+
const PRESIGNED_URL_HINT = "This deployment stores uploads in S3, so the API does not accept file bytes directly. " +
|
|
9
|
+
'Re-run with --url "<presignedUrl>" using the presignedUrl returned by `aximo upload create`.';
|
|
10
|
+
function isAbsoluteUrl(value) {
|
|
11
|
+
return value.startsWith("http://") || value.startsWith("https://");
|
|
12
|
+
}
|
|
13
|
+
function isMethodNotAllowed(err) {
|
|
14
|
+
return typeof err === "object" && err !== null && err.status === 405;
|
|
15
|
+
}
|
|
8
16
|
export async function runUploadPut(options, deps) {
|
|
9
17
|
let uploadId;
|
|
18
|
+
let urlOption;
|
|
10
19
|
try {
|
|
11
20
|
uploadId = requireId("uploadId", options.uploadId);
|
|
12
|
-
|
|
13
|
-
throw new Error("file bytes are required");
|
|
21
|
+
urlOption = optionalString("url", options.presignedUrl);
|
|
14
22
|
}
|
|
15
23
|
catch (err) {
|
|
16
24
|
return validationError(err);
|
|
17
25
|
}
|
|
18
|
-
|
|
26
|
+
// The presignedUrl from `upload create` is either an absolute S3 URL
|
|
27
|
+
// (cloud deployments) or a relative API path (local storage).
|
|
28
|
+
const presignedUrl = urlOption;
|
|
29
|
+
const useDirectPut = presignedUrl !== undefined && isAbsoluteUrl(presignedUrl);
|
|
30
|
+
const target = presignedUrl ?? `/api/uploads/${encodeURIComponent(uploadId)}`;
|
|
19
31
|
if (options.dryRun) {
|
|
20
|
-
return dryRunEnvelope("PUT",
|
|
32
|
+
return dryRunEnvelope("PUT", target);
|
|
33
|
+
}
|
|
34
|
+
if (!options.bytes) {
|
|
35
|
+
return validationError(new Error("file bytes are required"));
|
|
36
|
+
}
|
|
37
|
+
const body = new Blob([new Uint8Array(options.bytes)]);
|
|
38
|
+
if (useDirectPut) {
|
|
39
|
+
try {
|
|
40
|
+
await putToPresignedUrl(target, body, "application/octet-stream", deps ?? {});
|
|
41
|
+
return formatSuccess({ success: true });
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
return handleApiError(err);
|
|
45
|
+
}
|
|
21
46
|
}
|
|
22
47
|
try {
|
|
23
|
-
await request(
|
|
48
|
+
await request(target, {
|
|
24
49
|
method: "PUT",
|
|
25
|
-
rawBody:
|
|
50
|
+
rawBody: body,
|
|
26
51
|
contentType: "application/octet-stream",
|
|
27
52
|
expectJson: false,
|
|
28
53
|
}, deps ?? {});
|
|
29
54
|
return formatSuccess({ success: true });
|
|
30
55
|
}
|
|
31
56
|
catch (err) {
|
|
32
|
-
|
|
57
|
+
const envelope = handleApiError(err);
|
|
58
|
+
if (isMethodNotAllowed(err)) {
|
|
59
|
+
envelope.error.hint = PRESIGNED_URL_HINT;
|
|
60
|
+
}
|
|
61
|
+
return envelope;
|
|
33
62
|
}
|
|
34
63
|
}
|
|
35
64
|
export default defineCommand({
|
|
@@ -37,12 +66,16 @@ export default defineCommand({
|
|
|
37
66
|
args: {
|
|
38
67
|
uploadId: { type: "positional", required: true },
|
|
39
68
|
file: { type: "string", description: "Local path to the file" },
|
|
69
|
+
url: {
|
|
70
|
+
type: "string",
|
|
71
|
+
description: "presignedUrl from `upload create` output (required for S3-backed deployments)",
|
|
72
|
+
},
|
|
40
73
|
"dry-run": { type: "boolean", description: "Validate and show request without calling API" },
|
|
41
74
|
},
|
|
42
75
|
async run({ args }) {
|
|
43
76
|
const dryRun = args["dry-run"];
|
|
44
77
|
if (dryRun) {
|
|
45
|
-
printResult(await runUploadPut({ uploadId: args.uploadId, dryRun: true }));
|
|
78
|
+
printResult(await runUploadPut({ uploadId: args.uploadId, presignedUrl: args.url, dryRun: true }));
|
|
46
79
|
return;
|
|
47
80
|
}
|
|
48
81
|
if (!args.file) {
|
|
@@ -57,6 +90,6 @@ export default defineCommand({
|
|
|
57
90
|
printResult(formatError("validation_error", `Cannot read file: ${args.file}`, false, err instanceof Error ? err.message : undefined));
|
|
58
91
|
return;
|
|
59
92
|
}
|
|
60
|
-
printResult(await runUploadPut({ uploadId: args.uploadId, bytes }));
|
|
93
|
+
printResult(await runUploadPut({ uploadId: args.uploadId, presignedUrl: args.url, bytes }));
|
|
61
94
|
},
|
|
62
95
|
});
|
|
@@ -98,6 +98,75 @@ describe("runUploadPut", () => {
|
|
|
98
98
|
expect(body).not.toBeNull();
|
|
99
99
|
expect(new Uint8Array(body)).toEqual(new Uint8Array([9, 9]));
|
|
100
100
|
});
|
|
101
|
+
it("PUTs directly to an absolute presigned URL without auth headers", async () => {
|
|
102
|
+
let seenCT = "";
|
|
103
|
+
let seenAuth = "unset";
|
|
104
|
+
let seenApiKey = "unset";
|
|
105
|
+
let body = null;
|
|
106
|
+
server.use(http.put("https://bucket.s3.test/uploads/u1", async ({ request: req }) => {
|
|
107
|
+
seenCT = req.headers.get("content-type") ?? "";
|
|
108
|
+
seenAuth = req.headers.get("authorization");
|
|
109
|
+
seenApiKey = req.headers.get("x-api-key");
|
|
110
|
+
body = await req.arrayBuffer();
|
|
111
|
+
return new HttpResponse(null, { status: 200 });
|
|
112
|
+
}));
|
|
113
|
+
const result = await runUploadPut({
|
|
114
|
+
uploadId: "u1",
|
|
115
|
+
bytes: new Uint8Array([7, 7]),
|
|
116
|
+
presignedUrl: "https://bucket.s3.test/uploads/u1?X-Amz-Signature=abc",
|
|
117
|
+
});
|
|
118
|
+
expect(result.ok).toBe(true);
|
|
119
|
+
if (result.ok && !("dryRun" in result.data))
|
|
120
|
+
expect(result.data.success).toBe(true);
|
|
121
|
+
expect(seenCT).toContain("application/octet-stream");
|
|
122
|
+
expect(seenAuth).toBeNull();
|
|
123
|
+
expect(seenApiKey).toBeNull();
|
|
124
|
+
expect(new Uint8Array(body)).toEqual(new Uint8Array([7, 7]));
|
|
125
|
+
});
|
|
126
|
+
it("maps a presigned URL failure to an error envelope", async () => {
|
|
127
|
+
server.use(http.put("https://bucket.s3.test/uploads/u2", () => HttpResponse.text("SignatureDoesNotMatch", { status: 403 })));
|
|
128
|
+
const result = await runUploadPut({
|
|
129
|
+
uploadId: "u2",
|
|
130
|
+
bytes: new Uint8Array([1]),
|
|
131
|
+
presignedUrl: "https://bucket.s3.test/uploads/u2",
|
|
132
|
+
});
|
|
133
|
+
expect(result.ok).toBe(false);
|
|
134
|
+
if (!result.ok)
|
|
135
|
+
expect(result.error.code).toBe("unauthorized");
|
|
136
|
+
});
|
|
137
|
+
it("PUTs via the authenticated API when --url is relative", async () => {
|
|
138
|
+
let seenApiKey = null;
|
|
139
|
+
server.use(http.put(`${TEST_BASE_URL}/api/uploads/u1`, ({ request: req }) => {
|
|
140
|
+
seenApiKey = req.headers.get("x-api-key");
|
|
141
|
+
return HttpResponse.json({ success: true });
|
|
142
|
+
}));
|
|
143
|
+
const result = await runUploadPut({
|
|
144
|
+
uploadId: "u1",
|
|
145
|
+
bytes: new Uint8Array([1]),
|
|
146
|
+
presignedUrl: "/api/uploads/u1",
|
|
147
|
+
});
|
|
148
|
+
expect(result.ok).toBe(true);
|
|
149
|
+
expect(seenApiKey).toBe("test-key");
|
|
150
|
+
});
|
|
151
|
+
it("hints at --url when the API PUT returns 405", async () => {
|
|
152
|
+
server.use(http.put(`${TEST_BASE_URL}/api/uploads/u1`, () => HttpResponse.json({ error: "Uploads use presigned URLs; PUT the file to the presigned URL instead" }, { status: 405 })));
|
|
153
|
+
const result = await runUploadPut({ uploadId: "u1", bytes: new Uint8Array([1]) });
|
|
154
|
+
expect(result.ok).toBe(false);
|
|
155
|
+
if (!result.ok) {
|
|
156
|
+
expect(result.error.hint).toContain("--url");
|
|
157
|
+
expect(result.error.hint).toContain("aximo upload create");
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
it("rejects a non-string --url", async () => {
|
|
161
|
+
const result = await runUploadPut({
|
|
162
|
+
uploadId: "u1",
|
|
163
|
+
bytes: new Uint8Array([1]),
|
|
164
|
+
presignedUrl: 42,
|
|
165
|
+
});
|
|
166
|
+
expect(result.ok).toBe(false);
|
|
167
|
+
if (!result.ok)
|
|
168
|
+
expect(result.error.code).toBe("validation_error");
|
|
169
|
+
});
|
|
101
170
|
it("rejects missing bytes", async () => {
|
|
102
171
|
const result = await runUploadPut({ uploadId: "u1" });
|
|
103
172
|
expect(result.ok).toBe(false);
|
|
@@ -107,6 +176,19 @@ describe("runUploadPut", () => {
|
|
|
107
176
|
expect(result.ok).toBe(true);
|
|
108
177
|
if (result.ok && "dryRun" in result.data) {
|
|
109
178
|
expect(result.data.method).toBe("PUT");
|
|
179
|
+
expect(result.data.url).toBe(`${TEST_BASE_URL}/api/uploads/u1`);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
it("--dry-run reflects the presigned URL target", async () => {
|
|
183
|
+
const result = await runUploadPut({
|
|
184
|
+
uploadId: "u1",
|
|
185
|
+
presignedUrl: "https://bucket.s3.test/uploads/u1?X-Amz-Signature=abc",
|
|
186
|
+
dryRun: true,
|
|
187
|
+
});
|
|
188
|
+
expect(result.ok).toBe(true);
|
|
189
|
+
if (result.ok && "dryRun" in result.data) {
|
|
190
|
+
expect(result.data.method).toBe("PUT");
|
|
191
|
+
expect(result.data.url).toBe("https://bucket.s3.test/uploads/u1?X-Amz-Signature=abc");
|
|
110
192
|
}
|
|
111
193
|
});
|
|
112
194
|
it("rejects uploadId with forbidden chars", async () => {
|
|
@@ -34,14 +34,18 @@ export declare class AppiumController implements MobileController {
|
|
|
34
34
|
doubleTap(x: number, y: number): Promise<void>;
|
|
35
35
|
longPress(x: number, y: number, durationMs: number): Promise<void>;
|
|
36
36
|
swipe(x1: number, y1: number, x2: number, y2: number, durationMs: number): Promise<void>;
|
|
37
|
+
pinchZoom(x: number, y: number, scale: number, durationMs: number): Promise<void>;
|
|
37
38
|
private performPointer;
|
|
38
39
|
typeText(text: string): Promise<ActionResult>;
|
|
40
|
+
private activeElementOrNull;
|
|
39
41
|
clearText(): Promise<ActionResult>;
|
|
40
42
|
pressKey(key: string): Promise<ActionResult>;
|
|
41
43
|
launchApp(app: string): Promise<ActionResult>;
|
|
42
44
|
findElement(text: string): Promise<ActionResult>;
|
|
43
45
|
private locate;
|
|
44
46
|
hideKeyboard(): Promise<ActionResult>;
|
|
47
|
+
rotateDevice(orientation: "portrait" | "landscape"): Promise<ActionResult>;
|
|
48
|
+
setLocation(latitude: number, longitude: number, altitude: number): Promise<ActionResult>;
|
|
45
49
|
getDeviceTime(): Promise<{
|
|
46
50
|
datetime: string;
|
|
47
51
|
timezone: string;
|