@autifyhq/aximo-cli 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +28 -13
  2. package/dist/client.d.ts +7 -1
  3. package/dist/client.js +21 -2
  4. package/dist/client.test.js +36 -0
  5. package/dist/commands/case/case.test.js +62 -0
  6. package/dist/commands/case/create.d.ts +1 -1
  7. package/dist/commands/case/create.js +4 -4
  8. package/dist/commands/case/index.js +1 -0
  9. package/dist/commands/case/update.d.ts +50 -0
  10. package/dist/commands/case/update.js +78 -0
  11. package/dist/commands/health/check.js +15 -3
  12. package/dist/commands/health/check.test.js +27 -3
  13. package/dist/commands/mobile/connect-core.d.ts +3 -1
  14. package/dist/commands/mobile/connect-core.js +34 -14
  15. package/dist/commands/mobile/connect.d.ts +5 -0
  16. package/dist/commands/mobile/connect.js +10 -4
  17. package/dist/commands/mobile/connect.test.js +7 -1
  18. package/dist/commands/mobile/doctor.d.ts +1 -0
  19. package/dist/commands/mobile/doctor.js +18 -2
  20. package/dist/commands/mobile/doctor.test.js +15 -1
  21. package/dist/commands/mobile/install.d.ts +1 -0
  22. package/dist/commands/mobile/install.js +14 -3
  23. package/dist/commands/mobile/list.d.ts +1 -0
  24. package/dist/commands/mobile/list.js +23 -3
  25. package/dist/commands/mobile/list.test.d.ts +1 -0
  26. package/dist/commands/mobile/list.test.js +24 -0
  27. package/dist/commands/mobile/output.d.ts +13 -0
  28. package/dist/commands/mobile/output.js +21 -0
  29. package/dist/commands/mobile/output.test.d.ts +1 -0
  30. package/dist/commands/mobile/output.test.js +54 -0
  31. package/dist/commands/mobile/register.d.ts +1 -0
  32. package/dist/commands/mobile/register.js +11 -3
  33. package/dist/commands/mobile/setup.d.ts +1 -0
  34. package/dist/commands/mobile/setup.js +7 -2
  35. package/dist/commands/mobile/signing-consent.d.ts +18 -0
  36. package/dist/commands/mobile/signing-consent.js +28 -0
  37. package/dist/commands/mobile/signing-consent.test.d.ts +1 -0
  38. package/dist/commands/mobile/signing-consent.test.js +50 -0
  39. package/dist/commands/mobile/unregister.d.ts +7 -3
  40. package/dist/commands/mobile/unregister.js +10 -4
  41. package/dist/commands/plan/index.js +1 -0
  42. package/dist/commands/plan/plan.test.js +61 -0
  43. package/dist/commands/plan/update.d.ts +42 -0
  44. package/dist/commands/plan/update.js +78 -0
  45. package/dist/commands/schema.js +1 -1
  46. package/dist/commands/session/save-as-case.js +3 -3
  47. package/dist/commands/trigger/plan/run.d.ts +6 -1
  48. package/dist/commands/trigger/plan/run.js +9 -3
  49. package/dist/commands/trigger/trigger.test.js +22 -3
  50. package/dist/commands/upload/put.d.ts +5 -0
  51. package/dist/commands/upload/put.js +44 -11
  52. package/dist/commands/upload/upload.test.js +82 -0
  53. package/dist/mobile-control/appium-controller.d.ts +4 -0
  54. package/dist/mobile-control/appium-controller.js +90 -2
  55. package/dist/mobile-control/appium-controller.test.js +156 -8
  56. package/dist/mobile-control/bundle-id-map.js +3 -0
  57. package/dist/mobile-control/controller.d.ts +3 -0
  58. package/dist/mobile-control/exec.d.ts +5 -1
  59. package/dist/mobile-control/exec.js +2 -2
  60. package/dist/mobile-control/ios-tunnel.d.ts +5 -2
  61. package/dist/mobile-control/ios-tunnel.js +25 -5
  62. package/dist/mobile-control/ios-tunnel.test.js +8 -3
  63. package/dist/mobile-control/ios-wda-preinstall.js +6 -3
  64. package/dist/mobile-control/ios-wda-preinstall.test.js +12 -8
  65. package/dist/mobile-control/tunnel.js +57 -1
  66. package/dist/mobile-control/tunnel.test.js +157 -0
  67. package/dist/mobile-control/webdriver.d.ts +7 -1
  68. package/dist/mobile-control/webdriver.js +12 -6
  69. package/dist/mutate.d.ts +1 -1
  70. package/dist/mutate.js +4 -2
  71. package/dist/types.d.ts +49 -4
  72. package/dist/validate.d.ts +6 -1
  73. package/dist/validate.js +46 -0
  74. package/dist/validate.test.js +39 -1
  75. package/package.json +1 -1
