@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.
Files changed (48) hide show
  1. package/README.md +18 -6
  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 +25 -10
  15. package/dist/commands/mobile/connect.d.ts +5 -0
  16. package/dist/commands/mobile/connect.js +6 -1
  17. package/dist/commands/mobile/signing-consent.d.ts +18 -0
  18. package/dist/commands/mobile/signing-consent.js +28 -0
  19. package/dist/commands/mobile/signing-consent.test.d.ts +1 -0
  20. package/dist/commands/mobile/signing-consent.test.js +50 -0
  21. package/dist/commands/plan/index.js +1 -0
  22. package/dist/commands/plan/plan.test.js +61 -0
  23. package/dist/commands/plan/update.d.ts +42 -0
  24. package/dist/commands/plan/update.js +78 -0
  25. package/dist/commands/schema.js +1 -1
  26. package/dist/commands/session/save-as-case.js +3 -3
  27. package/dist/commands/trigger/plan/run.d.ts +6 -1
  28. package/dist/commands/trigger/plan/run.js +9 -3
  29. package/dist/commands/trigger/trigger.test.js +22 -3
  30. package/dist/commands/upload/put.d.ts +5 -0
  31. package/dist/commands/upload/put.js +44 -11
  32. package/dist/commands/upload/upload.test.js +82 -0
  33. package/dist/mobile-control/appium-controller.d.ts +4 -0
  34. package/dist/mobile-control/appium-controller.js +90 -2
  35. package/dist/mobile-control/appium-controller.test.js +156 -8
  36. package/dist/mobile-control/bundle-id-map.js +3 -0
  37. package/dist/mobile-control/controller.d.ts +3 -0
  38. package/dist/mobile-control/tunnel.js +23 -0
  39. package/dist/mobile-control/tunnel.test.js +75 -0
  40. package/dist/mobile-control/webdriver.d.ts +7 -1
  41. package/dist/mobile-control/webdriver.js +12 -6
  42. package/dist/mutate.d.ts +1 -1
  43. package/dist/mutate.js +4 -2
  44. package/dist/types.d.ts +49 -4
  45. package/dist/validate.d.ts +6 -1
  46. package/dist/validate.js +46 -0
  47. package/dist/validate.test.js +39 -1
  48. package/package.json +1 -1
@@ -12,6 +12,8 @@ const ANDROID_KEY_CODES = {
12
12
  };
13
13
  const ANDROID_KEYCODE_A = 29;
14
14
  const ANDROID_META_CTRL_ON = 4096;
15
+ // Chars that break Android's `mobile: type` (adb shell input text): shell metachars, non-ASCII.
16
+ const ANDROID_INPUT_TEXT_UNSAFE = /[^A-Za-z0-9 @:,./+_=-]/;
15
17
  const IOS_BUTTONS = {
16
18
  home: "home",
17
19
  };
@@ -155,6 +157,31 @@ export class AppiumController {
155
157
  { type: "pointerUp", button: 0 },
156
158
  ]);
157
159
  }
