@autifyhq/aximo-cli 0.8.0 → 0.10.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 (70) hide show
  1. package/README.md +19 -7
  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/device/connect.js +19 -1
  12. package/dist/commands/device/doctor.d.ts +2 -0
  13. package/dist/commands/device/doctor.js +37 -10
  14. package/dist/commands/device/doctor.test.js +26 -1
  15. package/dist/commands/health/check.js +15 -3
  16. package/dist/commands/health/check.test.js +27 -3
  17. package/dist/commands/mcp/server.test.js +3 -1
  18. package/dist/commands/mcp/tools.js +28 -0
  19. package/dist/commands/mcp/tools.test.js +90 -0
  20. package/dist/commands/mobile/connect-core.d.ts +5 -3
  21. package/dist/commands/mobile/connect-core.js +60 -12
  22. package/dist/commands/mobile/connect.d.ts +5 -0
  23. package/dist/commands/mobile/connect.js +6 -1
  24. package/dist/commands/mobile/signing-consent.d.ts +18 -0
  25. package/dist/commands/mobile/signing-consent.js +22 -0
  26. package/dist/commands/mobile/signing-consent.test.d.ts +1 -0
  27. package/dist/commands/mobile/signing-consent.test.js +50 -0
  28. package/dist/commands/plan/index.js +1 -0
  29. package/dist/commands/plan/plan.test.js +61 -0
  30. package/dist/commands/plan/update.d.ts +42 -0
  31. package/dist/commands/plan/update.js +78 -0
  32. package/dist/commands/schema.js +1 -1
  33. package/dist/commands/session/save-as-case.js +3 -3
  34. package/dist/commands/trigger/plan/run.d.ts +6 -1
  35. package/dist/commands/trigger/plan/run.js +9 -3
  36. package/dist/commands/trigger/trigger.test.js +22 -3
  37. package/dist/commands/upload/put.d.ts +5 -0
  38. package/dist/commands/upload/put.js +44 -11
  39. package/dist/commands/upload/upload.test.js +82 -0
  40. package/dist/desktop-server/preflight.d.ts +43 -0
  41. package/dist/desktop-server/preflight.js +78 -0
  42. package/dist/desktop-server/preflight.test.d.ts +1 -0
  43. package/dist/desktop-server/preflight.test.js +115 -0
  44. package/dist/mobile-control/appium-controller.d.ts +28 -0
  45. package/dist/mobile-control/appium-controller.js +271 -92
  46. package/dist/mobile-control/appium-controller.test.js +258 -8
  47. package/dist/mobile-control/bundle-id-map.js +3 -0
  48. package/dist/mobile-control/controller.d.ts +3 -0
  49. package/dist/mobile-control/frame-sender.d.ts +34 -0
  50. package/dist/mobile-control/frame-sender.js +96 -0
  51. package/dist/mobile-control/frame-sender.test.d.ts +1 -0
  52. package/dist/mobile-control/frame-sender.test.js +108 -0
  53. package/dist/mobile-control/mjpeg-stream.d.ts +43 -0
  54. package/dist/mobile-control/mjpeg-stream.js +160 -0
  55. package/dist/mobile-control/mjpeg-stream.test.d.ts +1 -0
  56. package/dist/mobile-control/mjpeg-stream.test.js +93 -0
  57. package/dist/mobile-control/tunnel.d.ts +10 -1
  58. package/dist/mobile-control/tunnel.js +43 -2
  59. package/dist/mobile-control/tunnel.test.js +108 -0
  60. package/dist/mobile-control/webdriver.d.ts +9 -1
  61. package/dist/mobile-control/webdriver.js +17 -5
  62. package/dist/mutate.d.ts +1 -1
  63. package/dist/mutate.js +4 -2
  64. package/dist/prompt.d.ts +11 -0
  65. package/dist/prompt.js +19 -0
  66. package/dist/types.d.ts +66 -4
  67. package/dist/validate.d.ts +6 -1
  68. package/dist/validate.js +46 -0
  69. package/dist/validate.test.js +39 -1
  70. package/package.json +1 -1