@@ -49,10 +49,43 @@ function defaultConnect(wsUrl, deviceToken) {
49
49
  timeout: 10000,
50
50
  });
51
51
  }
52
+ // Mirrors the gateway's lock lease: a session quiet for longer than this has lost
53
+ // its server-side lock, so a new session id is a legitimate takeover, not a stray.
54
+ const SESSION_LEASE_MS = 25 * 60 * 1000;
52
55
  export function registerHandlers(socket, controller, installApp) {
56
+ // Device-side guard against interleaved sessions: the gateway announces owner
57
+ // changes (session_acquired/session_released) and tags each command with its
58
+ // session id, so commands from a session that no longer owns the device are
59
+ // dropped even if a gateway instance lost its in-memory lock.
60
+ let activeSession = null;
61
+ socket.on("session_acquired", (args) => {
62
+ const sessionId = args?.sessionId;
63
+ if (typeof sessionId === "string" && sessionId) {
64
+ activeSession = { id: sessionId, lastSeenAt: Date.now() };
65
+ }
66
+ });
67
+ socket.on("session_released", (args) => {
68
+ // Only the owner's release clears the guard; a takeover by a new session is
69
+ // announced via session_acquired, so an unmatched release is ignored.
70
+ const sessionId = args?.sessionId;
71
+ if (activeSession && activeSession.id === sessionId) {
72
+ activeSession = null;
73
+ }
74
+ });
53
75
  const on = (event, handler) => {
54
76
  socket.on(event, (rawArgs, ack) => {
55
- const args = (rawArgs ?? {});
77
+ // Destructure the routing-only tag off a clone so the caller's payload
78
+ // object is never mutated.
79
+ const { __sessionId: sessionId, ...args } = (rawArgs ?? {});
80
+ if (typeof sessionId === "string" && sessionId) {
81
+ if (activeSession &&
82
+ activeSession.id !== sessionId &&
83
+ Date.now() - activeSession.lastSeenAt < SESSION_LEASE_MS) {
84
+ ack?.(fail(new Error("Device is busy with another session")));
85
+ return;
86
+ }
87
+ activeSession = { id: sessionId, lastSeenAt: Date.now() };
88
+ }
56
89
  void handler(args)
57
90
  .then((result) => ack?.(result))
58
91
  .catch((err) => ack?.(fail(err)));
@@ -97,6 +130,29 @@ export function registerHandlers(socket, controller, installApp) {
97
130
  await controller.swipe(r.coords[0], r.coords[1], r.coords[2], r.coords[3], num(args["duration"], 300));
98
131
  return ok();
99
132
  });
133
+ on("pinch_zoom", async (args) => {
134
+ const r = requireCoords(args, ["x", "y"]);
135
+ if ("error" in r)
136
+ return { ok: false, error: `pinch_zoom ${r.error}` };
137
+ const scale = reqNum(args, "scale");
138
+ if (scale === null)
139
+ return { ok: false, error: "pinch_zoom requires a numeric scale" };
140
+ await controller.pinchZoom(r.coords[0], r.coords[1], scale, num(args["duration"], 600));
141
+ return ok();
142
+ });
143
+ on("rotate_device", async (args) => {
144
+ const orientation = String(args["orientation"] ?? "").toLowerCase();
145
+ if (orientation !== "portrait" && orientation !== "landscape") {
146
+ return { ok: false, error: 'rotate_device requires orientation "portrait" or "landscape"' };
147
+ }
148
+ return asActionResult(await controller.rotateDevice(orientation));
149
+ });
150
+ on("set_location", async (args) => {
151
+ const r = requireCoords(args, ["latitude", "longitude"]);
152
+ if ("error" in r)
153
+ return { ok: false, error: `set_location ${r.error}` };
154
+ return asActionResult(await controller.setLocation(r.coords[0], r.coords[1], num(args["altitude"], 0)));
155
+ });
100
156
  on("input", async (args) => asActionResult(await controller.typeText(String(args["text"] ?? ""))));
101
157
  on("key", async (args) => asActionResult(await controller.pressKey(String(args["key"] ?? ""))));
102
158
  on("launch_app", async (args) => asActionResult(await controller.launchApp(String(args["app"] ?? ""))));
@@ -20,12 +20,15 @@ function stubController(overrides = {}) {
20
20
  doubleTap: vi.fn(async () => { }),
21
21
  longPress: vi.fn(async () => { }),
22
22
  swipe: vi.fn(async () => { }),
23
+ pinchZoom: vi.fn(async () => { }),
23
24
  typeText: vi.fn(async () => ({ ok: true })),
24
25
  pressKey: vi.fn(async () => ({ ok: true })),
25
26
  launchApp: vi.fn(async () => ({ ok: true })),
26
27
  findElement: vi.fn(async () => ({ ok: true })),
27
28
  clearText: vi.fn(async () => ({ ok: true })),
28
29
  hideKeyboard: vi.fn(async () => ({ ok: true })),
30
+ rotateDevice: vi.fn(async () => ({ ok: true })),
31
+ setLocation: vi.fn(async () => ({ ok: true })),
29
32
  close: vi.fn(async () => { }),
30
33
  };
31
34
  return { ...base, ...overrides };
@@ -116,6 +119,78 @@ describe("registerHandlers", () => {
116
119
  error: "no focused field",
117
120
  });
118
121
  });
122
+ it("pinch_zoom forwards center, scale, and duration", async () => {
123
+ const pinchZoom = vi.fn(async () => { });
124
+ const socket = wire(stubController({ pinchZoom }));
125
+ await expect(socket.emit("pinch_zoom", { x: 100, y: 200, scale: 2, duration: 800 })).resolves.toEqual({
126
+ ok: true,
127
+ });
128
+ expect(pinchZoom).toHaveBeenCalledWith(100, 200, 2, 800);
129
+ });
130
+ it("pinch_zoom defaults duration to 600ms", async () => {
131
+ const pinchZoom = vi.fn(async () => { });
132
+ const socket = wire(stubController({ pinchZoom }));
133
+ await socket.emit("pinch_zoom", { x: 1, y: 2, scale: 0.5 });
134
+ expect(pinchZoom).toHaveBeenCalledWith(1, 2, 0.5, 600);
135
+ });
136
+ it("pinch_zoom rejects a missing scale without calling the controller", async () => {
137
+ const pinchZoom = vi.fn(async () => { });
138
+ const socket = wire(stubController({ pinchZoom }));
139
+ await expect(socket.emit("pinch_zoom", { x: 1, y: 2 })).resolves.toEqual({
140
+ ok: false,
141
+ error: "pinch_zoom requires a numeric scale",
142
+ });
143
+ expect(pinchZoom).not.toHaveBeenCalled();
144
+ });
145
+ it("pinch_zoom rejects missing coordinates", async () => {
146
+ const pinchZoom = vi.fn(async () => { });
147
+ const socket = wire(stubController({ pinchZoom }));
148
+ await expect(socket.emit("pinch_zoom", { scale: 2 })).resolves.toEqual({
149
+ ok: false,
150
+ error: "pinch_zoom requires numeric coordinates",
151
+ });
152
+ expect(pinchZoom).not.toHaveBeenCalled();
153
+ });
154
+ it("rotate_device forwards a valid orientation", async () => {
155
+ const rotateDevice = vi.fn(async () => ({ ok: true }));
156
+ const socket = wire(stubController({ rotateDevice }));
157
+ await expect(socket.emit("rotate_device", { orientation: "landscape" })).resolves.toEqual({
158
+ ok: true,
159
+ });
160
+ expect(rotateDevice).toHaveBeenCalledWith("landscape");
161
+ });
162
+ it("rotate_device rejects an invalid orientation without calling the controller", async () => {
163
+ const rotateDevice = vi.fn(async () => ({ ok: true }));
164
+ const socket = wire(stubController({ rotateDevice }));
165
+ await expect(socket.emit("rotate_device", { orientation: "sideways" })).resolves.toEqual({
166
+ ok: false,
167
+ error: 'rotate_device requires orientation "portrait" or "landscape"',
168
+ });
169
+ expect(rotateDevice).not.toHaveBeenCalled();
170
+ });
171
+ it("set_location forwards coordinates with altitude defaulting to 0", async () => {
172
+ const setLocation = vi.fn(async () => ({ ok: true }));
173
+ const socket = wire(stubController({ setLocation }));
174
+ await expect(socket.emit("set_location", { latitude: 35.68, longitude: 139.69 })).resolves.toEqual({
175
+ ok: true,
176
+ });
177
+ expect(setLocation).toHaveBeenCalledWith(35.68, 139.69, 0);
178
+ });
179
+ it("set_location rejects a non-numeric latitude without calling the controller", async () => {
180
+ const setLocation = vi.fn(async () => ({ ok: true }));
181
+ const socket = wire(stubController({ setLocation }));
182
+ await expect(socket.emit("set_location", { latitude: "35", longitude: 139.69 })).resolves.toEqual({
183
+ ok: false,
184
+ error: "set_location requires numeric coordinates",
185
+ });
186
+ expect(setLocation).not.toHaveBeenCalled();
187
+ });
188
+ it("set_location acks the structured failure from the controller", async () => {
189
+ const setLocation = vi.fn(async () => ({ ok: false, error: "mock location not allowed" }));
190
+ const socket = wire(stubController({ setLocation }));
191
+ await expect(socket.emit("set_location", { latitude: 1, longitude: 2, altitude: 30 })).resolves.toEqual({ ok: false, error: "mock location not allowed" });
192
+ expect(setLocation).toHaveBeenCalledWith(1, 2, 30);
193
+ });
119
194
  it("hide_keyboard acks ok", async () => {
120
195
  const hideKeyboard = vi.fn(async () => ({ ok: true }));
121
196
  const socket = wire(stubController({ hideKeyboard }));
@@ -153,3 +228,85 @@ describe("registerHandlers", () => {
153
228
  });
154
229
  });
155
230
  });
