@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.
- package/README.md +19 -7
- 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/device/connect.js +19 -1
- package/dist/commands/device/doctor.d.ts +2 -0
- package/dist/commands/device/doctor.js +37 -10
- package/dist/commands/device/doctor.test.js +26 -1
- package/dist/commands/health/check.js +15 -3
- package/dist/commands/health/check.test.js +27 -3
- package/dist/commands/mcp/server.test.js +3 -1
- package/dist/commands/mcp/tools.js +28 -0
- package/dist/commands/mcp/tools.test.js +90 -0
- package/dist/commands/mobile/connect-core.d.ts +5 -3
- package/dist/commands/mobile/connect-core.js +60 -12
- 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 +22 -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/desktop-server/preflight.d.ts +43 -0
- package/dist/desktop-server/preflight.js +78 -0
- package/dist/desktop-server/preflight.test.d.ts +1 -0
- package/dist/desktop-server/preflight.test.js +115 -0
- package/dist/mobile-control/appium-controller.d.ts +28 -0
- package/dist/mobile-control/appium-controller.js +271 -92
- package/dist/mobile-control/appium-controller.test.js +258 -8
- package/dist/mobile-control/bundle-id-map.js +3 -0
- package/dist/mobile-control/controller.d.ts +3 -0
- package/dist/mobile-control/frame-sender.d.ts +34 -0
- package/dist/mobile-control/frame-sender.js +96 -0
- package/dist/mobile-control/frame-sender.test.d.ts +1 -0
- package/dist/mobile-control/frame-sender.test.js +108 -0
- package/dist/mobile-control/mjpeg-stream.d.ts +43 -0
- package/dist/mobile-control/mjpeg-stream.js +160 -0
- package/dist/mobile-control/mjpeg-stream.test.d.ts +1 -0
- package/dist/mobile-control/mjpeg-stream.test.js +93 -0
- package/dist/mobile-control/tunnel.d.ts +10 -1
- package/dist/mobile-control/tunnel.js +43 -2
- package/dist/mobile-control/tunnel.test.js +108 -0
- package/dist/mobile-control/webdriver.d.ts +9 -1
- package/dist/mobile-control/webdriver.js +17 -5
- package/dist/mutate.d.ts +1 -1
- package/dist/mutate.js +4 -2
- package/dist/prompt.d.ts +11 -0
- package/dist/prompt.js +19 -0
- package/dist/types.d.ts +66 -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
|
@@ -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
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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,
|
|
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;
|
package/dist/prompt.d.ts
ADDED
|
@@ -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
|
+
}
|