@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.
- package/README.md +18 -6
- 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 +25 -10
- package/dist/commands/mobile/connect.d.ts +5 -0
- package/dist/commands/mobile/connect.js +6 -1
- package/dist/commands/mobile/signing-consent.d.ts +18 -0
- package/dist/commands/mobile/signing-consent.js +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/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/tunnel.js +23 -0
- package/dist/mobile-control/tunnel.test.js +75 -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
|
@@ -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;
|
|
@@ -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,
|
|
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,
|
|
5
|
-
const url =
|
|
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;
|