231
+ describe("registerHandlers session guard", () => {
232
+ const BUSY = { ok: false, error: "Device is busy with another session" };
233
+ function fire(socket, event, args) {
234
+ socket.handlers.get(event)?.(args);
235
+ }
236
+ it("strips __sessionId for the handler without mutating the incoming payload", async () => {
237
+ const tap = vi.fn(async () => { });
238
+ const socket = wire(stubController({ tap }));
239
+ const payload = { x: 5, y: 9, __sessionId: "s1" };
240
+ await expect(socket.emit("tap", payload)).resolves.toEqual({ ok: true });
241
+ expect(tap).toHaveBeenCalledWith(5, 9);
242
+ expect(payload.__sessionId).toBe("s1");
243
+ });
244
+ it("rejects a second session's command while the first is active", async () => {
245
+ const tap = vi.fn(async () => { });
246
+ const socket = wire(stubController({ tap }));
247
+ await socket.emit("tap", { x: 1, y: 2, __sessionId: "s1" });
248
+ await expect(socket.emit("tap", { x: 3, y: 4, __sessionId: "s2" })).resolves.toEqual(BUSY);
249
+ expect(tap).toHaveBeenCalledTimes(1);
250
+ });
251
+ it("accepts a new session after session_released for the active one", async () => {
252
+ const tap = vi.fn(async () => { });
253
+ const socket = wire(stubController({ tap }));
254
+ await socket.emit("tap", { x: 1, y: 2, __sessionId: "s1" });
255
+ fire(socket, "session_released", { sessionId: "s1" });
256
+ await expect(socket.emit("tap", { x: 3, y: 4, __sessionId: "s2" })).resolves.toEqual({
257
+ ok: true,
258
+ });
259
+ expect(tap).toHaveBeenCalledTimes(2);
260
+ });
261
+ it("ignores session_released for a session that is not active", async () => {
262
+ const tap = vi.fn(async () => { });
263
+ const socket = wire(stubController({ tap }));
264
+ await socket.emit("tap", { x: 1, y: 2, __sessionId: "s1" });
265
+ fire(socket, "session_released", { sessionId: "other" });
266
+ await expect(socket.emit("tap", { x: 3, y: 4, __sessionId: "s2" })).resolves.toEqual(BUSY);
267
+ expect(tap).toHaveBeenCalledTimes(1);
268
+ });
269
+ it("ignores session_released without a sessionId (malformed release)", async () => {
270
+ const tap = vi.fn(async () => { });
271
+ const socket = wire(stubController({ tap }));
272
+ await socket.emit("tap", { x: 1, y: 2, __sessionId: "s1" });
273
+ fire(socket, "session_released", {});
274
+ fire(socket, "session_released", { sessionId: null });
275
+ await expect(socket.emit("tap", { x: 3, y: 4, __sessionId: "s2" })).resolves.toEqual(BUSY);
276
+ expect(tap).toHaveBeenCalledTimes(1);
277
+ });
278
+ it("switches owner on session_acquired and rejects the superseded session", async () => {
279
+ const tap = vi.fn(async () => { });
280
+ const socket = wire(stubController({ tap }));
281
+ await socket.emit("tap", { x: 1, y: 2, __sessionId: "s1" });
282
+ fire(socket, "session_acquired", { sessionId: "s2" });
283
+ await expect(socket.emit("tap", { x: 3, y: 4, __sessionId: "s1" })).resolves.toEqual(BUSY);
284
+ await expect(socket.emit("tap", { x: 5, y: 6, __sessionId: "s2" })).resolves.toEqual({
285
+ ok: true,
286
+ });
287
+ expect(tap).toHaveBeenCalledTimes(2);
288
+ });
289
+ it("allows takeover once the active session is idle past the lease", async () => {
290
+ vi.useFakeTimers();
291
+ try {
292
+ const tap = vi.fn(async () => { });
293
+ const socket = wire(stubController({ tap }));
294
+ await socket.emit("tap", { x: 1, y: 2, __sessionId: "s1" });
295
+ vi.advanceTimersByTime(26 * 60 * 1000);
296
+ await expect(socket.emit("tap", { x: 3, y: 4, __sessionId: "s2" })).resolves.toEqual({
297
+ ok: true,
298
+ });
299
+ expect(tap).toHaveBeenCalledTimes(2);
300
+ }
301
+ finally {
302
+ vi.useRealTimers();
303
+ }
304
+ });
305
+ it("leaves untagged commands ungated (pre-tagging gateways)", async () => {
306
+ const tap = vi.fn(async () => { });
307
+ const socket = wire(stubController({ tap }));
308
+ await socket.emit("tap", { x: 1, y: 2, __sessionId: "s1" });
309
+ await expect(socket.emit("tap", { x: 3, y: 4 })).resolves.toEqual({ ok: true });
310
+ expect(tap).toHaveBeenCalledTimes(2);
311
+ });
312
+ });
@@ -18,6 +18,12 @@ export declare class WebDriverSession {
18
18
  private requireId;
19
19
  screenshot(): Promise<Buffer>;
20
20
  windowRect(): Promise<WindowRect>;
21
+ setOrientation(orientation: "PORTRAIT" | "LANDSCAPE"): Promise<void>;
22
+ setGeoLocation(location: {
23
+ latitude: number;
24
+ longitude: number;
25
+ altitude: number;
26
+ }): Promise<void>;
21
27
  performActions(actions: unknown[]): Promise<void>;
22
28
  releaseActions(): Promise<void>;
23
29
  getActiveElement(): Promise<string | null>;
@@ -25,7 +31,7 @@ export declare class WebDriverSession {
25
31
  elementClick(elementRef: string): Promise<void>;
26
32
  elementClear(elementRef: string): Promise<void>;
27
33
  elementSendKeys(elementRef: string, text: string): Promise<void>;
34
+ elementDisplayed(elementRef: string): Promise<boolean>;
28
35
  execute(script: string, args?: unknown[]): Promise<unknown>;
29
- setValueOnActiveElement(text: string): Promise<void>;
30
36
  }
31
37
  export declare const ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf";
@@ -84,6 +84,14 @@ export class WebDriverSession {
84
84
  y: Number(value?.y ?? 0),
85
85
  };
86
86
  }
