@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.
- package/README.md +28 -13
- package/dist/client.d.ts +7 -1
- package/dist/client.js +21 -2
- package/dist/client.test.js +36 -0
- package/dist/commands/case/case.test.js +62 -0
- package/dist/commands/case/create.d.ts +1 -1
- package/dist/commands/case/create.js +4 -4
- package/dist/commands/case/index.js +1 -0
- package/dist/commands/case/update.d.ts +50 -0
- package/dist/commands/case/update.js +78 -0
- package/dist/commands/health/check.js +15 -3
- package/dist/commands/health/check.test.js +27 -3
- package/dist/commands/mobile/connect-core.d.ts +3 -1
- package/dist/commands/mobile/connect-core.js +34 -14
- package/dist/commands/mobile/connect.d.ts +5 -0
- package/dist/commands/mobile/connect.js +10 -4
- package/dist/commands/mobile/connect.test.js +7 -1
- package/dist/commands/mobile/doctor.d.ts +1 -0
- package/dist/commands/mobile/doctor.js +18 -2
- package/dist/commands/mobile/doctor.test.js +15 -1
- package/dist/commands/mobile/install.d.ts +1 -0
- package/dist/commands/mobile/install.js +14 -3
- package/dist/commands/mobile/list.d.ts +1 -0
- package/dist/commands/mobile/list.js +23 -3
- package/dist/commands/mobile/list.test.d.ts +1 -0
- package/dist/commands/mobile/list.test.js +24 -0
- package/dist/commands/mobile/output.d.ts +13 -0
- package/dist/commands/mobile/output.js +21 -0
- package/dist/commands/mobile/output.test.d.ts +1 -0
- package/dist/commands/mobile/output.test.js +54 -0
- package/dist/commands/mobile/register.d.ts +1 -0
- package/dist/commands/mobile/register.js +11 -3
- package/dist/commands/mobile/setup.d.ts +1 -0
- package/dist/commands/mobile/setup.js +7 -2
- package/dist/commands/mobile/signing-consent.d.ts +18 -0
- package/dist/commands/mobile/signing-consent.js +28 -0
- package/dist/commands/mobile/signing-consent.test.d.ts +1 -0
- package/dist/commands/mobile/signing-consent.test.js +50 -0
- package/dist/commands/mobile/unregister.d.ts +7 -3
- package/dist/commands/mobile/unregister.js +10 -4
- package/dist/commands/plan/index.js +1 -0
- package/dist/commands/plan/plan.test.js +61 -0
- package/dist/commands/plan/update.d.ts +42 -0
- package/dist/commands/plan/update.js +78 -0
- package/dist/commands/schema.js +1 -1
- package/dist/commands/session/save-as-case.js +3 -3
- package/dist/commands/trigger/plan/run.d.ts +6 -1
- package/dist/commands/trigger/plan/run.js +9 -3
- package/dist/commands/trigger/trigger.test.js +22 -3
- package/dist/commands/upload/put.d.ts +5 -0
- package/dist/commands/upload/put.js +44 -11
- package/dist/commands/upload/upload.test.js +82 -0
- package/dist/mobile-control/appium-controller.d.ts +4 -0
- package/dist/mobile-control/appium-controller.js +90 -2
- package/dist/mobile-control/appium-controller.test.js +156 -8
- package/dist/mobile-control/bundle-id-map.js +3 -0
- package/dist/mobile-control/controller.d.ts +3 -0
- package/dist/mobile-control/exec.d.ts +5 -1
- package/dist/mobile-control/exec.js +2 -2
- package/dist/mobile-control/ios-tunnel.d.ts +5 -2
- package/dist/mobile-control/ios-tunnel.js +25 -5
- package/dist/mobile-control/ios-tunnel.test.js +8 -3
- package/dist/mobile-control/ios-wda-preinstall.js +6 -3
- package/dist/mobile-control/ios-wda-preinstall.test.js +12 -8
- package/dist/mobile-control/tunnel.js +57 -1
- package/dist/mobile-control/tunnel.test.js +157 -0
- package/dist/mobile-control/webdriver.d.ts +7 -1
- package/dist/mobile-control/webdriver.js +12 -6
- package/dist/mutate.d.ts +1 -1
- package/dist/mutate.js +4 -2
- package/dist/types.d.ts +49 -4
- package/dist/validate.d.ts +6 -1
- package/dist/validate.js +46 -0
- package/dist/validate.test.js +39 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
158
|
-
const { controller, calls } = makeController("
|
|
159
|
-
await controller.typeText("
|
|
160
|
-
expect(
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
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
|
|
24
|
-
* immediately while the tunnel keeps running
|
|
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
|
|
42
|
-
* immediately while the tunnel keeps running
|
|
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
|
-
`
|
|
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
|
-
|
|
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("
|
|
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
|
-
|
|
13
|
-
|
|
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
|
|
72
|
-
//
|
|
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("
|
|
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
|
-
/**
|
|
37
|
-
const
|
|
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 === "
|
|
44
|
-
|
|
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:
|
|
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"),
|
|
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
|
|
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:
|
|
108
|
+
const opts = { ...makeOptions(), exec: badExec };
|
|
105
109
|
await expect(preinstallWda(opts)).rejects.toThrow(/did not contain/);
|
|
106
110
|
});
|
|
107
111
|
});
|