160
+ // Two-finger vertical pinch: scale < 1 pinches in (zoom out), > 1 spreads (zoom in).
161
+ async pinchZoom(x, y, scale, durationMs) {
162
+ await this.ensureSession();
163
+ const [cx, cy] = await this.toPoint(x, y);
164
+ const startOffset = 100;
165
+ const endOffset = startOffset * scale;
166
+ const duration = Math.max(0, Math.round(durationMs));
167
+ const finger = (id, startY, endY) => ({
168
+ type: "pointer",
169
+ id,
170
+ parameters: { pointerType: "touch" },
171
+ actions: [
172
+ { type: "pointerMove", duration: 0, x: cx, y: Math.round(startY) },
173
+ { type: "pointerDown", button: 0 },
174
+ { type: "pause", duration: 50 },
175
+ { type: "pointerMove", duration, x: cx, y: Math.round(endY) },
176
+ { type: "pointerUp", button: 0 },
177
+ ],
178
+ });
179
+ await this.session.performActions([
180
+ finger("finger1", cy - startOffset / 2, cy - endOffset / 2),
181
+ finger("finger2", cy + startOffset / 2, cy + endOffset / 2),
182
+ ]);
183
+ await this.session.releaseActions();
184
+ }
158
185
  async performPointer(actions) {
159
186
  await this.session.performActions(this.pointerAction(actions));
160
187
  await this.session.releaseActions();
@@ -163,16 +190,42 @@ export class AppiumController {
163
190
  await this.ensureSession();
164
191
  try {
165
192
  if (this.platform === "android") {
193
+ if (ANDROID_INPUT_TEXT_UNSAFE.test(text)) {
194
+ const active = await this.activeElementOrNull();
195
+ if (!active) {
196
+ return {
197
+ ok: false,
198
+ error: "Cannot type special or non-ASCII characters: no input field is focused. Tap the field first.",
199
+ };
200
+ }
201
+ await this.session.elementSendKeys(active, text);
202
+ return { ok: true };
203
+ }
166
204
  await this.session.execute("mobile: type", [{ text }]);
167
205
  return { ok: true };
168
206
  }
169
- await this.session.setValueOnActiveElement(text);
207
+ // XCUITest: per-char W3C key actions; no focused element required.
208
+ const actions = [...text].flatMap((char) => [
209
+ { type: "keyDown", value: char },
210
+ { type: "keyUp", value: char },
211
+ ]);
212
+ await this.session.performActions([{ type: "key", id: "keyboard", actions }]);
213
+ await this.session.releaseActions();
170
214
  return { ok: true };
171
215
  }
172
216
  catch (error) {
173
217
  return fail(error);
174
218
  }
175
219
  }
220
+ // Some drivers report "no focus" by rejecting; treat that as null.
221
+ async activeElementOrNull() {
222
+ try {
223
+ return await this.session.getActiveElement();
224
+ }
225
+ catch {
226
+ return null;
227
+ }
228
+ }
176
229
  async clearText() {
177
230
  await this.ensureSession();
178
231
  try {
@@ -277,7 +330,8 @@ export class AppiumController {
277
330
  for (const [using, value] of strategies) {
278
331
  try {
279
332
  const ref = await this.session.findElement(using, value);
280
- if (ref)
333
+ // Skip hidden matches so the next strategy can find a visible one.
334
+ if (ref && (await this.session.elementDisplayed(ref)))
281
335
  return ref;
282
336
  }
283
337
  catch { }
@@ -294,6 +348,40 @@ export class AppiumController {
294
348
  return { ok: true };
295
349
  }
296
350
  }
351
+ async rotateDevice(orientation) {
352
+ await this.ensureSession();
353
+ try {
354
+ const wantLandscape = orientation === "landscape";
355
+ await this.session.setOrientation(wantLandscape ? "LANDSCAPE" : "PORTRAIT");
356
+ // Rotation changes the window rect, so the cached pixel->point scale is stale.
357
+ this.scaling = null;
358
+ // Poll up to 2s until the OS reports the new dimensions; rotation speed varies.
359
+ const deadline = Date.now() + 2000;
360
+ while (Date.now() < deadline) {
361
+ try {
362
+ const rect = await this.session.windowRect();
363
+ if (rect.width > rect.height === wantLandscape)
364
+ break;
365
+ }
366
+ catch { }
367
+ await new Promise((resolve) => setTimeout(resolve, 100));
368
+ }
369
+ return { ok: true };
370
+ }
371
+ catch (error) {
372
+ return fail(error);
373
+ }
374
+ }
375
+ async setLocation(latitude, longitude, altitude) {
376
+ await this.ensureSession();
377
+ try {
378
+ await this.session.setGeoLocation({ latitude, longitude, altitude });
379
+ return { ok: true };
380
+ }
381
+ catch (error) {
382
+ return fail(error);
383
+ }
384
+ }
297
385
  async getDeviceTime() {
298
386
  await this.ensureSession();
299
387
  try {
@@ -11,24 +11,47 @@ function fakeTransport(options = {}) {
11
11
  const calls = [];
12
12
  const windowWidth = options.windowWidth ?? 400;
13
13
  const screenshotWidth = options.screenshotWidth ?? 400;
14
+ const findQueue = options.findElementRefs ? [...options.findElementRefs] : null;
15
+ let landscape = false;
14
16
  const transport = {
15
17
  async request(method, path, body) {
16
18
  calls.push({ method, path, body });
17
19
  if (method === "POST" && path === "/session") {
18
20
  return { sessionId: "sess-1" };
19
21
  }
22
+ if (path.endsWith("/orientation")) {
23
+ landscape = body.orientation === "LANDSCAPE";
24
+ return {};
25
+ }
26
+ if (path.endsWith("/location")) {
27
+ if (options.locationError)
28
+ throw new Error(options.locationError);
29
+ return {};
30
+ }
20
31
  if (path.endsWith("/screenshot")) {
21
32
  return pngWithWidth(screenshotWidth);
22
33
  }
23
34
  if (path.endsWith("/window/rect")) {
24
- return { width: windowWidth, height: windowWidth * 2, x: 0, y: 0 };
35
+ return landscape
36
+ ? { width: windowWidth * 2, height: windowWidth, x: 0, y: 0 }
37
+ : { width: windowWidth, height: windowWidth * 2, x: 0, y: 0 };
25
38
  }
26
39
  if (path.endsWith("/element/active")) {
40
+ if (options.activeElementError)
41
+ throw new Error(options.activeElementError);
27
42
  const ref = options.activeElement === undefined ? "active-el" : options.activeElement;
28
43
  return ref ? { [ELEMENT_KEY]: ref } : null;
29
44
  }
45
+ if (method === "GET" && path.endsWith("/displayed")) {
46
+ const ref = path.split("/").at(-2) ?? "";
47
+ return options.displayed ? options.displayed(ref) : true;
48
+ }
30
49
  if (method === "POST" && path.endsWith("/element")) {
31
- const ref = options.findElementRef === undefined ? "found-el" : options.findElementRef;
50
+ const ref = findQueue
51
+ ? (findQueue.shift() ?? null)
52
+ : options.findElementRef === undefined
53
+ ? "found-el"
54
+ : options.findElementRef;
32
55
  return ref ? { [ELEMENT_KEY]: ref } : null;
33
56
  }
34
57
  return {};
@@ -66,6 +89,12 @@ function pointerActions(calls) {
66
89
  const groups = body["actions"];
67
90
  return groups[0]?.actions ?? [];
68
91
  }
92
+ function lastPointerActions(calls) {
93
+ const actionCalls = calls.filter((c) => c.path.endsWith("/actions") && c.method === "POST");
94
+ const body = (actionCalls[actionCalls.length - 1]?.body ?? {});
95
+ const groups = body["actions"];
96
+ return groups[0]?.actions ?? [];
97
+ }
69
98
  describe("AppiumController session capabilities", () => {
70
99
  it("sends Android UiAutomator2 caps", async () => {
71
100
  const { controller, calls } = makeController("android");
@@ -145,6 +174,75 @@ describe("AppiumController touch actions", () => {
145
174
  expect(downs).toHaveLength(2);
146
175
  });
147
176
  });
177
+ describe("AppiumController pinch zoom", () => {
178
+ it("sends two synchronized touch pointers split around the scaled center", async () => {
179
+ const { controller, calls } = makeController("ios", { windowWidth: 400, screenshotWidth: 800 });
180
+ await controller.pinchZoom(200, 400, 2, 500);
181
+ const body = bodyOf(calls, (c) => c.path.endsWith("/actions") && c.method === "POST");
182
+ const groups = body["actions"];
183
+ expect(groups).toHaveLength(2);
184
+ expect(groups.every((g) => g.type === "pointer" && g.parameters.pointerType === "touch")).toBe(true);
185
+ expect(groups[0]?.id).not.toBe(groups[1]?.id);
186
+ // Center scales 2x to (100, 200); start offset ±50, end offset ±100 (100 * scale / 2).
187
+ expect(groups[0]?.actions[0]).toMatchObject({ type: "pointerMove", x: 100, y: 150 });
188
+ expect(groups[0]?.actions[3]).toMatchObject({
189
+ type: "pointerMove",
190
+ x: 100,
191
+ y: 100,
192
+ duration: 500,
193
+ });
194
+ expect(groups[1]?.actions[0]).toMatchObject({ type: "pointerMove", x: 100, y: 250 });
195
+ expect(groups[1]?.actions[3]).toMatchObject({
196
+ type: "pointerMove",
197
+ x: 100,
198
+ y: 300,
199
+ duration: 500,
200
+ });
201
+ expect(calls.some((c) => c.method === "DELETE" && c.path.endsWith("/actions"))).toBe(true);
202
+ });
203
+ it("moves fingers toward the center when scale < 1 (zoom out)", async () => {
204
+ const { controller, calls } = makeController("android");
205
+ await controller.pinchZoom(100, 300, 0.5, 600);
206
+ const body = bodyOf(calls, (c) => c.path.endsWith("/actions") && c.method === "POST");
207
+ const groups = body["actions"];
208
+ expect(groups[0]?.actions[3]).toMatchObject({ x: 100, y: 275 });
209
+ expect(groups[1]?.actions[3]).toMatchObject({ x: 100, y: 325 });
210
+ });
211
+ });
212
+ describe("AppiumController rotation + location", () => {
213
+ it("rotateDevice posts the W3C orientation and refreshes coordinate scaling", async () => {
214
+ const { controller, calls } = makeController("ios", { windowWidth: 400, screenshotWidth: 800 });
215
+ await controller.tap(200, 100); // caches the 2x pixel->point scale
216
+ const result = await controller.rotateDevice("landscape");
217
+ expect(result.ok).toBe(true);
218
+ const body = bodyOf(calls, (c) => c.path.endsWith("/orientation"));
219
+ expect(body["orientation"]).toBe("LANDSCAPE");
220
+ // Landscape rect is 800pt wide -> scale is 1 now; a stale cache would halve these.
221
+ await controller.tap(200, 100);
222
+ const move = lastPointerActions(calls)[0];
223
+ expect(move.x).toBe(200);
224
+ expect(move.y).toBe(100);
225
+ });
226
+ it("rotateDevice maps portrait to PORTRAIT", async () => {
227
+ const { controller, calls } = makeController("android");
228
+ const result = await controller.rotateDevice("portrait");
229
+ expect(result.ok).toBe(true);
230
+ expect(bodyOf(calls, (c) => c.path.endsWith("/orientation"))["orientation"]).toBe("PORTRAIT");
231
+ });
232
+ it("setLocation posts the W3C location body", async () => {
233
+ const { controller, calls } = makeController("android");
234
+ const result = await controller.setLocation(35.68, 139.69, 10);
235
+ expect(result.ok).toBe(true);
236
+ const body = bodyOf(calls, (c) => c.path.endsWith("/location"));
237
+ expect(body["location"]).toEqual({ latitude: 35.68, longitude: 139.69, altitude: 10 });
238
+ });
239
+ it("setLocation returns the WebDriver failure as a structured error", async () => {
240
+ const { controller } = makeController("android", { locationError: "mock location denied" });
241
+ const result = await controller.setLocation(1, 2, 0);
242
+ expect(result.ok).toBe(false);
243
+ expect(result.error).toContain("mock location denied");
244
+ });
245
+ });
148
246
  describe("AppiumController text + keys", () => {
149
247
  it("typeText on Android uses mobile: type", async () => {
150
248
  const { controller, calls } = makeController("android");
@@ -154,12 +252,41 @@ describe("AppiumController text + keys", () => {
154
252
  expect(exec.script).toBe("mobile: type");
155
253
  expect(exec.args[0]?.["text"]).toBe("hello");
156
254
  });
157
- it("typeText on iOS sends keys to the active element", async () => {
158
- const { controller, calls } = makeController("ios");
159
- await controller.typeText("hi");
160
- expect(calls.some((c) => c.path.endsWith("/element/active"))).toBe(true);
161
- const send = bodyOf(calls, (c) => c.path.endsWith("/element/active-el/value"));
162
- expect(send["text"]).toBe("hi");
255
+ it("typeText on Android types unsafe text into the focused element", async () => {
256
+ const { controller, calls } = makeController("android", { activeElement: "el-1" });
257
+ const result = await controller.typeText("パスワード$1!");
258
+ expect(result.ok).toBe(true);
259
+ expect(calls.some((c) => c.path.endsWith("/execute/sync"))).toBe(false);
260
+ const send = bodyOf(calls, (c) => c.path.endsWith("/element/el-1/value"));
261
+ expect(send["text"]).toBe("パスワード$1!");
262
+ });
263
+ it("typeText on Android returns a clear error for unsafe text with no focused element", async () => {
264
+ const { controller } = makeController("android", { activeElement: null });
265
+ const result = await controller.typeText("p@ss!word");
266
+ expect(result.ok).toBe(false);
267
+ expect(result.error).toBe("Cannot type special or non-ASCII characters: no input field is focused. Tap the field first.");
268
+ });
269
+ it("typeText on Android treats an active-element rejection as no focus", async () => {
270
+ const { controller } = makeController("android", { activeElementError: "no such element" });
271
+ const result = await controller.typeText("héllo");
272
+ expect(result.ok).toBe(false);
273
+ expect(result.error).toContain("no input field is focused");
274
+ });
275
+ it("typeText on iOS sends per-char key actions without resolving the active element", async () => {
276
+ const { controller, calls } = makeController("ios", { activeElement: null });
277
+ const result = await controller.typeText("hi");
278
+ expect(result.ok).toBe(true);
279
+ expect(calls.some((c) => c.path.endsWith("/element/active"))).toBe(false);
280
+ const body = bodyOf(calls, (c) => c.path.endsWith("/actions") && c.method === "POST");
281
+ const groups = body["actions"];
282
+ expect(groups[0]?.type).toBe("key");
283
+ expect(groups[0]?.actions).toEqual([
284
+ { type: "keyDown", value: "h" },
285
+ { type: "keyUp", value: "h" },
286
+ { type: "keyDown", value: "i" },
287
+ { type: "keyUp", value: "i" },
288
+ ]);
289
+ expect(calls.some((c) => c.method === "DELETE" && c.path.endsWith("/actions"))).toBe(true);
163
290
  });
164
291
  it("clearText clears the active element when present", async () => {
165
292
  const { controller, calls } = makeController("android", { activeElement: "el-9" });
@@ -220,6 +347,27 @@ describe("AppiumController findElement", () => {
220
347
  expect(result.ok).toBe(false);
221
348
  expect(result.error).toContain("not found");
222
349
  });
350
+ it("skips a non-displayed match and clicks the next strategy's visible one", async () => {
351
+ const { controller, calls } = makeController("android", {
352
+ findElementRefs: ["hidden-el", "vis-el"],
353
+ displayed: (ref) => ref === "vis-el",
354
+ });
355
+ const result = await controller.findElement("Sign in");
356
+ expect(result.ok).toBe(true);
357
+ expect(calls.some((c) => c.path.endsWith("/element/hidden-el/click"))).toBe(false);
358
+ expect(calls.some((c) => c.path.endsWith("/element/vis-el/click"))).toBe(true);
359
+ expect(calls.filter((c) => c.method === "POST" && c.path.endsWith("/element"))).toHaveLength(2);
360
+ });
361
+ it("treats a match that is never displayed as not found", async () => {
362
+ const { controller, calls } = makeController("ios", {
363
+ findElementRef: "hidden-el",
364
+ displayed: () => false,
365
+ });
366
+ const result = await controller.findElement("OK");
367
+ expect(result.ok).toBe(false);
368
+ expect(result.error).toContain("not found");
369
+ expect(calls.some((c) => c.path.includes("/click"))).toBe(false);
370
+ });
223
371
  });
224
372
  describe("AppiumController launch + lifecycle", () => {
225
373
  it("launchApp uses mobile: activateApp with appId on Android", async () => {
@@ -12,12 +12,15 @@ export function withBundleIdTranslation(controller, map) {
12
12
  doubleTap: (x, y) => controller.doubleTap(x, y),
13
13
  longPress: (x, y, durationMs) => controller.longPress(x, y, durationMs),
14
14
  swipe: (x1, y1, x2, y2, durationMs) => controller.swipe(x1, y1, x2, y2, durationMs),
15
+ pinchZoom: (x, y, scale, durationMs) => controller.pinchZoom(x, y, scale, durationMs),
15
16
  typeText: (text) => controller.typeText(text),
16
17
  pressKey: (key) => controller.pressKey(key),
17
18
  launchApp: (app) => controller.launchApp(map.get(app) ?? app),
18
19
  findElement: (text) => controller.findElement(text),
19
20
  clearText: () => controller.clearText(),
20
21
  hideKeyboard: () => controller.hideKeyboard(),
22
+ rotateDevice: (orientation) => controller.rotateDevice(orientation),
23
+ setLocation: (latitude, longitude, altitude) => controller.setLocation(latitude, longitude, altitude),
21
24
  close: () => controller.close(),
22
25
  };
23
26
  if (controller.getDeviceTime) {
@@ -8,12 +8,15 @@ export interface MobileController {
8
8
  doubleTap(x: number, y: number): Promise<void>;
9
9
  longPress(x: number, y: number, durationMs: number): Promise<void>;
10
10
  swipe(x1: number, y1: number, x2: number, y2: number, durationMs: number): Promise<void>;
11
+ pinchZoom(x: number, y: number, scale: number, durationMs: number): Promise<void>;
11
12
  typeText(text: string): Promise<ActionResult>;
12
13
  pressKey(key: string): Promise<ActionResult>;
13
14
  launchApp(app: string): Promise<ActionResult>;
14
15
  findElement(text: string): Promise<ActionResult>;
15
16
  clearText(): Promise<ActionResult>;
16
17
  hideKeyboard(): Promise<ActionResult>;
18
+ rotateDevice(orientation: "portrait" | "landscape"): Promise<ActionResult>;
19
+ setLocation(latitude: number, longitude: number, altitude: number): Promise<ActionResult>;
17
20
  getDeviceTime?(): Promise<{
18
21
  datetime: string;
19
22
  timezone: string;
@@ -130,6 +130,29 @@ export function registerHandlers(socket, controller, installApp) {
130
130
  await controller.swipe(r.coords[0], r.coords[1], r.coords[2], r.coords[3], num(args["duration"], 300));
131
131
  return ok();
132
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
+ });
133
156
  on("input", async (args) => asActionResult(await controller.typeText(String(args["text"] ?? ""))));
134
157
  on("key", async (args) => asActionResult(await controller.pressKey(String(args["key"] ?? ""))));
135
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 }));
@@ -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;