87
+ async setOrientation(orientation) {
88
+ await this.transport.request("POST", `/session/${this.requireId()}/orientation`, {
89
+ orientation,
90
+ });
91
+ }
92
+ async setGeoLocation(location) {
93
+ await this.transport.request("POST", `/session/${this.requireId()}/location`, { location });
94
+ }
87
95
  async performActions(actions) {
88
96
  await this.transport.request("POST", `/session/${this.requireId()}/actions`, { actions });
89
97
  }
@@ -110,18 +118,16 @@ export class WebDriverSession {
110
118
  async elementSendKeys(elementRef, text) {
111
119
  await this.transport.request("POST", `/session/${this.requireId()}/element/${elementRef}/value`, { text, value: [...text] });
112
120
  }
121
+ async elementDisplayed(elementRef) {
122
+ const value = await this.transport.request("GET", `/session/${this.requireId()}/element/${elementRef}/displayed`);
123
+ return value === true;
124
+ }
113
125
  async execute(script, args = []) {
114
126
  return this.transport.request("POST", `/session/${this.requireId()}/execute/sync`, {
115
127
  script,
116
128
  args,
117
129
  });
118
130
  }
119
- async setValueOnActiveElement(text) {
120
- const active = await this.getActiveElement();
121
- if (!active)
122
- throw new Error("No active element to receive text");
123
- await this.elementSendKeys(active, text);
124
- }
125
131
  }