@@ -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 () => {
@@ -250,3 +398,105 @@ describe("AppiumController launch + lifecycle", () => {
250
398
  expect(calls.some((c) => c.method === "DELETE" && c.path === "/session/sess-1")).toBe(true);
251
399
  });
252
400
  });
401
+ describe("AppiumController MJPEG capture", () => {
402
+ it("adds the mjpegServerPort capability and applies conservative settings", async () => {
403
+ const { transport, calls } = fakeTransport();
404
+ const controller = new AppiumController({
405
+ endpoint: "http://x",
406
+ platform: "android",
407
+ target: "udid-1",
408
+ mjpegServerPort: async () => 7777,
409
+ transport,
410
+ });
411
+ await controller.ensureReady();
412
+ const body = calls.find((c) => c.method === "POST" && c.path === "/session")?.body;
413
+ expect(body["capabilities"]?.alwaysMatch["appium:mjpegServerPort"]).toBe(7777);
414
+ const settings = calls.find((c) => c.path.endsWith("/appium/settings"));
415
+ expect(settings?.body).toEqual({
416
+ settings: {
417
+ mjpegServerFramerate: 10,
418
+ mjpegScalingFactor: 50,
419
+ mjpegServerScreenshotQuality: 40,
420
+ },
421
+ });
422
+ expect(controller.getMjpegPort()).toBe(7777);
423
+ });
424
+ it("omits the capability and settings call when no port is configured", async () => {
425
+ const { controller, calls } = makeController("android");
426
+ await controller.screenshot();
427
+ const body = calls.find((c) => c.method === "POST" && c.path === "/session")?.body;
428
+ expect(body["capabilities"]?.alwaysMatch["appium:mjpegServerPort"]).toBeUndefined();
429
+ expect(calls.some((c) => c.path.endsWith("/appium/settings"))).toBe(false);
430
+ expect(controller.getMjpegPort()).toBeUndefined();
431
+ });
432
+ });
433
+ describe("AppiumController session recovery", () => {
434
+ function deadSessionTransport(failuresBeforeRecovery) {
435
+ const calls = [];
436
+ let sessions = 0;
437
+ let remainingFailures = failuresBeforeRecovery;
438
+ const transport = {
439
+ async request(method, path, body) {
440
+ calls.push({ method, path, body });
441
+ if (method === "POST" && path === "/session") {
442
+ sessions += 1;
443
+ return { sessionId: `sess-${sessions}` };
444
+ }
445
+ if (path.endsWith("/screenshot")) {
446
+ if (remainingFailures > 0) {
447
+ remainingFailures -= 1;
448
+ throw new Error("WebDriver GET /screenshot failed: invalid session id");
449
+ }
450
+ return pngWithWidth(400);
451
+ }
452
+ return {};
453
+ },
454
+ };
455
+ return { transport, calls };
456
+ }
457
+ it("rebuilds the session once and retries when the cached session has died", async () => {
458
+ const { transport, calls } = deadSessionTransport(1);
459
+ const controller = new AppiumController({
460
+ endpoint: "http://x",
461
+ platform: "android",
462
+ target: "udid-1",
463
+ transport,
464
+ });
465
+ const shot = await controller.screenshot();
466
+ expect(Buffer.isBuffer(shot)).toBe(true);
467
+ expect(calls.filter((c) => c.method === "POST" && c.path === "/session")).toHaveLength(2);
468
+ });
469
+ it("surfaces the error when the rebuilt session is dead too", async () => {
470
+ const { transport, calls } = deadSessionTransport(2);
471
+ const controller = new AppiumController({
472
+ endpoint: "http://x",
473
+ platform: "android",
474
+ target: "udid-1",
475
+ transport,
476
+ });
477
+ await expect(controller.screenshot()).rejects.toThrow(/invalid session id/);
478
+ expect(calls.filter((c) => c.method === "POST" && c.path === "/session")).toHaveLength(2);
479
+ });
480
+ it("does not rebuild the session for ordinary command failures", async () => {
481
+ const calls = [];
482
+ const transport = {
483
+ async request(method, path, body) {
484
+ calls.push({ method, path, body });
485
+ if (method === "POST" && path === "/session")
486
+ return { sessionId: "sess-1" };
487
+ if (path.endsWith("/screenshot")) {
488
+ throw new Error("WebDriver GET /screenshot failed: unexpected server error");
489
+ }
490
+ return {};
491
+ },
492
+ };
493
+ const controller = new AppiumController({
494
+ endpoint: "http://x",
495
+ platform: "android",
496
+ target: "udid-1",
497
+ transport,
498
+ });
499
+ await expect(controller.screenshot()).rejects.toThrow(/unexpected server error/);
500
+ expect(calls.filter((c) => c.method === "POST" && c.path === "/session")).toHaveLength(1);
501
+ });
502
+ });
@@ -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;
@@ -0,0 +1,34 @@
1
+ import { type Socket } from "socket.io-client";
2
+ export interface FrameSenderOptions {
3
+ wsUrl: string;
4
+ deviceToken: string;
5
+ /** Called when the gateway acks that no viewer is watching — capture should stop. */
6
+ onInactive: () => void;
7
+ log?: (message: string) => void;
8
+ /** Test seam: overrides the socket factory. */
9
+ connect?: (wsUrl: string, deviceToken: string) => Socket;
10
+ }
11
+ /**
12
+ * Pushes JPEG frames to the gateway over a DEDICATED socket.io connection
13
+ * (handshake `role: "frames"`) so frame bytes never share a TCP stream with
14
+ * command acks or engine.io pings — on a saturated uplink that head-of-line
15
+ * blocking delays tap acks by seconds and starves pings into a tunnel drop.
16
+ *
17
+ * Sending is ack-clocked with keep-latest replacement: one frame in flight,
18
+ * and whatever arrives while it is unacknowledged replaces the queued one.
19
+ * socket.io `volatile` cannot bound this — the websocket transport reports
20
+ * writable again on the next tick without consulting the real send buffer, so
21
+ * volatile frames still queue unboundedly on a slow link.
22
+ */
23
+ export declare class FrameSender {
24
+ private readonly options;
25
+ private socket;
26
+ private pending;
27
+ private pumping;
28
+ private oversizedWarned;
29
+ constructor(options: FrameSenderOptions);
30
+ start(): void;
31
+ stop(): void;
32
+ push(frame: Buffer): void;
33
+ private pump;
34
+ }
@@ -0,0 +1,96 @@
1
+ import { io } from "socket.io-client";
2
+ const SOCKET_PATH = "/devices/tunnel";
3
+ const ACK_TIMEOUT_MS = 10_000;
4
+ // Frames at the pinned capture settings are ~30-80KB. Anything this large
5
+ // means misapplied settings; sending it would monopolize a slow uplink.
6
+ const MAX_FRAME_BYTES = 1024 * 1024;
7
+ /**
8
+ * Pushes JPEG frames to the gateway over a DEDICATED socket.io connection
9
+ * (handshake `role: "frames"`) so frame bytes never share a TCP stream with
10
+ * command acks or engine.io pings — on a saturated uplink that head-of-line
11
+ * blocking delays tap acks by seconds and starves pings into a tunnel drop.
12
+ *
13
+ * Sending is ack-clocked with keep-latest replacement: one frame in flight,
14
+ * and whatever arrives while it is unacknowledged replaces the queued one.
15
+ * socket.io `volatile` cannot bound this — the websocket transport reports
16
+ * writable again on the next tick without consulting the real send buffer, so
17
+ * volatile frames still queue unboundedly on a slow link.
18
+ */
19
+ export class FrameSender {
20
+ options;
21
+ socket = null;
22
+ pending = null;
23
+ pumping = false;
24
+ oversizedWarned = false;
25
+ constructor(options) {
26
+ this.options = options;
27
+ }
28
+ start() {
29
+ if (this.socket)
30
+ return;
31
+ const connect = this.options.connect ?? defaultConnect;
32
+ this.socket = connect(this.options.wsUrl, this.options.deviceToken);
33
+ this.socket.on("connect_error", (err) => {
34
+ this.options.log?.(`Frame connection error: ${err.message}`);
35
+ });
36
+ // A reconnect may have frames waiting from before the drop.
37
+ this.socket.on("connect", () => void this.pump());
38
+ }
39
+ stop() {
40
+ this.pending = null;
41
+ this.socket?.close();
42
+ this.socket = null;
43
+ }
44
+ push(frame) {
45
+ if (frame.length > MAX_FRAME_BYTES) {
46
+ if (!this.oversizedWarned) {
47
+ this.oversizedWarned = true;
48
+ this.options.log?.(`Dropping oversized MJPEG frame (${frame.length} bytes) — capture settings were likely not applied.`);
49
+ }
50
+ return;
51
+ }
52
+ this.pending = frame;
53
+ void this.pump();
54
+ }
55
+ async pump() {
56
+ if (this.pumping)
57
+ return;
58
+ this.pumping = true;
59
+ try {
60
+ while (this.pending) {
61
+ const socket = this.socket;
62
+ // Disconnected: leave the newest frame queued; the connect handler
63
+ // resumes the pump once the transport is back.
64
+ if (!socket?.connected)
65
+ return;
66
+ const frame = this.pending;
67
+ this.pending = null;
68
+ try {
69
+ const ack = (await socket.timeout(ACK_TIMEOUT_MS).emitWithAck("stream_frame", frame));
70
+ if (ack?.active === false) {
71
+ this.pending = null;
72
+ this.options.onInactive();
73
+ return;
74
+ }
75
+ }
76
+ catch {
77
+ // Ack timeout: the link is stalling. The frame is already stale —
78
+ // drop it and let the next capture retry.
79
+ return;
80
+ }
81
+ }
82
+ }
83
+ finally {
84
+ this.pumping = false;
85
+ }
86
+ }
87
+ }
88
+ function defaultConnect(wsUrl, deviceToken) {
89
+ return io(wsUrl, {
90
+ path: SOCKET_PATH,
91
+ transports: ["websocket"],
92
+ auth: { deviceToken, role: "frames" },
93
+ reconnectionAttempts: Infinity,
94
+ timeout: 10_000,
95
+ });
96
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,108 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { FrameSender } from "./frame-sender.js";
3
+ class FakeFramesSocket {
4
+ connected = true;
5
+ sent = [];
6
+ acks = [];
7
+ handlers = new Map();
8
+ on(event, handler) {
9
+ this.handlers.set(event, handler);
10
+ return this;
11
+ }
12
+ fire(event, ...args) {
13
+ this.handlers.get(event)?.(...args);
14
+ }
15
+ timeout(_ms) {
16
+ return {
17
+ emitWithAck: (_event, frame) => new Promise((resolve) => {
18
+ this.sent.push(frame);
19
+ this.acks.push(resolve);
20
+ }),
21
+ };
22
+ }
23
+ close() {
24
+ this.connected = false;
25
+ }
26
+ }
27
+ function tick() {
28
+ return new Promise((resolve) => setTimeout(resolve, 0));
29
+ }
30
+ function build(socket, onInactive = vi.fn()) {
31
+ const sender = new FrameSender({
32
+ wsUrl: "ws://gateway",
33
+ deviceToken: "token",
34
+ onInactive,
35
+ connect: () => socket,
36
+ });
37
+ sender.start();
38
+ return { sender, onInactive };
39
+ }
40
+ describe("FrameSender", () => {
41
+ it("ack-clocks with keep-latest: frames pushed mid-flight collapse to the newest", async () => {
42
+ const socket = new FakeFramesSocket();
43
+ const { sender } = build(socket);
44
+ sender.push(Buffer.from("f1"));
45
+ await tick();
46
+ sender.push(Buffer.from("f2"));
47
+ sender.push(Buffer.from("f3"));
48
+ await tick();
49
+ // f1 unacked: nothing else may be in flight.
50
+ expect(socket.sent.map(String)).toEqual(["f1"]);
51
+ socket.acks[0]?.({ active: true });
52
+ await tick();
53
+ // f2 was replaced by f3 while waiting.
54
+ expect(socket.sent.map(String)).toEqual(["f1", "f3"]);
55
+ socket.acks[1]?.({ active: true });
56
+ await tick();
57
+ expect(socket.sent).toHaveLength(2);
58
+ });
59
+ it("stops capture when the gateway acks that nobody is watching", async () => {
60
+ const socket = new FakeFramesSocket();
61
+ const { sender, onInactive } = build(socket);
62
+ sender.push(Buffer.from("frame"));
63
+ await tick();
64
+ socket.acks[0]?.({ active: false });
65
+ await tick();
66
+ expect(onInactive).toHaveBeenCalledTimes(1);
67
+ });
68
+ it("holds the newest frame while disconnected and resumes on connect", async () => {
69
+ const socket = new FakeFramesSocket();
70
+ socket.connected = false;
71
+ const { sender } = build(socket);
72
+ sender.push(Buffer.from("f1"));
73
+ sender.push(Buffer.from("f2"));
74
+ await tick();
75
+ expect(socket.sent).toHaveLength(0);
76
+ socket.connected = true;
77
+ socket.fire("connect");
78
+ await tick();
79
+ expect(socket.sent.map(String)).toEqual(["f2"]);
80
+ });
81
+ it("drops oversized frames instead of sending them", async () => {
82
+ const socket = new FakeFramesSocket();
83
+ const log = vi.fn();
84
+ const sender = new FrameSender({
85
+ wsUrl: "ws://gateway",
86
+ deviceToken: "token",
87
+ onInactive: vi.fn(),
88
+ log,
89
+ connect: () => socket,
90
+ });
91
+ sender.start();
92
+ sender.push(Buffer.alloc(2 * 1024 * 1024));
93
+ await tick();
94
+ expect(socket.sent).toHaveLength(0);
95
+ expect(log).toHaveBeenCalledOnce();
96
+ });
97
+ it("stop() clears the queue and closes the socket", async () => {
98
+ const socket = new FakeFramesSocket();
99
+ const { sender } = build(socket);
100
+ sender.push(Buffer.from("f1"));
101
+ await tick();
102
+ sender.stop();
103
+ expect(socket.connected).toBe(false);
104
+ socket.acks[0]?.({ active: true });
105
+ await tick();
106
+ expect(socket.sent).toHaveLength(1);
107
+ });
108
+ });
@@ -0,0 +1,43 @@
1
+ import { type Socket as NetSocket } from "node:net";
2
+ /**
3
+ * Incremental JPEG frame extractor for an MJPEG byte stream. Scans raw bytes
4
+ * for SOI/EOI markers instead of parsing multipart headers — the WDA and
5
+ * UiAutomator2 servers frame their streams differently, and marker scanning
6
+ * handles both and resyncs after corruption (HTTP headers and part boundaries
7
+ * fall out naturally because scanning starts at the first SOI).
8
+ */
9
+ export declare class MjpegFrameParser {
10
+ private buffer;
11
+ push(chunk: Buffer): Buffer[];
12
+ }
13
+ export interface MjpegStreamClientOptions {
14
+ /** Bring the device session (and its forwarded MJPEG port) up; called before each connect. */
15
+ ensureReady: () => Promise<void>;
16
+ /** Local port the driver forwarded to the on-device MJPEG server. */
17
+ getPort: () => number | undefined;
18
+ onFrame: (jpeg: Buffer) => void;
19
+ log?: (message: string) => void;
20
+ /** Test seam: overrides the TCP connection factory. */
21
+ createConnection?: (port: number) => NetSocket;
22
+ }
23
+ /**
24
+ * Connects to the driver-forwarded MJPEG port and emits complete JPEG frames.
25
+ * Reconnects with capped backoff while started — an Appium session rebuild
26
+ * lands on a fresh port, which `getPort` reflects on the next attempt. All
27
+ * timers are cancellable so `stop()` never holds the process open.
28
+ */
29
+ export declare class MjpegStreamClient {
30
+ private readonly options;
31
+ private active;
32
+ private socket;
33
+ private attempt;
34
+ private loop;
35
+ private wake;
36
+ constructor(options: MjpegStreamClientOptions);
37
+ isActive(): boolean;
38
+ start(): void;
39
+ stop(): Promise<void>;
40
+ private run;
41
+ private streamOnce;
42
+ private sleep;
43
+ }