@autifyhq/aximo-cli 0.8.0 → 0.10.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 +19 -7
- 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/device/connect.js +19 -1
- package/dist/commands/device/doctor.d.ts +2 -0
- package/dist/commands/device/doctor.js +37 -10
- package/dist/commands/device/doctor.test.js +26 -1
- package/dist/commands/health/check.js +15 -3
- package/dist/commands/health/check.test.js +27 -3
- package/dist/commands/mcp/server.test.js +3 -1
- package/dist/commands/mcp/tools.js +28 -0
- package/dist/commands/mcp/tools.test.js +90 -0
- package/dist/commands/mobile/connect-core.d.ts +5 -3
- package/dist/commands/mobile/connect-core.js +60 -12
- 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 +22 -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/desktop-server/preflight.d.ts +43 -0
- package/dist/desktop-server/preflight.js +78 -0
- package/dist/desktop-server/preflight.test.d.ts +1 -0
- package/dist/desktop-server/preflight.test.js +115 -0
- package/dist/mobile-control/appium-controller.d.ts +28 -0
- package/dist/mobile-control/appium-controller.js +271 -92
- package/dist/mobile-control/appium-controller.test.js +258 -8
- package/dist/mobile-control/bundle-id-map.js +3 -0
- package/dist/mobile-control/controller.d.ts +3 -0
- package/dist/mobile-control/frame-sender.d.ts +34 -0
- package/dist/mobile-control/frame-sender.js +96 -0
- package/dist/mobile-control/frame-sender.test.d.ts +1 -0
- package/dist/mobile-control/frame-sender.test.js +108 -0
- package/dist/mobile-control/mjpeg-stream.d.ts +43 -0
- package/dist/mobile-control/mjpeg-stream.js +160 -0
- package/dist/mobile-control/mjpeg-stream.test.d.ts +1 -0
- package/dist/mobile-control/mjpeg-stream.test.js +93 -0
- package/dist/mobile-control/tunnel.d.ts +10 -1
- package/dist/mobile-control/tunnel.js +43 -2
- package/dist/mobile-control/tunnel.test.js +108 -0
- package/dist/mobile-control/webdriver.d.ts +9 -1
- package/dist/mobile-control/webdriver.js +17 -5
- package/dist/mutate.d.ts +1 -1
- package/dist/mutate.js +4 -2
- package/dist/prompt.d.ts +11 -0
- package/dist/prompt.js +19 -0
- package/dist/types.d.ts +66 -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
|
@@ -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 })));
|
|
@@ -24,7 +24,7 @@ async function connect() {
|
|
|
24
24
|
};
|
|
25
25
|
}
|
|
26
26
|
describe("createMcpServer wiring", () => {
|
|
27
|
-
it("exposes all
|
|
27
|
+
it("exposes all 18 tools through tools/list with valid JSON Schema", async () => {
|
|
28
28
|
const { client, cleanup } = await connect();
|
|
29
29
|
try {
|
|
30
30
|
const { tools } = await client.listTools();
|
|
@@ -44,6 +44,8 @@ describe("createMcpServer wiring", () => {
|
|
|
44
44
|
"aximo_create_plan",
|
|
45
45
|
"aximo_add_case_to_plan",
|
|
46
46
|
"aximo_remove_case_from_plan",
|
|
47
|
+
"aximo_list_plan_runs",
|
|
48
|
+
"aximo_get_plan_run",
|
|
47
49
|
"aximo_save_session_as_case",
|
|
48
50
|
].sort());
|
|
49
51
|
const timeline = tools.find((t) => t.name === "aximo_get_session_timeline");
|
|
@@ -260,6 +260,34 @@ export function defineTools(deps = {}) {
|
|
|
260
260
|
return await request(`/api/plans/${encodeURIComponent(planId)}/cases/${encodeURIComponent(caseId)}`, { method: "DELETE" }, deps);
|
|
261
261
|
}),
|
|
262
262
|
},
|
|
263
|
+
{
|
|
264
|
+
name: "aximo_list_plan_runs",
|
|
265
|
+
title: "List plan runs",
|
|
266
|
+
description: "List a plan's runs, newest first, without per-case detail. Use aximo_get_plan_run for one run's per-case breakdown. Paginate by passing the previous page's nextCursor as cursor. A run's status reflects execution, not the test verdict: read passedCases/failedCases to judge outcomes.",
|
|
267
|
+
inputSchema: {
|
|
268
|
+
planId: z.string().uuid(),
|
|
269
|
+
limit: z.number().int().positive().max(50).optional(),
|
|
270
|
+
cursor: z
|
|
271
|
+
.string()
|
|
272
|
+
.optional()
|
|
273
|
+
.describe("nextCursor from the previous page (ISO 8601 createdAt of its last run)."),
|
|
274
|
+
},
|
|
275
|
+
handler: async ({ planId, ...rest }) => call(async () => {
|
|
276
|
+
return await request(`/api/triggers/plans/${encodeURIComponent(planId)}/runs`, { query: buildQuery(rest) }, deps);
|
|
277
|
+
}),
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
name: "aximo_get_plan_run",
|
|
281
|
+
title: "Get plan run status",
|
|
282
|
+
description: "Get the overall status and per-case breakdown of a plan run (from aximo_list_plan_runs, or the planRunId returned when a run was triggered). Poll this to observe a run in progress; wait at least 5 seconds between polls. A run's status reflects execution, not the test verdict: a run can complete with failedCases > 0, so read passedCases/failedCases and the per-case statuses to judge outcomes.",
|
|
283
|
+
inputSchema: {
|
|
284
|
+
planId: z.string().uuid(),
|
|
285
|
+
planRunId: z.string().uuid(),
|
|
286
|
+
},
|
|
287
|
+
handler: async ({ planId, planRunId }) => call(async () => {
|
|
288
|
+
return await request(`/api/triggers/plans/${encodeURIComponent(planId)}/runs/${encodeURIComponent(planRunId)}`, {}, deps);
|
|
289
|
+
}),
|
|
290
|
+
},
|
|
263
291
|
{
|
|
264
292
|
name: "aximo_save_session_as_case",
|
|
265
293
|
title: "Save session as test case",
|
|
@@ -36,6 +36,8 @@ describe("defineTools", () => {
|
|
|
36
36
|
"aximo_create_plan",
|
|
37
37
|
"aximo_add_case_to_plan",
|
|
38
38
|
"aximo_remove_case_from_plan",
|
|
39
|
+
"aximo_list_plan_runs",
|
|
40
|
+
"aximo_get_plan_run",
|
|
39
41
|
"aximo_save_session_as_case",
|
|
40
42
|
]);
|
|
41
43
|
});
|
|
@@ -141,6 +143,94 @@ describe("aximo_list_projects", () => {
|
|
|
141
143
|
expect(new URL(captured.url).pathname).toBe("/api/organizations/projects");
|
|
142
144
|
});
|
|
143
145
|
});
|
|
146
|
+
describe("aximo_list_plan_runs", () => {
|
|
147
|
+
const planId = "77777777-7777-4777-9777-777777777777";
|
|
148
|
+
it("calls /api/triggers/plans/:id/runs, passing limit and cursor through", async () => {
|
|
149
|
+
let url = null;
|
|
150
|
+
const payload = {
|
|
151
|
+
items: [
|
|
152
|
+
{
|
|
153
|
+
id: "88888888-8888-4888-9888-888888888888",
|
|
154
|
+
status: "succeeded",
|
|
155
|
+
triggerSource: "api",
|
|
156
|
+
executionMode: "parallel",
|
|
157
|
+
totalCases: 3,
|
|
158
|
+
completedCases: 3,
|
|
159
|
+
passedCases: 2,
|
|
160
|
+
failedCases: 1,
|
|
161
|
+
startedAt: "2026-07-01T00:00:00.000Z",
|
|
162
|
+
completedAt: "2026-07-01T00:10:00.000Z",
|
|
163
|
+
createdAt: "2026-07-01T00:00:00.000Z",
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
nextCursor: "2026-07-01T00:00:00.000Z",
|
|
167
|
+
};
|
|
168
|
+
server.use(http.get(`${TEST_BASE_URL}/api/triggers/plans/${planId}/runs`, ({ request: req }) => {
|
|
169
|
+
url = new URL(req.url);
|
|
170
|
+
return HttpResponse.json(payload);
|
|
171
|
+
}));
|
|
172
|
+
const tool = byName(defineTools(), "aximo_list_plan_runs");
|
|
173
|
+
const result = await tool.handler({
|
|
174
|
+
planId,
|
|
175
|
+
limit: 5,
|
|
176
|
+
cursor: "2026-07-02T00:00:00.000Z",
|
|
177
|
+
});
|
|
178
|
+
expect(result.isError).toBeUndefined();
|
|
179
|
+
expect(parsePayload(result)).toEqual(payload);
|
|
180
|
+
expect(url).not.toBeNull();
|
|
181
|
+
expect(url.searchParams.get("limit")).toBe("5");
|
|
182
|
+
expect(url.searchParams.get("cursor")).toBe("2026-07-02T00:00:00.000Z");
|
|
183
|
+
});
|
|
184
|
+
it("preserves the backend's 404 for an unknown plan", async () => {
|
|
185
|
+
server.use(http.get(`${TEST_BASE_URL}/api/triggers/plans/${planId}/runs`, () => HttpResponse.json({ error: "Test plan not found" }, { status: 404 })));
|
|
186
|
+
const tool = byName(defineTools(), "aximo_list_plan_runs");
|
|
187
|
+
const result = await tool.handler({ planId });
|
|
188
|
+
expect(result.isError).toBe(true);
|
|
189
|
+
expect(parsePayload(result).error).toBe("Test plan not found");
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
describe("aximo_get_plan_run", () => {
|
|
193
|
+
const planId = "77777777-7777-4777-9777-777777777777";
|
|
194
|
+
const planRunId = "99999999-9999-4999-9999-999999999999";
|
|
195
|
+
it("calls /api/triggers/plans/:id/runs/:runId and returns the run with cases", async () => {
|
|
196
|
+
const payload = {
|
|
197
|
+
id: planRunId,
|
|
198
|
+
status: "succeeded",
|
|
199
|
+
triggerSource: "api",
|
|
200
|
+
executionMode: "sequential",
|
|
201
|
+
totalCases: 1,
|
|
202
|
+
completedCases: 1,
|
|
203
|
+
passedCases: 0,
|
|
204
|
+
failedCases: 1,
|
|
205
|
+
startedAt: "2026-07-01T00:00:00.000Z",
|
|
206
|
+
completedAt: "2026-07-01T00:05:00.000Z",
|
|
207
|
+
createdAt: "2026-07-01T00:00:00.000Z",
|
|
208
|
+
cases: [
|
|
209
|
+
{
|
|
210
|
+
id: "aaaaaaaa-aaaa-4aaa-9aaa-aaaaaaaaaaaa",
|
|
211
|
+
caseId: "bbbbbbbb-bbbb-4bbb-9bbb-bbbbbbbbbbbb",
|
|
212
|
+
title: "Login flow",
|
|
213
|
+
type: "web",
|
|
214
|
+
status: "failed",
|
|
215
|
+
startedAt: "2026-07-01T00:00:10.000Z",
|
|
216
|
+
completedAt: "2026-07-01T00:04:40.000Z",
|
|
217
|
+
},
|
|
218
|
+
],
|
|
219
|
+
};
|
|
220
|
+
server.use(http.get(`${TEST_BASE_URL}/api/triggers/plans/${planId}/runs/${planRunId}`, () => HttpResponse.json(payload)));
|
|
221
|
+
const tool = byName(defineTools(), "aximo_get_plan_run");
|
|
222
|
+
const result = await tool.handler({ planId, planRunId });
|
|
223
|
+
expect(result.isError).toBeUndefined();
|
|
224
|
+
expect(parsePayload(result)).toEqual(payload);
|
|
225
|
+
});
|
|
226
|
+
it("preserves the backend's 404 for an unknown run id", async () => {
|
|
227
|
+
server.use(http.get(`${TEST_BASE_URL}/api/triggers/plans/${planId}/runs/${planRunId}`, () => HttpResponse.json({ error: "Plan run not found" }, { status: 404 })));
|
|
228
|
+
const tool = byName(defineTools(), "aximo_get_plan_run");
|
|
229
|
+
const result = await tool.handler({ planId, planRunId });
|
|
230
|
+
expect(result.isError).toBe(true);
|
|
231
|
+
expect(parsePayload(result).error).toBe("Plan run not found");
|
|
232
|
+
});
|
|
233
|
+
});
|
|
144
234
|
describe("call() error mapping", () => {
|
|
145
235
|
const sessionId = "66666666-6666-4666-9666-666666666666";
|
|
146
236
|
it("maps 403 with the original message and a hint", async () => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ErrorEnvelope } from "../../types.js";
|
|
2
|
-
import
|
|
2
|
+
import { AppiumController } from "../../mobile-control/appium-controller.js";
|
|
3
3
|
import { type IosSigningConfig } from "../../mobile-control/ios-signing.js";
|
|
4
4
|
import { type MobileDeviceConfig } from "./paths.js";
|
|
5
5
|
export declare function getTunnelOrigin(): string;
|
|
@@ -8,6 +8,8 @@ interface ConfigOk {
|
|
|
8
8
|
config: MobileDeviceConfig;
|
|
9
9
|
}
|
|
10
10
|
export declare function resolveDeviceConfig(deviceId: string | undefined): ConfigOk | ErrorEnvelope;
|
|
11
|
-
export declare function buildController(config: MobileDeviceConfig, endpoint: string, iosSigning?: IosSigningConfig, wdaLocalPort?: number | (() => Promise<number>)):
|
|
12
|
-
export declare function connectWithConfig(config: MobileDeviceConfig
|
|
11
|
+
export declare function buildController(config: MobileDeviceConfig, endpoint: string, iosSigning?: IosSigningConfig, wdaLocalPort?: number | (() => Promise<number>), mjpegServerPort?: number | (() => Promise<number>)): AppiumController;
|
|
12
|
+
export declare function connectWithConfig(config: MobileDeviceConfig, options?: {
|
|
13
|
+
assumeYes?: boolean;
|
|
14
|
+
}): Promise<void>;
|
|
13
15
|
export {};
|
|
@@ -4,6 +4,8 @@ import { AppiumController } from "../../mobile-control/appium-controller.js";
|
|
|
4
4
|
import { installUploadedApp } from "../../mobile-control/app-install.js";
|
|
5
5
|
import { pickFreePort, startAppiumServer } from "../../mobile-control/appium-server.js";
|
|
6
6
|
import { withBundleIdTranslation } from "../../mobile-control/bundle-id-map.js";
|
|
7
|
+
import { FrameSender } from "../../mobile-control/frame-sender.js";
|
|
8
|
+
import { MjpegStreamClient } from "../../mobile-control/mjpeg-stream.js";
|
|
7
9
|
import { getIosDeviceInfo, iosMajorVersion } from "../../mobile-control/ios-devicectl.js";
|
|
8
10
|
import { ensureIosTunnel, TUNNEL_STOP_COMMAND } from "../../mobile-control/ios-tunnel.js";
|
|
9
11
|
import { resolveIosSigning, resolveIosSigningAuto, } from "../../mobile-control/ios-signing.js";
|
|
@@ -11,6 +13,7 @@ import { resolveWdaCaching } from "../../mobile-control/ios-wda-cache.js";
|
|
|
11
13
|
import { resolvePreinstalledWdaStrategy } from "../../mobile-control/ios-wda-preinstall.js";
|
|
12
14
|
import { runControlTunnel } from "../../mobile-control/tunnel.js";
|
|
13
15
|
import { runMobileDoctor } from "./doctor.js";
|
|
16
|
+
import { confirmIosSigning } from "./signing-consent.js";
|
|
14
17
|
import { listMobileDeviceConfigs, readMobileDeviceConfig, } from "./paths.js";
|
|
15
18
|
export function getTunnelOrigin() {
|
|
16
19
|
return getBaseUrl().replace(/^http/, "ws");
|
|
@@ -37,11 +40,12 @@ export function resolveDeviceConfig(deviceId) {
|
|
|
37
40
|
}
|
|
38
41
|
return { ok: true, config: only };
|
|
39
42
|
}
|
|
40
|
-
export function buildController(config, endpoint, iosSigning, wdaLocalPort) {
|
|
43
|
+
export function buildController(config, endpoint, iosSigning, wdaLocalPort, mjpegServerPort) {
|
|
41
44
|
return new AppiumController({
|
|
42
45
|
endpoint,
|
|
43
46
|
platform: config.platform,
|
|
44
47
|
target: config.target,
|
|
48
|
+
...(mjpegServerPort ? { mjpegServerPort } : {}),
|
|
45
49
|
...(config.platform === "ios"
|
|
46
50
|
? { iosSigning: iosSigning ?? resolveIosSigning(), ...(wdaLocalPort ? { wdaLocalPort } : {}) }
|
|
47
51
|
: {}),
|
|
@@ -86,12 +90,34 @@ async function preflightIosDevice(udid) {
|
|
|
86
90
|
}
|
|
87
91
|
return iosMajorVersion(info.osVersion);
|
|
88
92
|
}
|
|
89
|
-
export async function connectWithConfig(config) {
|
|
93
|
+
export async function connectWithConfig(config, options) {
|
|
90
94
|
await assertPrerequisites(config.platform);
|
|
91
95
|
let iosMajor = null;
|
|
92
96
|
let tunnelRunning = false;
|
|
97
|
+
let signing;
|
|
93
98
|
if (config.platform === "ios") {
|
|
94
99
|
iosMajor = await preflightIosDevice(config.target);
|
|
100
|
+
// Resolve and confirm the signing identity before any signing side effects —
|
|
101
|
+
// the RemoteXPC tunnel (admin prompt), Appium, WDA install, and profile
|
|
102
|
+
// minting against the user's Apple Developer account all come after, so
|
|
103
|
+
// declining costs nothing.
|
|
104
|
+
signing = await resolveIosSigningAuto({ udid: config.target });
|
|
105
|
+
if (signing.teamId) {
|
|
106
|
+
const accepted = await confirmIosSigning({
|
|
107
|
+
signing,
|
|
108
|
+
...(options?.assumeYes !== undefined ? { assumeYes: options.assumeYes } : {}),
|
|
109
|
+
});
|
|
110
|
+
if (!accepted) {
|
|
111
|
+
console.error("Signing not accepted — not connecting. Re-run with --yes to skip this prompt, " +
|
|
112
|
+
"or set AXIMO_IOS_TEAM_ID to sign with a different team.");
|
|
113
|
+
process.exitCode = 1;
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
console.error("No Apple signing identity found — physical-device WebDriverAgent signing will fail. " +
|
|
119
|
+
"Open Xcode → Settings → Accounts to add your Apple ID (see `aximo mobile doctor`).");
|
|
120
|
+
}
|
|
95
121
|
// Physical iPhones on iOS 17+ need the RemoteXPC tunnel before XCUITest can
|
|
96
122
|
// reach WebDriverAgent; start it (prompting for admin) unless one is running.
|
|
97
123
|
// Older devices use the classic lockdown path — no tunnel, no admin prompt.
|
|
@@ -114,15 +140,7 @@ export async function connectWithConfig(config) {
|
|
|
114
140
|
// installed profiles; env vars only override.
|
|
115
141
|
let iosSigning;
|
|
116
142
|
let wdaLocalPort;
|
|
117
|
-
if (config.platform === "ios") {
|
|
118
|
-
const signing = await resolveIosSigningAuto({ udid: config.target });
|
|
119
|
-
if (signing.teamId) {
|
|
120
|
-
console.error(`Signing as team ${signing.teamId} (WDA id ${signing.wdaBundleId}).`);
|
|
121
|
-
}
|
|
122
|
-
else {
|
|
123
|
-
console.error("No Apple signing identity found — physical-device WebDriverAgent signing will fail. " +
|
|
124
|
-
"Open Xcode → Settings → Accounts to add your Apple ID (see `aximo mobile doctor`).");
|
|
125
|
-
}
|
|
143
|
+
if (config.platform === "ios" && signing) {
|
|
126
144
|
const preinstalled = await resolvePreinstalledWdaStrategy(signing, {
|
|
127
145
|
udid: config.target,
|
|
128
146
|
iosMajor,
|
|
@@ -145,10 +163,35 @@ export async function connectWithConfig(config) {
|
|
|
145
163
|
}
|
|
146
164
|
// Re-signing can remap bundle ids; launches from the backend use the original.
|
|
147
165
|
const installedBundleIds = new Map();
|
|
148
|
-
const rawController = buildController(config, server.endpoint, iosSigning, wdaLocalPort);
|
|
166
|
+
const rawController = buildController(config, server.endpoint, iosSigning, wdaLocalPort, pickFreePort);
|
|
149
167
|
const controller = config.platform === "ios"
|
|
150
168
|
? withBundleIdTranslation(rawController, installedBundleIds)
|
|
151
169
|
: rawController;
|
|
170
|
+
// Live-view pipeline: capture (device MJPEG server -> local socket) feeds the
|
|
171
|
+
// sender (dedicated tunnel connection -> gateway). Started/stopped on demand
|
|
172
|
+
// by the gateway; frame acks reporting "no viewers" also stop it.
|
|
173
|
+
const frameSender = new FrameSender({
|
|
174
|
+
wsUrl: getTunnelOrigin(),
|
|
175
|
+
deviceToken: config.deviceToken,
|
|
176
|
+
onInactive: () => stream.stop(),
|
|
177
|
+
log: (message) => console.error(message),
|
|
178
|
+
});
|
|
179
|
+
const streamClient = new MjpegStreamClient({
|
|
180
|
+
ensureReady: () => rawController.ensureReady(),
|
|
181
|
+
getPort: () => rawController.getMjpegPort(),
|
|
182
|
+
onFrame: (frame) => frameSender.push(frame),
|
|
183
|
+
log: (message) => console.error(message),
|
|
184
|
+
});
|
|
185
|
+
const stream = {
|
|
186
|
+
start: () => {
|
|
187
|
+
frameSender.start();
|
|
188
|
+
streamClient.start();
|
|
189
|
+
},
|
|
190
|
+
stop: () => {
|
|
191
|
+
void streamClient.stop();
|
|
192
|
+
frameSender.stop();
|
|
193
|
+
},
|
|
194
|
+
};
|
|
152
195
|
try {
|
|
153
196
|
// Warm up the device session before connecting to Aximo so WDA build/signing/
|
|
154
197
|
// trust problems surface here — with a clear, actionable message — instead of
|
|
@@ -163,6 +206,7 @@ export async function connectWithConfig(config) {
|
|
|
163
206
|
deviceToken: config.deviceToken,
|
|
164
207
|
deviceName: config.name,
|
|
165
208
|
controller,
|
|
209
|
+
stream,
|
|
166
210
|
installApp: async ({ uploadId }) => {
|
|
167
211
|
const result = await installUploadedApp({
|
|
168
212
|
uploadId,
|
|
@@ -181,6 +225,10 @@ export async function connectWithConfig(config) {
|
|
|
181
225
|
});
|
|
182
226
|
}
|
|
183
227
|
finally {
|
|
228
|
+
// Await the capture loop before tearing down the controller/server so a
|
|
229
|
+
// mid-flight ensureReady/stream read can't race the Appium session close.
|
|
230
|
+
frameSender.stop();
|
|
231
|
+
await streamClient.stop();
|
|
184
232
|
await controller.close();
|
|
185
233
|
await server.stop();
|
|
186
234
|
if (tunnelRunning) {
|
|
@@ -5,5 +5,10 @@ declare const _default: import("citty").CommandDef<{
|
|
|
5
5
|
readonly type: "string";
|
|
6
6
|
readonly description: "Registered device id (required if multiple)";
|
|
7
7
|
};
|
|
8
|
+
readonly yes: {
|
|
9
|
+
readonly type: "boolean";
|
|
10
|
+
readonly alias: "y";
|
|
11
|
+
readonly description: "Skip the iOS code-signing confirmation prompt";
|
|
12
|
+
};
|
|
8
13
|
}>;
|
|
9
14
|
export default _default;
|
|
@@ -17,6 +17,11 @@ export default defineCommand({
|
|
|
17
17
|
},
|
|
18
18
|
args: {
|
|
19
19
|
"device-id": { type: "string", description: "Registered device id (required if multiple)" },
|
|
20
|
+
yes: {
|
|
21
|
+
type: "boolean",
|
|
22
|
+
alias: "y",
|
|
23
|
+
description: "Skip the iOS code-signing confirmation prompt",
|
|
24
|
+
},
|
|
20
25
|
},
|
|
21
26
|
async run({ args }) {
|
|
22
27
|
const resolved = resolveDeviceConfig(args["device-id"]);
|
|
@@ -28,7 +33,7 @@ export default defineCommand({
|
|
|
28
33
|
if (warning)
|
|
29
34
|
console.error(warning);
|
|
30
35
|
try {
|
|
31
|
-
await connectWithConfig(resolved.config);
|
|
36
|
+
await connectWithConfig(resolved.config, { assumeYes: Boolean(args.yes) });
|
|
32
37
|
}
|
|
33
38
|
catch (error) {
|
|
34
39
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { IosSigningConfig } from "../../mobile-control/ios-signing.js";
|
|
2
|
+
export interface ConfirmIosSigningOptions {
|
|
3
|
+
signing: IosSigningConfig;
|
|
4
|
+
/** Skip the prompt entirely (--yes). The notice still prints. */
|
|
5
|
+
assumeYes?: boolean;
|
|
6
|
+
/** Whether a human can answer; defaults to stdin being a TTY. */
|
|
7
|
+
interactive?: boolean;
|
|
8
|
+
ask?: (question: string) => Promise<string>;
|
|
9
|
+
log?: (message: string) => void;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Informed consent before using the user's Apple signing identity: connecting
|
|
13
|
+
* can re-sign apps/WDA with it, register the device to the team's Apple
|
|
14
|
+
* Developer account, and create aximo App IDs in it. Defaults to yes (Enter
|
|
15
|
+
* proceeds); only an explicit n/no declines. Unattended connects (no TTY)
|
|
16
|
+
* proceed after printing the notice, so daemonized devices keep working.
|
|
17
|
+
*/
|
|
18
|
+
export declare function confirmIosSigning(options: ConfirmIosSigningOptions): Promise<boolean>;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { confirmYesNo } from "../../prompt.js";
|
|
2
|
+
/**
|
|
3
|
+
* Informed consent before using the user's Apple signing identity: connecting
|
|
4
|
+
* can re-sign apps/WDA with it, register the device to the team's Apple
|
|
5
|
+
* Developer account, and create aximo App IDs in it. Defaults to yes (Enter
|
|
6
|
+
* proceeds); only an explicit n/no declines. Unattended connects (no TTY)
|
|
7
|
+
* proceed after printing the notice, so daemonized devices keep working.
|
|
8
|
+
*/
|
|
9
|
+
export async function confirmIosSigning(options) {
|
|
10
|
+
const { signing } = options;
|
|
11
|
+
const log = options.log ?? ((message) => console.error(message));
|
|
12
|
+
log(`Code signing: team ${signing.teamId} (WDA id ${signing.wdaBundleId}). Connecting may ` +
|
|
13
|
+
"re-sign apps and WebDriverAgent with this identity, register this device to the " +
|
|
14
|
+
"team's Apple Developer account, and create aximo App IDs in it. " +
|
|
15
|
+
"Set AXIMO_IOS_TEAM_ID to sign with a different team.");
|
|
16
|
+
if (options.assumeYes)
|
|
17
|
+
return true;
|
|
18
|
+
return confirmYesNo("Proceed with this signing identity?", {
|
|
19
|
+
...(options.interactive === undefined ? {} : { interactive: options.interactive }),
|
|
20
|
+
...(options.ask ? { ask: options.ask } : {}),
|
|
21
|
+
});
|
|
22
|
+
}
|
|
@@ -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;
|