126
132
  export const ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf";
127
133
  function elementId(value) {
package/dist/mutate.d.ts CHANGED
@@ -5,7 +5,7 @@ export interface DryRunResult {
5
5
  url: string;
6
6
  body?: unknown;
7
7
  }
8
- export declare function dryRunEnvelope(method: string, path: string, body?: unknown): SuccessEnvelope<DryRunResult>;
8
+ export declare function dryRunEnvelope(method: string, pathOrUrl: string, body?: unknown): SuccessEnvelope<DryRunResult>;
9
9
  export declare function resolveJsonOverride(jsonFlag: unknown): {
10
10
  body: Record<string, unknown>;
11
11
  } | {
package/dist/mutate.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { formatError, formatSuccess, validationError } from "./output.js";
2
2
  import { parseJsonBody, JsonBodyError, OPENAPI_URL } from "./validate.js";
3
3
  import { getBaseUrl } from "./client.js";
4
- export function dryRunEnvelope(method, path, body) {
5
- const url = `${getBaseUrl()}${path.startsWith("/") ? path : `/${path}`}`;
4
+ export function dryRunEnvelope(method, pathOrUrl, body) {
5
+ const url = /^https?:\/\//.test(pathOrUrl)
6
+ ? pathOrUrl
7
+ : `${getBaseUrl()}${pathOrUrl.startsWith("/") ? pathOrUrl : `/${pathOrUrl}`}`;
6
8
  const data = { dryRun: true, method, url };
7
9
  if (body !== undefined)
8
10
  data.body = body;
package/dist/types.d.ts CHANGED
@@ -19,8 +19,12 @@ export interface ErrorEnvelope {
19
19
  export type Envelope<T> = SuccessEnvelope<T> | ErrorEnvelope;
20
20
  export type Platform = "ANDROID" | "IOS";
21
21
  export type FileType = "ANDROID_APP" | "IOS_APP";
22
+ export type ExecutionMode = "parallel" | "sequential";
23
+ export type CaseType = "web" | "mobile" | "desktop" | "multi_device";
24
+ export type CaseFormat = "gherkin" | "markdown" | "plain_text";
22
25
  export interface HealthStatus {
23
26
  status: string;
27
+ auth?: string | undefined;
24
28
  }
25
29
  export interface CreateSessionInput {
26
30
  title: string;
@@ -75,11 +79,30 @@ export interface CaseRunStatus {
75
79
  export interface PlanRunInput {
76
80
  model?: string | undefined;
77
81
  mobileConfig?: Record<string, unknown> | undefined;
82
+ executionMode?: ExecutionMode | undefined;
78
83
  }
79
84
  export interface PlanRunResult {
80
85
  success: boolean;
81
86
  planRunId: string;
82
87
  totalCases: number;
88
+ executionMode?: ExecutionMode | undefined;
89
+ }
90
+ export interface UpdatePlanInput {
91
+ title?: string | undefined;
92
+ description?: string | undefined;
93
+ executionMode?: ExecutionMode | undefined;
94
+ stopOnFailure?: boolean | undefined;
95
+ }
96
+ export interface UpdatePlanResult {
97
+ success: boolean;
98
+ plan: {
99
+ id: string;
100
+ title: string;
101
+ description: string | null;
102
+ executionMode: ExecutionMode;
103
+ stopOnFailure: boolean;
104
+ updatedAt: string;
105
+ };
83
106
  }
84
107
  export interface PlanRun {
85
108
  id: string;
@@ -123,10 +146,10 @@ export interface Project {
123
146
  export interface CreateCaseInput {
124
147
  projectId: string;
125
148
  scenario: string;
126
- type?: string | undefined;
149
+ type?: CaseType | undefined;
127
150
  title?: string | undefined;
128
151
  description?: string | undefined;
129
- format?: string | undefined;
152
+ format?: CaseFormat | undefined;
130
153
  startUrl?: string | undefined;
131
154
  directoryId?: string | undefined;
132
155
  }
@@ -134,6 +157,28 @@ export interface CreateCaseResult {
134
157
  success: boolean;
135
158
  caseId: string;
136
159
  }
160
+ export interface UpdateCaseInput {
161
+ title?: string | undefined;
162
+ description?: string | undefined;
163
+ scenario?: string | undefined;
164
+ type?: CaseType | undefined;
165
+ format?: CaseFormat | undefined;
166
+ startUrl?: string | undefined;
167
+ }
168
+ export interface UpdateCaseResult {
169
+ success: boolean;
170
+ case: {
171
+ id: string;
172
+ projectId: string;
173
+ type: string;
174
+ title: string;
175
+ description: string | null;
176
+ scenario: string;
177
+ format: string;
178
+ startUrl: string | null;
179
+ updatedAt: string;
180
+ };
181
+ }
137
182
  export interface DeleteCaseResult {
138
183
  success: boolean;
139
184
  }
@@ -151,9 +196,9 @@ export interface PlanCaseResult {
151
196
  }
152
197
  export interface SaveSessionAsCaseInput {
153
198
  title?: string | undefined;
154
- type?: string | undefined;
199
+ type?: Exclude<CaseType, "multi_device"> | undefined;
155
200
  scenario?: string | undefined;
156
- format?: string | undefined;
201
+ format?: CaseFormat | undefined;
157
202
  }
158
203
  export interface SaveSessionAsCaseResult {
159
204
  success: boolean;
@@ -1,10 +1,15 @@
1
- import type { Platform, FileType } from "./types.js";
1
+ import type { CaseFormat, CaseType, ExecutionMode, FileType, Platform } from "./types.js";
2
2
  export declare function optionalString(name: string, value: unknown): string | undefined;
3
3
  export declare function parseStringList(name: string, value: unknown): string[] | undefined;
4
4
  export declare function requireString(name: string, value: unknown): string;
5
5
  export declare function requireId(name: string, value: unknown): string;
6
6
  export declare function parsePlatform(value: unknown): Platform | undefined;
7
7
  export declare function parseFileType(value: unknown): FileType | undefined;
8
+ export declare function parseExecutionMode(value: unknown): ExecutionMode | undefined;
9
+ export declare function parseCaseType(value: unknown): CaseType | undefined;
10
+ export declare function parseSaveAsCaseType(value: unknown): Exclude<CaseType, "multi_device"> | undefined;
11
+ export declare function parseCaseFormat(value: unknown): CaseFormat | undefined;
12
+ export declare function parseOptionalBoolean(name: string, value: unknown): boolean | undefined;
8
13
  export declare function parseTags(value: unknown): string[] | undefined;
9
14
  export declare const OPENAPI_URL = "https://aximo.autify.com/openapi.yaml";
10
15
  export declare class JsonBodyError extends Error {
package/dist/validate.js CHANGED
@@ -56,6 +56,52 @@ export function parseFileType(value) {
56
56
  }
57
57
  return value;
58
58
  }
59
+ const EXECUTION_MODES = new Set(["parallel", "sequential"]);
60
+ export function parseExecutionMode(value) {
61
+ if (value === undefined || value === "")
62
+ return undefined;
63
+ if (typeof value !== "string" || !EXECUTION_MODES.has(value)) {
64
+ throw new Error(`executionMode must be parallel or sequential (got ${String(value)})`);
65
+ }
66
+ return value;
67
+ }
68
+ const CASE_TYPES = new Set(["web", "mobile", "desktop", "multi_device"]);
69
+ export function parseCaseType(value) {
70
+ if (value === undefined || value === "")
71
+ return undefined;
72
+ if (typeof value !== "string" || !CASE_TYPES.has(value)) {
73
+ throw new Error(`type must be web, mobile, desktop, or multi_device (got ${String(value)})`);
74
+ }
75
+ return value;
76
+ }
77
+ // save-as-case's server schema accepts a narrower set than case create/update.
78
+ export function parseSaveAsCaseType(value) {
79
+ const parsed = parseCaseType(value);
80
+ if (parsed === "multi_device") {
81
+ throw new Error(`type must be web, mobile, or desktop (got ${String(value)})`);
82
+ }
83
+ return parsed;
84
+ }
85
+ const CASE_FORMATS = new Set(["gherkin", "markdown", "plain_text"]);
86
+ export function parseCaseFormat(value) {
87
+ if (value === undefined || value === "")
88
+ return undefined;
89
+ if (typeof value !== "string" || !CASE_FORMATS.has(value)) {
90
+ throw new Error(`format must be gherkin, markdown, or plain_text (got ${String(value)})`);
91
+ }
92
+ return value;
93
+ }
94
+ // Tri-state boolean for PATCH-style flags: absent means "leave unchanged",
95
+ // so a citty boolean (which defaults to false) would silently overwrite.
96
+ export function parseOptionalBoolean(name, value) {
97
+ if (value === undefined || value === "")
98
+ return undefined;
99
+ if (value === true || value === "true")
100
+ return true;
101
+ if (value === false || value === "false")
102
+ return false;
103
+ throw new Error(`${name} must be true or false (got ${String(value)})`);
104
+ }
59
105
  export function parseTags(value) {
60
106
  return parseStringList("tags", value);
61
107
  }
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { requireString, requireId, parsePlatform, parseFileType, parseTags, parseJsonObject, parseJsonBody, JsonBodyError, } from "./validate.js";
2
+ import { requireString, requireId, parseCaseFormat, parseCaseType, parsePlatform, parseSaveAsCaseType, parseFileType, parseTags, parseJsonObject, parseJsonBody, JsonBodyError, } from "./validate.js";
3
3
  describe("requireId", () => {
4
4
  it("accepts plain IDs", () => {
5
5
  expect(requireId("sessionId", "abc123_-")).toBe("abc123_-");
@@ -94,3 +94,41 @@ describe("parseJsonObject", () => {
94
94
  expect(parseJsonObject("x", "")).toBeUndefined();
95
95
  });
96
96
  });
97
+ describe("parseCaseType", () => {
98
+ it("accepts all case types", () => {
99
+ expect(parseCaseType("web")).toBe("web");
100
+ expect(parseCaseType("mobile")).toBe("mobile");
101
+ expect(parseCaseType("desktop")).toBe("desktop");
102
+ expect(parseCaseType("multi_device")).toBe("multi_device");
103
+ });
104
+ it("returns undefined for absent values", () => {
105
+ expect(parseCaseType(undefined)).toBeUndefined();
106
+ expect(parseCaseType("")).toBeUndefined();
107
+ });
108
+ it("rejects unknown values", () => {
109
+ expect(() => parseCaseType("banana")).toThrow(/must be web, mobile, desktop/);
110
+ });
111
+ });
112
+ describe("parseSaveAsCaseType", () => {
113
+ it("accepts the narrower save-as-case set", () => {
114
+ expect(parseSaveAsCaseType("web")).toBe("web");
115
+ expect(parseSaveAsCaseType("desktop")).toBe("desktop");
116
+ });
117
+ it("rejects multi_device, which the save-as-case endpoint does not accept", () => {
118
+ expect(() => parseSaveAsCaseType("multi_device")).toThrow(/must be web, mobile, or desktop/);
119
+ });
120
+ });
121
+ describe("parseCaseFormat", () => {
122
+ it("accepts all formats", () => {
123
+ expect(parseCaseFormat("gherkin")).toBe("gherkin");
124
+ expect(parseCaseFormat("markdown")).toBe("markdown");
125
+ expect(parseCaseFormat("plain_text")).toBe("plain_text");
126
+ });
127
+ it("returns undefined for absent values", () => {
128
+ expect(parseCaseFormat(undefined)).toBeUndefined();
129
+ expect(parseCaseFormat("")).toBeUndefined();
130
+ });
131
+ it("rejects unknown values", () => {
132
+ expect(() => parseCaseFormat("klingon")).toThrow(/must be gherkin, markdown, or plain_text/);
133
+ });
134
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autifyhq/aximo-cli",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Autify Aximo Command Line Interface (CLI)",
5
5
  "keywords": [
6
6
  "autify",