@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
@@ -0,0 +1,160 @@
1
+ import { connect } from "node:net";
2
+ const SOI = Buffer.from([0xff, 0xd8]);
3
+ const EOI = Buffer.from([0xff, 0xd9]);
4
+ // A frame at the pinned capture settings is ~30-80KB; anything near this cap
5
+ // means the parser lost sync or the driver ignored the settings. Dropping the
6
+ // buffer resyncs on the next SOI marker.
7
+ const MAX_PARSER_BUFFER = 8 * 1024 * 1024;
8
+ // No bytes at all for this long means the server is wedged (locked phone with a
9
+ // dead WDA, stale forward) — drop the connection and let the retry loop rebuild.
10
+ const STREAM_IDLE_TIMEOUT_MS = 15_000;
11
+ const MAX_RECONNECT_DELAY_MS = 10_000;
12
+ /**
13
+ * Incremental JPEG frame extractor for an MJPEG byte stream. Scans raw bytes
14
+ * for SOI/EOI markers instead of parsing multipart headers — the WDA and
15
+ * UiAutomator2 servers frame their streams differently, and marker scanning
16
+ * handles both and resyncs after corruption (HTTP headers and part boundaries
17
+ * fall out naturally because scanning starts at the first SOI).
18
+ */
19
+ export class MjpegFrameParser {
20
+ buffer = Buffer.alloc(0);
21
+ push(chunk) {
22
+ this.buffer = this.buffer.length === 0 ? chunk : Buffer.concat([this.buffer, chunk]);
23
+ const frames = [];
24
+ for (;;) {
25
+ const soi = this.buffer.indexOf(SOI);
26
+ if (soi === -1) {
27
+ // Nothing before a frame start is useful; keep one byte in case a
28
+ // marker straddles the chunk edge.
29
+ this.buffer = this.buffer.subarray(Math.max(0, this.buffer.length - 1));
30
+ break;
31
+ }
32
+ if (soi > 0)
33
+ this.buffer = this.buffer.subarray(soi);
34
+ const eoi = this.buffer.indexOf(EOI, SOI.length);
35
+ if (eoi === -1)
36
+ break;
37
+ frames.push(this.buffer.subarray(0, eoi + EOI.length));
38
+ this.buffer = this.buffer.subarray(eoi + EOI.length);
39
+ }
40
+ if (this.buffer.length > MAX_PARSER_BUFFER)
41
+ this.buffer = Buffer.alloc(0);
42
+ return frames;
43
+ }
44
+ }
45
+ /**
46
+ * Connects to the driver-forwarded MJPEG port and emits complete JPEG frames.
47
+ * Reconnects with capped backoff while started — an Appium session rebuild
48
+ * lands on a fresh port, which `getPort` reflects on the next attempt. All
49
+ * timers are cancellable so `stop()` never holds the process open.
50
+ */
51
+ export class MjpegStreamClient {
52
+ options;
53
+ active = false;
54
+ socket = null;
55
+ attempt = 0;
56
+ loop = null;
57
+ wake = null;
58
+ constructor(options) {
59
+ this.options = options;
60
+ }
61
+ isActive() {
62
+ return this.active;
63
+ }
64
+ start() {
65
+ if (this.active)
66
+ return;
67
+ this.active = true;
68
+ this.attempt = 0;
69
+ this.loop = this.run().catch((err) => {
70
+ this.options.log?.(`MJPEG capture loop crashed: ${err instanceof Error ? err.message : String(err)}`);
71
+ });
72
+ }
73
+ async stop() {
74
+ if (!this.active)
75
+ return;
76
+ this.active = false;
77
+ this.socket?.destroy();
78
+ this.socket = null;
79
+ this.wake?.();
80
+ await this.loop;
81
+ this.loop = null;
82
+ }
83
+ async run() {
84
+ while (this.active) {
85
+ try {
86
+ await this.options.ensureReady();
87
+ if (!this.active)
88
+ break;
89
+ const port = this.options.getPort();
90
+ if (!port)
91
+ throw new Error("MJPEG server port is not configured");
92
+ await this.streamOnce(port);
93
+ }
94
+ catch (err) {
95
+ if (this.active) {
96
+ this.options.log?.(`MJPEG stream interrupted: ${err instanceof Error ? err.message : String(err)}`);
97
+ }
98
+ }
99
+ if (!this.active)
100
+ break;
101
+ const delay = Math.min(1000 * 2 ** this.attempt, MAX_RECONNECT_DELAY_MS);
102
+ this.attempt += 1;
103
+ await this.sleep(delay);
104
+ }
105
+ }
106
+ streamOnce(port) {
107
+ return new Promise((resolve, reject) => {
108
+ const parser = new MjpegFrameParser();
109
+ const socket = this.options.createConnection?.(port) ?? connect({ host: "127.0.0.1", port });
110
+ this.socket = socket;
111
+ let failed = null;
112
+ socket.setNoDelay(true);
113
+ socket.setTimeout(STREAM_IDLE_TIMEOUT_MS, () => {
114
+ failed = new Error(`no stream data for ${STREAM_IDLE_TIMEOUT_MS}ms`);
115
+ socket.destroy();
116
+ });
117
+ // WDA streams to any TCP client; the UiAutomator2 server wants a request
118
+ // line first. Sending one is harmless for both.
119
+ socket.on("connect", () => {
120
+ socket.write("GET / HTTP/1.0\r\nConnection: keep-alive\r\n\r\n");
121
+ });
122
+ socket.on("data", (chunk) => {
123
+ const frames = parser.push(chunk);
124
+ if (frames.length === 0)
125
+ return;
126
+ // A delivered frame proves the pipeline works — restart backoff.
127
+ this.attempt = 0;
128
+ // Keep-latest: only the newest frame matters for a live view.
129
+ const latest = frames[frames.length - 1];
130
+ if (latest)
131
+ this.options.onFrame(latest);
132
+ });
133
+ socket.on("error", (err) => {
134
+ failed = failed ?? err;
135
+ });
136
+ socket.on("close", () => {
137
+ if (this.socket === socket)
138
+ this.socket = null;
139
+ if (failed)
140
+ reject(failed);
141
+ else
142
+ resolve();
143
+ });
144
+ });
145
+ }
146
+ sleep(ms) {
147
+ return new Promise((resolve) => {
148
+ const timer = setTimeout(() => {
149
+ this.wake = null;
150
+ resolve();
151
+ }, ms);
152
+ timer.unref?.();
153
+ this.wake = () => {
154
+ clearTimeout(timer);
155
+ this.wake = null;
156
+ resolve();
157
+ };
158
+ });
159
+ }
160
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,93 @@
1
+ import { createServer } from "node:net";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import { MjpegFrameParser, MjpegStreamClient } from "./mjpeg-stream.js";
4
+ function jpeg(payload) {
5
+ return Buffer.from([0xff, 0xd8, ...payload, 0xff, 0xd9]);
6
+ }
7
+ describe("MjpegFrameParser", () => {
8
+ it("extracts a complete frame from one chunk", () => {
9
+ const parser = new MjpegFrameParser();
10
+ const frame = jpeg([1, 2, 3]);
11
+ expect(parser.push(frame)).toEqual([frame]);
12
+ });
13
+ it("reassembles a frame split across chunks", () => {
14
+ const parser = new MjpegFrameParser();
15
+ const frame = jpeg([9, 8, 7, 6]);
16
+ expect(parser.push(frame.subarray(0, 3))).toEqual([]);
17
+ expect(parser.push(frame.subarray(3))).toEqual([frame]);
18
+ });
19
+ it("skips multipart headers and boundary bytes between frames", () => {
20
+ const parser = new MjpegFrameParser();
21
+ const a = jpeg([1]);
22
+ const b = jpeg([2]);
23
+ const stream = Buffer.concat([
24
+ Buffer.from("HTTP/1.1 200 OK\r\nContent-Type: multipart/x-mixed-replace\r\n\r\n--BoundaryString\r\n"),
25
+ a,
26
+ Buffer.from("\r\n--BoundaryString\r\nContent-Length: 4\r\n\r\n"),
27
+ b,
28
+ ]);
29
+ expect(parser.push(stream)).toEqual([a, b]);
30
+ });
31
+ it("keeps a trailing partial marker across pushes", () => {
32
+ const parser = new MjpegFrameParser();
33
+ const frame = jpeg([5]);
34
+ // First push ends exactly on the 0xff of the next frame's SOI.
35
+ expect(parser.push(Buffer.concat([frame, Buffer.from([0xff])]))).toEqual([frame]);
36
+ const next = jpeg([6]);
37
+ expect(parser.push(next.subarray(1))).toEqual([next]);
38
+ });
39
+ });
40
+ describe("MjpegStreamClient", () => {
41
+ let server = null;
42
+ afterEach(() => {
43
+ server?.close();
44
+ server = null;
45
+ });
46
+ function listen(handler) {
47
+ return new Promise((resolve) => {
48
+ const created = createServer((socket) => {
49
+ socket.on("error", () => { });
50
+ handler((data) => socket.write(data));
51
+ });
52
+ server = created;
53
+ created.listen(0, "127.0.0.1", () => {
54
+ resolve(created.address().port);
55
+ });
56
+ });
57
+ }
58
+ it("delivers frames from the device stream", async () => {
59
+ const frame = jpeg([1, 2, 3, 4]);
60
+ const port = await listen((write) => {
61
+ write(Buffer.from("HTTP/1.1 200 OK\r\n\r\n"));
62
+ write(frame);
63
+ });
64
+ const frames = [];
65
+ const client = new MjpegStreamClient({
66
+ ensureReady: async () => { },
67
+ getPort: () => port,
68
+ onFrame: (f) => frames.push(f),
69
+ });
70
+ client.start();
71
+ await vi.waitFor(() => expect(frames.length).toBeGreaterThanOrEqual(1));
72
+ expect(frames[0]?.equals(frame)).toBe(true);
73
+ await client.stop();
74
+ });
75
+ it("stop() resolves promptly and stops reconnect attempts", async () => {
76
+ let readyCalls = 0;
77
+ const client = new MjpegStreamClient({
78
+ ensureReady: async () => {
79
+ readyCalls += 1;
80
+ throw new Error("session down");
81
+ },
82
+ getPort: () => undefined,
83
+ onFrame: () => { },
84
+ });
85
+ client.start();
86
+ await vi.waitFor(() => expect(readyCalls).toBeGreaterThanOrEqual(1));
87
+ await client.stop();
88
+ const after = readyCalls;
89
+ await new Promise((resolve) => setTimeout(resolve, 50));
90
+ expect(readyCalls).toBe(after);
91
+ expect(client.isActive()).toBe(false);
92
+ });
93
+ });
@@ -9,7 +9,16 @@ export interface RunControlTunnelOptions {
9
9
  installApp?: (args: {
10
10
  uploadId: string;
11
11
  }) => Promise<void>;
12
+ /**
13
+ * Live-view MJPEG pipeline hooks. The gateway demand-gates capture:
14
+ * `stream_start` when a viewer is watching, `stream_stop` when idle. Capture
15
+ * must also stop on release/disconnect so an unwatched phone never streams.
16
+ */
17
+ stream?: {
18
+ start: () => void;
19
+ stop: () => void;
20
+ };
12
21
  connect?: (wsUrl: string, deviceToken: string) => Socket;
13
22
  }
14
- export declare function registerHandlers(socket: Socket, controller: MobileController, installApp?: RunControlTunnelOptions["installApp"]): void;
23
+ export declare function registerHandlers(socket: Socket, controller: MobileController, installApp?: RunControlTunnelOptions["installApp"], stream?: RunControlTunnelOptions["stream"]): void;
15
24
  export declare function runControlTunnel(options: RunControlTunnelOptions): Promise<void>;
@@ -52,7 +52,7 @@ function defaultConnect(wsUrl, deviceToken) {
52
52
  // Mirrors the gateway's lock lease: a session quiet for longer than this has lost
53
53
  // its server-side lock, so a new session id is a legitimate takeover, not a stray.
54
54
  const SESSION_LEASE_MS = 25 * 60 * 1000;
55
- export function registerHandlers(socket, controller, installApp) {
55
+ export function registerHandlers(socket, controller, installApp, stream) {
56
56
  // Device-side guard against interleaved sessions: the gateway announces owner
57
57
  // changes (session_acquired/session_released) and tags each command with its
58
58
  // session id, so commands from a session that no longer owns the device are
@@ -70,8 +70,22 @@ export function registerHandlers(socket, controller, installApp) {
70
70
  const sessionId = args?.sessionId;
71
71
  if (activeSession && activeSession.id === sessionId) {
72
72
  activeSession = null;
73
+ // No owning session -> nobody may watch the phone.
74
+ stream?.stop();
73
75
  }
74
76
  });
77
+ // Demand gating: the gateway asks for frames only while a session owns the
78
+ // device AND at least one viewer is reading them; it signals idle explicitly
79
+ // (and the frame acks carry the same signal as a backstop). The device-side
80
+ // owner check mirrors the command guard: a stray start from a gateway
81
+ // instance with stale state must not stream an idle phone. A start dropped
82
+ // here self-heals — the gateway re-signals while viewers keep reading, and
83
+ // the owner becomes known on session_acquired or the next tagged command.
84
+ socket.on("stream_start", () => {
85
+ if (activeSession)
86
+ stream?.start();
87
+ });
88
+ socket.on("stream_stop", () => stream?.stop());
75
89
  const on = (event, handler) => {
76
90
  socket.on(event, (rawArgs, ack) => {
77
91
  // Destructure the routing-only tag off a clone so the caller's payload
@@ -130,6 +144,29 @@ export function registerHandlers(socket, controller, installApp) {
130
144
  await controller.swipe(r.coords[0], r.coords[1], r.coords[2], r.coords[3], num(args["duration"], 300));
131
145
  return ok();
132
146
  });
147
+ on("pinch_zoom", async (args) => {
148
+ const r = requireCoords(args, ["x", "y"]);
149
+ if ("error" in r)
150
+ return { ok: false, error: `pinch_zoom ${r.error}` };
151
+ const scale = reqNum(args, "scale");
152
+ if (scale === null)
153
+ return { ok: false, error: "pinch_zoom requires a numeric scale" };
154
+ await controller.pinchZoom(r.coords[0], r.coords[1], scale, num(args["duration"], 600));
155
+ return ok();
156
+ });
157
+ on("rotate_device", async (args) => {
158
+ const orientation = String(args["orientation"] ?? "").toLowerCase();
159
+ if (orientation !== "portrait" && orientation !== "landscape") {
160
+ return { ok: false, error: 'rotate_device requires orientation "portrait" or "landscape"' };
161
+ }
162
+ return asActionResult(await controller.rotateDevice(orientation));
163
+ });
164
+ on("set_location", async (args) => {
165
+ const r = requireCoords(args, ["latitude", "longitude"]);
166
+ if ("error" in r)
167
+ return { ok: false, error: `set_location ${r.error}` };
168
+ return asActionResult(await controller.setLocation(r.coords[0], r.coords[1], num(args["altitude"], 0)));
169
+ });
133
170
  on("input", async (args) => asActionResult(await controller.typeText(String(args["text"] ?? ""))));
134
171
  on("key", async (args) => asActionResult(await controller.pressKey(String(args["key"] ?? ""))));
135
172
  on("launch_app", async (args) => asActionResult(await controller.launchApp(String(args["app"] ?? ""))));
@@ -149,7 +186,7 @@ export function registerHandlers(socket, controller, installApp) {
149
186
  export async function runControlTunnel(options) {
150
187
  const connect = options.connect ?? defaultConnect;
151
188
  const socket = connect(options.wsUrl, options.deviceToken);
152
- registerHandlers(socket, options.controller, options.installApp);
189
+ registerHandlers(socket, options.controller, options.installApp, options.stream);
153
190
  await new Promise((resolve, reject) => {
154
191
  let shuttingDown = false;
155
192
  let serverKicks = [];
@@ -157,6 +194,7 @@ export async function runControlTunnel(options) {
157
194
  if (shuttingDown)
158
195
  return;
159
196
  shuttingDown = true;
197
+ options.stream?.stop();
160
198
  socket.close();
161
199
  resolve();
162
200
  };
@@ -188,6 +226,9 @@ export async function runControlTunnel(options) {
188
226
  console.error(`Connection error: ${err.message}`);
189
227
  });
190
228
  socket.on("disconnect", (reason) => {
229
+ // Whatever the reason, a disconnected tunnel means no viewer can be
230
+ // watching — stop capture; a reconnect re-arms via stream_start.
231
+ options.stream?.stop();
191
232
  // A SIGINT/SIGTERM teardown closes the socket itself; cleanup() already
192
233
  // resolved the promise, so don't report our own close as an error.
193
234
  if (shuttingDown)
@@ -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 }));
@@ -235,3 +310,36 @@ describe("registerHandlers session guard", () => {
235
310
  expect(tap).toHaveBeenCalledTimes(2);
236
311
  });
237
312
  });
313
+ describe("stream lifecycle handlers", () => {
314
+ function wireWithStream() {
315
+ const socket = new FakeSocket();
316
+ const stream = { start: vi.fn(), stop: vi.fn() };
317
+ registerHandlers(socket, stubController(), undefined, stream);
318
+ return { socket, stream };
319
+ }
320
+ it("starts and stops capture on gateway demand signals while a session owns the device", () => {
321
+ const { socket, stream } = wireWithStream();
322
+ socket.handlers.get("session_acquired")?.({ sessionId: "s1" }, undefined);
323
+ socket.handlers.get("stream_start")?.({}, undefined);
324
+ expect(stream.start).toHaveBeenCalledTimes(1);
325
+ socket.handlers.get("stream_stop")?.({}, undefined);
326
+ expect(stream.stop).toHaveBeenCalledTimes(1);
327
+ });
328
+ it("drops stream_start when no session owns the device", () => {
329
+ const { socket, stream } = wireWithStream();
330
+ socket.handlers.get("stream_start")?.({}, undefined);
331
+ expect(stream.start).not.toHaveBeenCalled();
332
+ });
333
+ it("stops capture when the owning session releases the device", () => {
334
+ const { socket, stream } = wireWithStream();
335
+ socket.handlers.get("session_acquired")?.({ sessionId: "s1" }, undefined);
336
+ socket.handlers.get("session_released")?.({ sessionId: "s1" }, undefined);
337
+ expect(stream.stop).toHaveBeenCalledTimes(1);
338
+ });
339
+ it("ignores a release from a session that does not own the device", () => {
340
+ const { socket, stream } = wireWithStream();
341
+ socket.handlers.get("session_acquired")?.({ sessionId: "s1" }, undefined);
342
+ socket.handlers.get("session_released")?.({ sessionId: "other" }, undefined);
343
+ expect(stream.stop).not.toHaveBeenCalled();
344
+ });
345
+ });
@@ -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,9 @@ 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>;
36
+ /** Appium extension endpoint: update driver settings for the current session. */
37
+ updateSettings(settings: Record<string, unknown>): Promise<void>;
30
38
  }
31
39
  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,17 +118,21 @@ 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);
131
+ /** Appium extension endpoint: update driver settings for the current session. */
132
+ async updateSettings(settings) {
133
+ await this.transport.request("POST", `/session/${this.requireId()}/appium/settings`, {
134
+ settings,
135
+ });
124
136
  }
125
137
  }
126
138
  export const ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf";
package/dist/mutate.d.ts CHANGED
@@ -5,7 +5,7 @@ export interface DryRunResult {
5
5
  url: string;
6
6
  body?: unknown;
7
7
  }
8
- export declare function dryRunEnvelope(method: string, path: string, body?: unknown): SuccessEnvelope<DryRunResult>;
8
+ export declare function dryRunEnvelope(method: string, pathOrUrl: string, body?: unknown): SuccessEnvelope<DryRunResult>;
9
9
  export declare function resolveJsonOverride(jsonFlag: unknown): {
10
10
  body: Record<string, unknown>;
11
11
  } | {
package/dist/mutate.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { formatError, formatSuccess, validationError } from "./output.js";
2
2
  import { parseJsonBody, JsonBodyError, OPENAPI_URL } from "./validate.js";
3
3
  import { getBaseUrl } from "./client.js";
4
- export function dryRunEnvelope(method, path, body) {
5
- const url = `${getBaseUrl()}${path.startsWith("/") ? path : `/${path}`}`;
4
+ export function dryRunEnvelope(method, pathOrUrl, body) {
5
+ const url = /^https?:\/\//.test(pathOrUrl)
6
+ ? pathOrUrl
7
+ : `${getBaseUrl()}${pathOrUrl.startsWith("/") ? pathOrUrl : `/${pathOrUrl}`}`;
6
8
  const data = { dryRun: true, method, url };
7
9
  if (body !== undefined)
8
10
  data.body = body;
@@ -0,0 +1,11 @@
1
+ export interface ConfirmOptions {
2
+ /** Whether a human can answer; defaults to stdin being a TTY. */
3
+ interactive?: boolean;
4
+ ask?: (question: string) => Promise<string>;
5
+ }
6
+ /**
7
+ * Yes-by-default confirmation. Enter proceeds; only an explicit n/no declines.
8
+ * Unattended callers (no TTY) proceed without asking, so CI and daemonized
9
+ * devices keep working instead of hanging on a prompt nobody can see.
10
+ */
11
+ export declare function confirmYesNo(question: string, options?: ConfirmOptions): Promise<boolean>;
package/dist/prompt.js ADDED
@@ -0,0 +1,19 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ function defaultAsk(question) {
3
+ // stderr, so prompts never contaminate the JSON envelope on stdout.
4
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
5
+ return rl.question(question).finally(() => rl.close());
6
+ }
7
+ /**
8
+ * Yes-by-default confirmation. Enter proceeds; only an explicit n/no declines.
9
+ * Unattended callers (no TTY) proceed without asking, so CI and daemonized
10
+ * devices keep working instead of hanging on a prompt nobody can see.
11
+ */
12
+ export async function confirmYesNo(question, options = {}) {
13
+ const interactive = options.interactive ?? process.stdin.isTTY === true;
14
+ if (!interactive)
15
+ return true;
16
+ const ask = options.ask ?? defaultAsk;
17
+ const answer = (await ask(`${question} [Y/n] `)).trim().toLowerCase();
18
+ return answer !== "n" && answer !== "no";
19
+ }