@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
@@ -34,14 +34,18 @@ export declare class AppiumController implements MobileController {
34
34
  doubleTap(x: number, y: number): Promise<void>;
35
35
  longPress(x: number, y: number, durationMs: number): Promise<void>;
36
36
  swipe(x1: number, y1: number, x2: number, y2: number, durationMs: number): Promise<void>;
37
+ pinchZoom(x: number, y: number, scale: number, durationMs: number): Promise<void>;
37
38
  private performPointer;
38
39
  typeText(text: string): Promise<ActionResult>;
40
+ private activeElementOrNull;
39
41
  clearText(): Promise<ActionResult>;
40
42
  pressKey(key: string): Promise<ActionResult>;
41
43
  launchApp(app: string): Promise<ActionResult>;
42
44
  findElement(text: string): Promise<ActionResult>;
43
45
  private locate;
44
46
  hideKeyboard(): Promise<ActionResult>;
47
+ rotateDevice(orientation: "portrait" | "landscape"): Promise<ActionResult>;
48
+ setLocation(latitude: number, longitude: number, altitude: number): Promise<ActionResult>;
45
49
  getDeviceTime(): Promise<{
46
50
  datetime: string;
47
51
  timezone: string;
@@ -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;
@@ -2,5 +2,9 @@ export interface ExecResult {
2
2
  stdout: Buffer;
3
3
  stderr: string;
4
4
  }
5
- export type ExecFn = (file: string, args: string[]) => Promise<ExecResult>;
5
+ export interface ExecOptions {
6
+ /** Working directory for the child (e.g. to zip relative paths). */
7
+ cwd?: string;
8
+ }
9
+ export type ExecFn = (file: string, args: string[], opts?: ExecOptions) => Promise<ExecResult>;
6
10
  export declare const execBinary: ExecFn;
@@ -1,7 +1,7 @@
1
1
  import { execFile } from "node:child_process";
2
2
  // Explicit argv, no shell — payloads can't inject into the local machine.
3
- export const execBinary = (file, args) => new Promise((resolve, reject) => {
4
- execFile(file, args, { encoding: "buffer", maxBuffer: 64 * 1024 * 1024 }, (error, stdout, stderr) => {
3
+ export const execBinary = (file, args, opts) => new Promise((resolve, reject) => {
4
+ execFile(file, args, { encoding: "buffer", maxBuffer: 64 * 1024 * 1024, ...(opts?.cwd ? { cwd: opts.cwd } : {}) }, (error, stdout, stderr) => {
5
5
  const stderrText = stderr.toString("utf-8");
6
6
  if (error) {
7
7
  const message = stderrText.trim() || error.message;
@@ -6,6 +6,8 @@
6
6
  * Older iOS and self-managed setups can opt out with AXIMO_IOS_SKIP_TUNNEL.
7
7
  */
8
8
  export declare const DEFAULT_TUNNEL_REGISTRY_PORT = 42314;
9
+ /** How to stop the elevated tunnel daemon — surfaced whenever we start or leave it running. */
10
+ export declare const TUNNEL_STOP_COMMAND = "sudo pkill -f tunnel-creation";
9
11
  /**
10
12
  * True when the tunnel registry server answers its HTTP API on `port`. A raw
11
13
  * TCP connect is not enough — an unrelated listener on the port would be
@@ -20,8 +22,9 @@ export interface TunnelScriptOptions {
20
22
  }
21
23
  /**
22
24
  * A self-contained shell script that launches the driver's `tunnel-creation` as a
23
- * detached background daemon. Run as root via the macOS auth prompt; it returns
24
- * immediately while the tunnel keeps running (health-checked via the registry port).
25
+ * fully detached daemon (own session, via {@link DAEMONIZE_PERL}). Run as root via
26
+ * the macOS auth prompt; it returns immediately while the tunnel keeps running
27
+ * (health-checked via the registry port).
25
28
  */
26
29
  export declare function buildTunnelScript(opts: TunnelScriptOptions): string;
27
30
  /** Prompt for admin rights (password/Touch ID) and run the tunnel script as root. */
@@ -13,6 +13,8 @@ import { envFlag } from "./env-flags.js";
13
13
  * Older iOS and self-managed setups can opt out with AXIMO_IOS_SKIP_TUNNEL.
14
14
  */
15
15
  export const DEFAULT_TUNNEL_REGISTRY_PORT = 42314;
16
+ /** How to stop the elevated tunnel daemon — surfaced whenever we start or leave it running. */
17
+ export const TUNNEL_STOP_COMMAND = "sudo pkill -f tunnel-creation";
16
18
  /**
17
19
  * True when the tunnel registry server answers its HTTP API on `port`. A raw
18
20
  * TCP connect is not enough — an unrelated listener on the port would be
@@ -36,17 +38,34 @@ export async function isTunnelHealthy(port = DEFAULT_TUNNEL_REGISTRY_PORT, host
36
38
  function shQuote(value) {
37
39
  return `'${value.replace(/'/g, "'\\''")}'`;
38
40
  }
41
+ // Daemonize via perl (always present on macOS). A plain `nohup … &` does NOT
42
+ // survive `do shell script … with administrator privileges`: the security
43
+ // trampoline tears down its child's process group when the script returns, and
44
+ // nohup only blocks SIGHUP — so the tunnel is killed before it initializes.
45
+ // Double-fork + setsid moves the daemon into its own session and process group,
46
+ // out of the trampoline's reach. $ARGV[0] is the log file; $ARGV[1..] the argv.
47
+ const DAEMONIZE_PERL = [
48
+ "use POSIX qw(setsid);",
49
+ "open(STDIN, '<', '/dev/null');",
50
+ "open(STDOUT, '>', $ARGV[0]) or die $!;",
51
+ "open(STDERR, '>&', STDOUT);",
52
+ "exit if fork;", // top process (the trampoline's child) returns immediately
53
+ "setsid();", // child leads a new session — detached from the trampoline
54
+ "exit if fork;", // grandchild can never reacquire a controlling terminal
55
+ "exec(@ARGV[1 .. $#ARGV]) or die $!;",
56
+ ].join(" ");
39
57
  /**
40
58
  * A self-contained shell script that launches the driver's `tunnel-creation` as a
41
- * detached background daemon. Run as root via the macOS auth prompt; it returns
42
- * immediately while the tunnel keeps running (health-checked via the registry port).
59
+ * fully detached daemon (own session, via {@link DAEMONIZE_PERL}). Run as root via
60
+ * the macOS auth prompt; it returns immediately while the tunnel keeps running
61
+ * (health-checked via the registry port).
43
62
  */
44
63
  export function buildTunnelScript(opts) {
45
64
  return [
46
65
  "#!/bin/sh",
47
66
  `export APPIUM_HOME=${shQuote(opts.appiumHome)}`,
48
67
  'export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"',
49
- `nohup ${shQuote(opts.nodePath)} ${shQuote(opts.appiumBin)} driver run xcuitest tunnel-creation > ${shQuote(opts.logFile)} 2>&1 &`,
68
+ `exec perl -e ${shQuote(DAEMONIZE_PERL)} ${shQuote(opts.logFile)} ${shQuote(opts.nodePath)} ${shQuote(opts.appiumBin)} driver run xcuitest tunnel-creation`,
50
69
  "",
51
70
  ].join("\n");
52
71
  }
@@ -81,7 +100,7 @@ async function pollHealthy(isHealthy, port, timeoutMs, logFile) {
81
100
  }
82
101
  throw new Error(`iOS device tunnel did not become ready within ${timeoutMs}ms. ` +
83
102
  `Check ${logFile} for the daemon's output. A partially-started daemon may still ` +
84
- "be running; stop it before retrying: sudo pkill -f tunnel-creation");
103
+ `be running; stop it before retrying: ${TUNNEL_STOP_COMMAND}`);
85
104
  }
86
105
  /**
87
106
  * Ensure the RemoteXPC tunnel is running so XCUITest can reach WebDriverAgent.
@@ -125,6 +144,7 @@ export async function ensureIosTunnel(options = {}) {
125
144
  `${shQuote(appiumBin)} driver run xcuitest tunnel-creation`);
126
145
  }
127
146
  await pollHealthy(isHealthy, port, options.readyTimeoutMs ?? 45_000, logFile);
128
- log(`iOS device tunnel is up (registry on port ${port}).`);
147
+ log(`iOS device tunnel is up (registry on port ${port}). It keeps running in the background ` +
148
+ `so later connects don't prompt again; stop it anytime with: ${TUNNEL_STOP_COMMAND}`);
129
149
  return { reused: false, started: true, skipped: false, port };
130
150
  }
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
2
  import { buildTunnelScript, DEFAULT_TUNNEL_REGISTRY_PORT, ensureIosTunnel } from "./ios-tunnel.js";
3
3
  describe("buildTunnelScript", () => {
4
- it("backgrounds tunnel-creation as a detached daemon with APPIUM_HOME set", () => {
4
+ it("launches tunnel-creation as a setsid-detached daemon with APPIUM_HOME set", () => {
5
5
  const script = buildTunnelScript({
6
6
  appiumHome: "/home/u/.aximo/appium-home",
7
7
  nodePath: "/usr/bin/node",
@@ -9,8 +9,13 @@ describe("buildTunnelScript", () => {
9
9
  logFile: "/home/u/.aximo/ios-tunnel/tunnel.log",
10
10
  });
11
11
  expect(script).toContain("export APPIUM_HOME='/home/u/.aximo/appium-home'");
12
- expect(script).toContain("nohup '/usr/bin/node' '/cli/node_modules/appium/index.js' driver run xcuitest tunnel-creation");
13
- expect(script).toContain("> '/home/u/.aximo/ios-tunnel/tunnel.log' 2>&1 &");
12
+ // Detached via perl double-fork + setsid so the admin-elevation trampoline
13
+ // can't reap it (a plain `nohup … &` was killed on script exit).
14
+ expect(script).toContain("setsid()");
15
+ expect(script).toContain("'/usr/bin/node' '/cli/node_modules/appium/index.js' driver run xcuitest tunnel-creation");
16
+ // Log file is passed as the perl daemon's first argv (STDOUT/STDERR sink).
17
+ expect(script).toContain("'/home/u/.aximo/ios-tunnel/tunnel.log' '/usr/bin/node'");
18
+ expect(script).not.toContain("nohup");
14
19
  });
15
20
  it("escapes single quotes in paths", () => {
16
21
  const script = buildTunnelScript({
@@ -68,8 +68,11 @@ export async function preinstallWda(options) {
68
68
  }
69
69
  const scratch = mkdtempSync(join(tmpdir(), "aximo-wda-preinstall-"));
70
70
  try {
71
- // Wrap the bare .app into Payload/ so it can go through the same resign path
72
- // as any other IPA (ditto ships with macOS and handles both directions).
71
+ // Wrap the bare .app into Payload/ so it goes through the same resign path as
72
+ // any other IPA. Extract with ditto (it reads the Apple-produced zip cleanly)
73
+ // but REPACKAGE with `zip`: applesign's own extractor can't parse ditto's
74
+ // PKZip variant and rejects a ditto-built IPA as "Invalid IPA" (no .app found
75
+ // in Payload). `zip -qr Payload` from the wrap dir keeps Payload/ at the root.
73
76
  const payloadRoot = join(scratch, "wrap");
74
77
  const extractDir = join(payloadRoot, "Payload");
75
78
  mkdirSync(extractDir, { recursive: true });
@@ -78,7 +81,7 @@ export async function preinstallWda(options) {
78
81
  throw new Error(`Prebuilt WDA zip did not contain ${RUNNER_APP} — re-download or report.`);
79
82
  }
80
83
  const wrappedIpa = join(scratch, "wda.ipa");
81
- await exec("ditto", ["-c", "-k", payloadRoot, wrappedIpa]);
84
+ await exec("zip", ["-qr", wrappedIpa, "Payload"], { cwd: payloadRoot });
82
85
  const runnerBundleId = `${options.wdaBundleId}.xctrunner`;
83
86
  const resignedIpa = join(scratch, "wda-resigned.ipa");
84
87
  log(`Signing WebDriverAgent as ${runnerBundleId}…`);
@@ -33,15 +33,19 @@ describe("preinstallWda", () => {
33
33
  afterEach(() => {
34
34
  rmSync(cacheDir, { recursive: true, force: true });
35
35
  });
36
- /** ditto fake: -x -k extract creates the runner app; -c -k creates the archive. */
37
- const dittoExec = async (file, args) => {
36
+ /** exec fake: `ditto -x` extracts the runner app; `zip` writes the IPA archive. */
37
+ const execFake = async (file, args, opts) => {
38
38
  if (file === "ditto" && args[0] === "-x") {
39
39
  const dest = args[3];
40
40
  if (dest)
41
41
  mkdirSync(join(dest, "WebDriverAgentRunner-Runner.app"), { recursive: true });
42
42
  }
43
- if (file === "ditto" && args[0] === "-c") {
44
- const out = args[3];
43
+ if (file === "zip") {
44
+ // Like real zip: the relative Payload argument only resolves against opts.cwd.
45
+ if (!opts?.cwd || !existsSync(join(opts.cwd, "Payload"))) {
46
+ throw new Error("zip error: Nothing to do (Payload not found in cwd)");
47
+ }
48
+ const out = args[1];
45
49
  if (out)
46
50
  writeFileSync(out, "zip");
47
51
  }
@@ -53,7 +57,7 @@ describe("preinstallWda", () => {
53
57
  identityHash: "A".repeat(40),
54
58
  profilePath: "/profiles/wda.mobileprovision",
55
59
  wdaBundleId: "com.aximo.wda.testteam99",
56
- exec: dittoExec,
60
+ exec: execFake,
57
61
  cacheDir,
58
62
  wdaVersion: "15.0.0",
59
63
  fetchImpl: vi.fn(async (_input) => new Response(new ArrayBuffer(8), { status: 200 })),
@@ -76,7 +80,7 @@ describe("preinstallWda", () => {
76
80
  profilePath: "/profiles/wda.mobileprovision",
77
81
  bundleId: "com.aximo.wda.testteam99.xctrunner",
78
82
  }));
79
- expect(opts.install).toHaveBeenCalledWith("UDID-1", expect.stringContaining("wda-resigned"), dittoExec);
83
+ expect(opts.install).toHaveBeenCalledWith("UDID-1", expect.stringContaining("wda-resigned"), execFake);
80
84
  // Second run: cache hit, no new download.
81
85
  await preinstallWda(opts);
82
86
  expect(opts.fetchImpl).toHaveBeenCalledOnce();
@@ -93,7 +97,7 @@ describe("preinstallWda", () => {
93
97
  await expect(preinstallWda(opts)).rejects.toThrow(/aximo mobile setup/);
94
98
  });
95
99
  it("fails loudly when the zip has an unexpected layout", async () => {
96
- const badDitto = async (file, args) => {
100
+ const badExec = async (file, args) => {
97
101
  if (file === "ditto" && args[0] === "-x") {
98
102
  const dest = args[3];
99
103
  if (dest)
@@ -101,7 +105,7 @@ describe("preinstallWda", () => {
101
105
  }
102
106
  return { stdout: Buffer.from(""), stderr: "" };
103
107
  };
104
- const opts = { ...makeOptions(), exec: badDitto };
108
+ const opts = { ...makeOptions(), exec: badExec };
105
109
  await expect(preinstallWda(opts)).rejects.toThrow(/did not contain/);
106
110
  });
107
111
  });