@sensaiorg/adapter-android 0.1.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/dist/android-adapter.d.ts.map +1 -0
- package/dist/android-adapter.js +89 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/tools/accessibility.d.ts.map +1 -0
- package/dist/tools/accessibility.js +85 -0
- package/dist/tools/adb.d.ts.map +1 -0
- package/dist/tools/adb.js +66 -0
- package/dist/tools/app-state.d.ts.map +1 -0
- package/dist/tools/app-state.js +173 -0
- package/dist/tools/diagnose.d.ts.map +1 -0
- package/dist/tools/diagnose.js +128 -0
- package/dist/tools/hot-reload.d.ts.map +1 -0
- package/dist/tools/hot-reload.js +97 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +66 -0
- package/dist/tools/interaction.d.ts.map +1 -0
- package/dist/tools/interaction.js +395 -0
- package/dist/tools/logcat.d.ts.map +1 -0
- package/dist/tools/logcat.js +216 -0
- package/dist/tools/network.d.ts.map +1 -0
- package/dist/tools/network.js +123 -0
- package/dist/tools/performance.d.ts.map +1 -0
- package/dist/tools/performance.js +143 -0
- package/dist/tools/recording.d.ts.map +1 -0
- package/dist/tools/recording.js +102 -0
- package/dist/tools/rn-tools.d.ts.map +1 -0
- package/dist/tools/rn-tools.js +120 -0
- package/dist/tools/smart-actions.d.ts.map +1 -0
- package/dist/tools/smart-actions.js +506 -0
- package/dist/tools/ui-tree.d.ts.map +1 -0
- package/dist/tools/ui-tree.js +226 -0
- package/dist/transport/adb-client.d.ts.map +1 -0
- package/dist/transport/adb-client.js +124 -0
- package/dist/transport/adb-client.test.d.ts.map +1 -0
- package/dist/transport/adb-client.test.js +153 -0
- package/dist/transport/agent-client.d.ts.map +1 -0
- package/dist/transport/agent-client.js +157 -0
- package/dist/transport/agent-client.test.d.ts.map +1 -0
- package/dist/transport/agent-client.test.js +199 -0
- package/dist/transport/connection-manager.d.ts.map +1 -0
- package/dist/transport/connection-manager.js +119 -0
- package/dist/util/logcat-parser.d.ts.map +1 -0
- package/dist/util/logcat-parser.js +79 -0
- package/dist/util/safety.d.ts.map +1 -0
- package/dist/util/safety.js +132 -0
- package/dist/util/safety.test.d.ts.map +1 -0
- package/dist/util/safety.test.js +205 -0
- package/dist/util/text-extractor.d.ts.map +1 -0
- package/dist/util/text-extractor.js +71 -0
- package/dist/util/ui-tree-cache.d.ts.map +1 -0
- package/dist/util/ui-tree-cache.js +46 -0
- package/dist/util/ui-tree-cache.test.d.ts.map +1 -0
- package/dist/util/ui-tree-cache.test.js +84 -0
- package/dist/util/ui-tree-parser.d.ts.map +1 -0
- package/dist/util/ui-tree-parser.js +123 -0
- package/dist/util/ui-tree-parser.test.d.ts.map +1 -0
- package/dist/util/ui-tree-parser.test.js +167 -0
- package/package.json +22 -0
- package/src/android-adapter.ts +124 -0
- package/src/index.ts +8 -0
- package/src/tools/accessibility.ts +94 -0
- package/src/tools/adb.ts +75 -0
- package/src/tools/app-state.ts +193 -0
- package/src/tools/diagnose.ts +146 -0
- package/src/tools/hot-reload.ts +103 -0
- package/src/tools/index.ts +66 -0
- package/src/tools/interaction.ts +448 -0
- package/src/tools/logcat.ts +252 -0
- package/src/tools/network.ts +145 -0
- package/src/tools/performance.ts +169 -0
- package/src/tools/recording.ts +123 -0
- package/src/tools/rn-tools.ts +143 -0
- package/src/tools/smart-actions.ts +593 -0
- package/src/tools/ui-tree.ts +258 -0
- package/src/transport/adb-client.test.ts +228 -0
- package/src/transport/adb-client.ts +139 -0
- package/src/transport/agent-client.test.ts +267 -0
- package/src/transport/agent-client.ts +188 -0
- package/src/transport/connection-manager.ts +140 -0
- package/src/util/logcat-parser.ts +94 -0
- package/src/util/safety.test.ts +251 -0
- package/src/util/safety.ts +143 -0
- package/src/util/text-extractor.ts +87 -0
- package/src/util/ui-tree-cache.test.ts +105 -0
- package/src/util/ui-tree-cache.ts +54 -0
- package/src/util/ui-tree-parser.test.ts +182 -0
- package/src/util/ui-tree-parser.ts +169 -0
- package/tsconfig.json +11 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import { AgentClient } from "./agent-client.js";
|
|
4
|
+
|
|
5
|
+
// --- Mock Socket ---
|
|
6
|
+
class MockSocket extends EventEmitter {
|
|
7
|
+
write = vi.fn(
|
|
8
|
+
(_data: string, _enc: string, cb?: (err?: Error) => void) => {
|
|
9
|
+
cb?.();
|
|
10
|
+
return true;
|
|
11
|
+
},
|
|
12
|
+
);
|
|
13
|
+
destroy = vi.fn();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let mockSocket: MockSocket;
|
|
17
|
+
|
|
18
|
+
vi.mock("node:net", () => ({
|
|
19
|
+
createConnection: () => {
|
|
20
|
+
mockSocket = new MockSocket();
|
|
21
|
+
// Schedule the "connect" event by default (tests can override before it fires)
|
|
22
|
+
return mockSocket;
|
|
23
|
+
},
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
describe("AgentClient", () => {
|
|
27
|
+
let client: AgentClient;
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
vi.useFakeTimers();
|
|
31
|
+
client = new AgentClient("127.0.0.1", 9222);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
vi.useRealTimers();
|
|
36
|
+
vi.clearAllMocks();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Helper: connect the client by emitting the connect event
|
|
40
|
+
async function connectClient(): Promise<void> {
|
|
41
|
+
const p = client.connect();
|
|
42
|
+
mockSocket.emit("connect");
|
|
43
|
+
await p;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("connect()", () => {
|
|
47
|
+
it("resolves on successful connection", async () => {
|
|
48
|
+
const p = client.connect();
|
|
49
|
+
// Socket emits connect
|
|
50
|
+
mockSocket.emit("connect");
|
|
51
|
+
await expect(p).resolves.toBeUndefined();
|
|
52
|
+
expect(client.isConnected()).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("rejects on timeout", async () => {
|
|
56
|
+
const p = client.connect();
|
|
57
|
+
// Advance past the 5s connect timeout
|
|
58
|
+
vi.advanceTimersByTime(5_001);
|
|
59
|
+
await expect(p).rejects.toThrow("Agent connection timed out");
|
|
60
|
+
expect(mockSocket.destroy).toHaveBeenCalled();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("rejects on connection error", async () => {
|
|
64
|
+
const p = client.connect();
|
|
65
|
+
mockSocket.emit("error", new Error("ECONNREFUSED"));
|
|
66
|
+
await expect(p).rejects.toThrow("Agent connection failed: ECONNREFUSED");
|
|
67
|
+
expect(client.isConnected()).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("reuses existing connection (no-op)", async () => {
|
|
71
|
+
await connectClient();
|
|
72
|
+
// Second connect should resolve immediately without creating a new socket
|
|
73
|
+
await expect(client.connect()).resolves.toBeUndefined();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("call()", () => {
|
|
78
|
+
it("sends JSON-RPC request with incremented id", async () => {
|
|
79
|
+
await connectClient();
|
|
80
|
+
|
|
81
|
+
// Fire first call
|
|
82
|
+
const p1 = client.call("getTree", { depth: 2 });
|
|
83
|
+
const payload1 = JSON.parse(mockSocket.write.mock.calls[0][0].trim());
|
|
84
|
+
expect(payload1).toEqual({
|
|
85
|
+
jsonrpc: "2.0",
|
|
86
|
+
id: 1,
|
|
87
|
+
method: "getTree",
|
|
88
|
+
params: { depth: 2 },
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Respond
|
|
92
|
+
mockSocket.emit("data", Buffer.from(JSON.stringify({ id: 1, result: "ok" }) + "\n"));
|
|
93
|
+
await p1;
|
|
94
|
+
|
|
95
|
+
// Fire second call
|
|
96
|
+
const p2 = client.call("getState");
|
|
97
|
+
const payload2 = JSON.parse(mockSocket.write.mock.calls[1][0].trim());
|
|
98
|
+
expect(payload2.id).toBe(2);
|
|
99
|
+
|
|
100
|
+
mockSocket.emit("data", Buffer.from(JSON.stringify({ id: 2, result: {} }) + "\n"));
|
|
101
|
+
await p2;
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("resolves with result from response", async () => {
|
|
105
|
+
await connectClient();
|
|
106
|
+
|
|
107
|
+
const p = client.call("getVersion");
|
|
108
|
+
mockSocket.emit(
|
|
109
|
+
"data",
|
|
110
|
+
Buffer.from(JSON.stringify({ id: 1, result: { version: "1.0" } }) + "\n"),
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
await expect(p).resolves.toEqual({ version: "1.0" });
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("rejects on RPC error response", async () => {
|
|
117
|
+
await connectClient();
|
|
118
|
+
|
|
119
|
+
const p = client.call("badMethod");
|
|
120
|
+
mockSocket.emit(
|
|
121
|
+
"data",
|
|
122
|
+
Buffer.from(
|
|
123
|
+
JSON.stringify({
|
|
124
|
+
id: 1,
|
|
125
|
+
error: { code: -32601, message: "Method not found" },
|
|
126
|
+
}) + "\n",
|
|
127
|
+
),
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
await expect(p).rejects.toThrow("RPC error (-32601): Method not found");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("rejects on timeout", async () => {
|
|
134
|
+
await connectClient();
|
|
135
|
+
|
|
136
|
+
const p = client.call("slowMethod", undefined, 2_000);
|
|
137
|
+
|
|
138
|
+
// No response comes; advance past timeout
|
|
139
|
+
vi.advanceTimersByTime(2_001);
|
|
140
|
+
|
|
141
|
+
await expect(p).rejects.toThrow("RPC call 'slowMethod' timed out after 2000ms");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("rejects if not connected", async () => {
|
|
145
|
+
await expect(client.call("anyMethod")).rejects.toThrow(
|
|
146
|
+
"Agent not connected. Call connect() first.",
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("sends empty params when none provided", async () => {
|
|
151
|
+
await connectClient();
|
|
152
|
+
|
|
153
|
+
const p = client.call("ping");
|
|
154
|
+
const payload = JSON.parse(mockSocket.write.mock.calls[0][0].trim());
|
|
155
|
+
expect(payload.params).toEqual({});
|
|
156
|
+
|
|
157
|
+
mockSocket.emit("data", Buffer.from(JSON.stringify({ id: 1, result: "pong" }) + "\n"));
|
|
158
|
+
await p;
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("rejects when socket write fails", async () => {
|
|
162
|
+
await connectClient();
|
|
163
|
+
|
|
164
|
+
mockSocket.write.mockImplementationOnce(
|
|
165
|
+
(_data: string, _enc: string, cb?: (err?: Error) => void) => {
|
|
166
|
+
cb?.(new Error("broken pipe"));
|
|
167
|
+
return false;
|
|
168
|
+
},
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
await expect(client.call("test")).rejects.toThrow("Failed to send RPC: broken pipe");
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe("disconnect()", () => {
|
|
176
|
+
it("cleans up socket and pending requests", async () => {
|
|
177
|
+
await connectClient();
|
|
178
|
+
|
|
179
|
+
// Create a pending request
|
|
180
|
+
const p = client.call("longRunning");
|
|
181
|
+
|
|
182
|
+
client.disconnect();
|
|
183
|
+
|
|
184
|
+
expect(mockSocket.destroy).toHaveBeenCalled();
|
|
185
|
+
expect(client.isConnected()).toBe(false);
|
|
186
|
+
await expect(p).rejects.toThrow("Agent disconnected");
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe("isConnected()", () => {
|
|
191
|
+
it("returns false before connecting", () => {
|
|
192
|
+
expect(client.isConnected()).toBe(false);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("returns true after connecting", async () => {
|
|
196
|
+
await connectClient();
|
|
197
|
+
expect(client.isConnected()).toBe(true);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("returns false after disconnect", async () => {
|
|
201
|
+
await connectClient();
|
|
202
|
+
client.disconnect();
|
|
203
|
+
expect(client.isConnected()).toBe(false);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe("buffer processing", () => {
|
|
208
|
+
it("handles multiple messages in one chunk", async () => {
|
|
209
|
+
await connectClient();
|
|
210
|
+
|
|
211
|
+
const p1 = client.call("method1");
|
|
212
|
+
const p2 = client.call("method2");
|
|
213
|
+
|
|
214
|
+
// Send both responses in a single data chunk
|
|
215
|
+
const combined =
|
|
216
|
+
JSON.stringify({ id: 1, result: "r1" }) +
|
|
217
|
+
"\n" +
|
|
218
|
+
JSON.stringify({ id: 2, result: "r2" }) +
|
|
219
|
+
"\n";
|
|
220
|
+
mockSocket.emit("data", Buffer.from(combined));
|
|
221
|
+
|
|
222
|
+
await expect(p1).resolves.toBe("r1");
|
|
223
|
+
await expect(p2).resolves.toBe("r2");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("handles split messages across chunks", async () => {
|
|
227
|
+
await connectClient();
|
|
228
|
+
|
|
229
|
+
const p = client.call("method1");
|
|
230
|
+
|
|
231
|
+
const full = JSON.stringify({ id: 1, result: "split-result" }) + "\n";
|
|
232
|
+
const mid = Math.floor(full.length / 2);
|
|
233
|
+
|
|
234
|
+
// Send first half
|
|
235
|
+
mockSocket.emit("data", Buffer.from(full.slice(0, mid)));
|
|
236
|
+
// Send second half
|
|
237
|
+
mockSocket.emit("data", Buffer.from(full.slice(mid)));
|
|
238
|
+
|
|
239
|
+
await expect(p).resolves.toBe("split-result");
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe("disconnect rejects all pending requests", () => {
|
|
244
|
+
it("rejects multiple pending requests on disconnect", async () => {
|
|
245
|
+
await connectClient();
|
|
246
|
+
|
|
247
|
+
const p1 = client.call("a");
|
|
248
|
+
const p2 = client.call("b");
|
|
249
|
+
const p3 = client.call("c");
|
|
250
|
+
|
|
251
|
+
client.disconnect();
|
|
252
|
+
|
|
253
|
+
await expect(p1).rejects.toThrow("Agent disconnected");
|
|
254
|
+
await expect(p2).rejects.toThrow("Agent disconnected");
|
|
255
|
+
await expect(p3).rejects.toThrow("Agent disconnected");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("rejects pending requests on socket close event", async () => {
|
|
259
|
+
await connectClient();
|
|
260
|
+
|
|
261
|
+
const p = client.call("pending");
|
|
262
|
+
mockSocket.emit("close");
|
|
263
|
+
|
|
264
|
+
await expect(p).rejects.toThrow("Agent disconnected: Socket closed");
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
});
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Client - Communicates with the on-device EmuDebug agent via TCP JSON-RPC.
|
|
3
|
+
*
|
|
4
|
+
* The agent runs inside the target app process and provides deep introspection
|
|
5
|
+
* capabilities (React Native component tree, app state, network interception, etc.)
|
|
6
|
+
* that are not available through ADB alone.
|
|
7
|
+
*
|
|
8
|
+
* Protocol: newline-delimited JSON-RPC 2.0 over a raw TCP socket.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Socket } from "node:net";
|
|
12
|
+
import { createConnection } from "node:net";
|
|
13
|
+
|
|
14
|
+
/** A pending JSON-RPC request awaiting a response. */
|
|
15
|
+
interface PendingRequest {
|
|
16
|
+
resolve: (value: unknown) => void;
|
|
17
|
+
reject: (reason: Error) => void;
|
|
18
|
+
timer: ReturnType<typeof setTimeout>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Default timeout for a single RPC call (ms). */
|
|
22
|
+
const RPC_TIMEOUT_MS = 15_000;
|
|
23
|
+
|
|
24
|
+
/** Default TCP connection timeout (ms). */
|
|
25
|
+
const CONNECT_TIMEOUT_MS = 5_000;
|
|
26
|
+
|
|
27
|
+
export class AgentClient {
|
|
28
|
+
private socket: Socket | null = null;
|
|
29
|
+
private connected = false;
|
|
30
|
+
private nextId = 1;
|
|
31
|
+
private pending = new Map<number, PendingRequest>();
|
|
32
|
+
private buffer = "";
|
|
33
|
+
private readonly host: string;
|
|
34
|
+
private readonly port: number;
|
|
35
|
+
|
|
36
|
+
constructor(host: string, port: number) {
|
|
37
|
+
this.host = host;
|
|
38
|
+
this.port = port;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Open a TCP connection to the on-device agent.
|
|
43
|
+
* Resolves when the connection is established, rejects on timeout or error.
|
|
44
|
+
*/
|
|
45
|
+
connect(): Promise<void> {
|
|
46
|
+
return new Promise((resolve, reject) => {
|
|
47
|
+
if (this.connected && this.socket) {
|
|
48
|
+
resolve();
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const socket = createConnection({ host: this.host, port: this.port });
|
|
53
|
+
|
|
54
|
+
const connectTimer = setTimeout(() => {
|
|
55
|
+
socket.destroy();
|
|
56
|
+
reject(new Error(`Agent connection timed out after ${CONNECT_TIMEOUT_MS}ms`));
|
|
57
|
+
}, CONNECT_TIMEOUT_MS);
|
|
58
|
+
|
|
59
|
+
socket.on("connect", () => {
|
|
60
|
+
clearTimeout(connectTimer);
|
|
61
|
+
this.socket = socket;
|
|
62
|
+
this.connected = true;
|
|
63
|
+
this.buffer = "";
|
|
64
|
+
resolve();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
socket.on("data", (chunk: Buffer) => {
|
|
68
|
+
this.buffer += chunk.toString("utf-8");
|
|
69
|
+
this.processBuffer();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
socket.on("close", () => {
|
|
73
|
+
this.handleDisconnect("Socket closed");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
socket.on("error", (err: Error) => {
|
|
77
|
+
clearTimeout(connectTimer);
|
|
78
|
+
this.handleDisconnect(err.message);
|
|
79
|
+
if (!this.connected) {
|
|
80
|
+
reject(new Error(`Agent connection failed: ${err.message}`));
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Send a JSON-RPC call to the agent and await the response.
|
|
88
|
+
* @param method - The RPC method name.
|
|
89
|
+
* @param params - Optional parameters object.
|
|
90
|
+
* @param timeoutMs - Per-call timeout.
|
|
91
|
+
*/
|
|
92
|
+
call(method: string, params?: Record<string, unknown>, timeoutMs = RPC_TIMEOUT_MS): Promise<unknown> {
|
|
93
|
+
return new Promise((resolve, reject) => {
|
|
94
|
+
if (!this.connected || !this.socket) {
|
|
95
|
+
reject(new Error("Agent not connected. Call connect() first."));
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const id = this.nextId++;
|
|
100
|
+
const request = {
|
|
101
|
+
jsonrpc: "2.0",
|
|
102
|
+
id,
|
|
103
|
+
method,
|
|
104
|
+
params: params ?? {},
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const timer = setTimeout(() => {
|
|
108
|
+
this.pending.delete(id);
|
|
109
|
+
reject(new Error(`RPC call '${method}' timed out after ${timeoutMs}ms`));
|
|
110
|
+
}, timeoutMs);
|
|
111
|
+
|
|
112
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
113
|
+
|
|
114
|
+
const payload = JSON.stringify(request) + "\n";
|
|
115
|
+
this.socket.write(payload, "utf-8", (err) => {
|
|
116
|
+
if (err) {
|
|
117
|
+
clearTimeout(timer);
|
|
118
|
+
this.pending.delete(id);
|
|
119
|
+
reject(new Error(`Failed to send RPC: ${err.message}`));
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Gracefully disconnect from the agent.
|
|
127
|
+
*/
|
|
128
|
+
disconnect(): void {
|
|
129
|
+
if (this.socket) {
|
|
130
|
+
this.socket.destroy();
|
|
131
|
+
}
|
|
132
|
+
this.handleDisconnect("Disconnect requested");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Check whether the agent TCP connection is alive.
|
|
137
|
+
*/
|
|
138
|
+
isConnected(): boolean {
|
|
139
|
+
return this.connected;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// --- Private helpers ---
|
|
143
|
+
|
|
144
|
+
/** Process newline-delimited JSON messages from the buffer. */
|
|
145
|
+
private processBuffer(): void {
|
|
146
|
+
let newlineIdx: number;
|
|
147
|
+
while ((newlineIdx = this.buffer.indexOf("\n")) !== -1) {
|
|
148
|
+
const line = this.buffer.slice(0, newlineIdx).trim();
|
|
149
|
+
this.buffer = this.buffer.slice(newlineIdx + 1);
|
|
150
|
+
|
|
151
|
+
if (!line) continue;
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
const msg = JSON.parse(line) as {
|
|
155
|
+
id?: number;
|
|
156
|
+
result?: unknown;
|
|
157
|
+
error?: { code: number; message: string; data?: unknown };
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
if (msg.id != null && this.pending.has(msg.id)) {
|
|
161
|
+
const req = this.pending.get(msg.id)!;
|
|
162
|
+
this.pending.delete(msg.id);
|
|
163
|
+
clearTimeout(req.timer);
|
|
164
|
+
|
|
165
|
+
if (msg.error) {
|
|
166
|
+
req.reject(new Error(`RPC error (${msg.error.code}): ${msg.error.message}`));
|
|
167
|
+
} else {
|
|
168
|
+
req.resolve(msg.result);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
} catch {
|
|
172
|
+
// Ignore unparseable lines
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Clean up connection state and reject all pending requests. */
|
|
178
|
+
private handleDisconnect(reason: string): void {
|
|
179
|
+
this.connected = false;
|
|
180
|
+
this.socket = null;
|
|
181
|
+
|
|
182
|
+
for (const [id, req] of this.pending) {
|
|
183
|
+
clearTimeout(req.timer);
|
|
184
|
+
req.reject(new Error(`Agent disconnected: ${reason}`));
|
|
185
|
+
this.pending.delete(id);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connection Manager - Maintains ADB and agent connections with health checks
|
|
3
|
+
* and automatic reconnection.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { AdbClient } from "./adb-client.js";
|
|
7
|
+
import { AgentClient } from "./agent-client.js";
|
|
8
|
+
import { UiTreeCache } from "../util/ui-tree-cache.js";
|
|
9
|
+
|
|
10
|
+
/** How often to run health checks (ms). */
|
|
11
|
+
const HEALTH_CHECK_INTERVAL_MS = 30_000;
|
|
12
|
+
|
|
13
|
+
/** Delay before attempting agent reconnection (ms). */
|
|
14
|
+
const RECONNECT_DELAY_MS = 5_000;
|
|
15
|
+
|
|
16
|
+
/** Maximum consecutive agent reconnect failures before backing off. */
|
|
17
|
+
const MAX_RECONNECT_ATTEMPTS = 3;
|
|
18
|
+
|
|
19
|
+
export interface ConnectionStatus {
|
|
20
|
+
adb: boolean;
|
|
21
|
+
agent: boolean;
|
|
22
|
+
device: string;
|
|
23
|
+
agentPort: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class ConnectionManager {
|
|
27
|
+
readonly adb: AdbClient;
|
|
28
|
+
readonly agent: AgentClient;
|
|
29
|
+
readonly uiCache: UiTreeCache;
|
|
30
|
+
private readonly agentPort: number;
|
|
31
|
+
private readonly device: string;
|
|
32
|
+
private healthTimer: ReturnType<typeof setInterval> | null = null;
|
|
33
|
+
private reconnectAttempts = 0;
|
|
34
|
+
|
|
35
|
+
constructor(adbPath: string, device: string, agentPort: number) {
|
|
36
|
+
this.device = device;
|
|
37
|
+
this.agentPort = agentPort;
|
|
38
|
+
this.adb = new AdbClient(adbPath, device);
|
|
39
|
+
this.agent = new AgentClient("127.0.0.1", agentPort);
|
|
40
|
+
this.uiCache = new UiTreeCache();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Initialize connections: verify ADB, set up port forwarding, attempt agent connection.
|
|
45
|
+
* ADB must succeed; agent connection is best-effort (Phase 2 feature).
|
|
46
|
+
*/
|
|
47
|
+
async initialize(): Promise<ConnectionStatus> {
|
|
48
|
+
// ADB is required
|
|
49
|
+
const adbOk = await this.adb.isConnected();
|
|
50
|
+
if (!adbOk) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`Device ${this.device} not reachable via ADB. ` +
|
|
53
|
+
`Ensure the emulator/device is running and 'adb devices' lists it.`
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Forward the agent port (best-effort)
|
|
58
|
+
try {
|
|
59
|
+
await this.adb.forward(this.agentPort, this.agentPort);
|
|
60
|
+
} catch {
|
|
61
|
+
// Port forwarding may fail if agent isn't installed yet - that's fine
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Try connecting to the on-device agent (optional for Phase 1)
|
|
65
|
+
await this.tryConnectAgent();
|
|
66
|
+
|
|
67
|
+
// Start periodic health checks
|
|
68
|
+
this.startHealthChecks();
|
|
69
|
+
|
|
70
|
+
return this.getStatus();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Get current connection status.
|
|
75
|
+
*/
|
|
76
|
+
getStatus(): ConnectionStatus {
|
|
77
|
+
return {
|
|
78
|
+
adb: this.adb.isDeviceConnected,
|
|
79
|
+
agent: this.agent.isConnected(),
|
|
80
|
+
device: this.device,
|
|
81
|
+
agentPort: this.agentPort,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Cleanly shut down connections and timers.
|
|
87
|
+
*/
|
|
88
|
+
shutdown(): void {
|
|
89
|
+
if (this.healthTimer) {
|
|
90
|
+
clearInterval(this.healthTimer);
|
|
91
|
+
this.healthTimer = null;
|
|
92
|
+
}
|
|
93
|
+
this.agent.disconnect();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// --- Private helpers ---
|
|
97
|
+
|
|
98
|
+
/** Attempt agent connection without throwing. */
|
|
99
|
+
private async tryConnectAgent(): Promise<void> {
|
|
100
|
+
try {
|
|
101
|
+
await this.agent.connect();
|
|
102
|
+
this.reconnectAttempts = 0;
|
|
103
|
+
} catch {
|
|
104
|
+
// Agent not available - Phase 1 operates without it
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Periodic health check: verify ADB, reconnect agent if dropped. */
|
|
109
|
+
private startHealthChecks(): void {
|
|
110
|
+
this.healthTimer = setInterval(async () => {
|
|
111
|
+
// Check ADB
|
|
112
|
+
try {
|
|
113
|
+
await this.adb.isConnected();
|
|
114
|
+
} catch {
|
|
115
|
+
// ADB went away - not much we can do automatically
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Reset reconnect counter if agent is healthy
|
|
119
|
+
if (this.agent.isConnected()) {
|
|
120
|
+
this.reconnectAttempts = 0;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Reconnect agent if it dropped
|
|
124
|
+
if (!this.agent.isConnected() && this.reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
|
|
125
|
+
this.reconnectAttempts++;
|
|
126
|
+
await this.sleep(RECONNECT_DELAY_MS);
|
|
127
|
+
await this.tryConnectAgent();
|
|
128
|
+
}
|
|
129
|
+
}, HEALTH_CHECK_INTERVAL_MS);
|
|
130
|
+
|
|
131
|
+
// Don't keep the process alive just for health checks
|
|
132
|
+
if (this.healthTimer.unref) {
|
|
133
|
+
this.healthTimer.unref();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private sleep(ms: number): Promise<void> {
|
|
138
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logcat Parser - Parses Android logcat output into structured entries.
|
|
3
|
+
*
|
|
4
|
+
* Supports the "threadtime" format which is the default for `adb logcat -v threadtime`.
|
|
5
|
+
* Format: "MM-DD HH:MM:SS.mmm PID TID LEVEL TAG: MESSAGE"
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Log severity levels in ascending order. */
|
|
9
|
+
export type LogLevel = "V" | "D" | "I" | "W" | "E" | "F";
|
|
10
|
+
|
|
11
|
+
/** A single parsed logcat entry. */
|
|
12
|
+
export interface LogEntry {
|
|
13
|
+
timestamp: string;
|
|
14
|
+
pid: string;
|
|
15
|
+
tid: string;
|
|
16
|
+
level: LogLevel;
|
|
17
|
+
tag: string;
|
|
18
|
+
message: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Regex for threadtime logcat format. */
|
|
22
|
+
const THREADTIME_RE =
|
|
23
|
+
/^(\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\.\d{3})\s+(\d+)\s+(\d+)\s+([VDIWEF])\s+(.+?):\s+(.*)$/;
|
|
24
|
+
|
|
25
|
+
/** Level priorities for filtering. */
|
|
26
|
+
const LEVEL_PRIORITY: Record<LogLevel, number> = {
|
|
27
|
+
V: 0,
|
|
28
|
+
D: 1,
|
|
29
|
+
I: 2,
|
|
30
|
+
W: 3,
|
|
31
|
+
E: 4,
|
|
32
|
+
F: 5,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Parse raw logcat output into structured entries.
|
|
37
|
+
*
|
|
38
|
+
* @param raw - Raw logcat text (threadtime format).
|
|
39
|
+
* @returns Array of parsed log entries.
|
|
40
|
+
*/
|
|
41
|
+
export function parseLogcat(raw: string): LogEntry[] {
|
|
42
|
+
const lines = raw.split("\n");
|
|
43
|
+
const entries: LogEntry[] = [];
|
|
44
|
+
|
|
45
|
+
for (const line of lines) {
|
|
46
|
+
const trimmed = line.trim();
|
|
47
|
+
if (!trimmed || trimmed.startsWith("-----")) continue;
|
|
48
|
+
|
|
49
|
+
const match = trimmed.match(THREADTIME_RE);
|
|
50
|
+
if (match) {
|
|
51
|
+
entries.push({
|
|
52
|
+
timestamp: match[1],
|
|
53
|
+
pid: match[2],
|
|
54
|
+
tid: match[3],
|
|
55
|
+
level: match[4] as LogLevel,
|
|
56
|
+
tag: match[5].trim(),
|
|
57
|
+
message: match[6],
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return entries;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Filter log entries by minimum level.
|
|
67
|
+
*/
|
|
68
|
+
export function filterByLevel(entries: LogEntry[], minLevel: LogLevel): LogEntry[] {
|
|
69
|
+
const minPriority = LEVEL_PRIORITY[minLevel];
|
|
70
|
+
return entries.filter((e) => LEVEL_PRIORITY[e.level] >= minPriority);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Filter log entries by tag (case-insensitive substring match).
|
|
75
|
+
*/
|
|
76
|
+
export function filterByTag(entries: LogEntry[], tag: string): LogEntry[] {
|
|
77
|
+
const lower = tag.toLowerCase();
|
|
78
|
+
return entries.filter((e) => e.tag.toLowerCase().includes(lower));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Filter log entries by grep pattern (applied to message, case-insensitive).
|
|
83
|
+
*/
|
|
84
|
+
export function filterByGrep(entries: LogEntry[], pattern: string): LogEntry[] {
|
|
85
|
+
const re = new RegExp(pattern, "i");
|
|
86
|
+
return entries.filter((e) => re.test(e.message) || re.test(e.tag));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Format a log entry back to a human-readable string.
|
|
91
|
+
*/
|
|
92
|
+
export function formatEntry(entry: LogEntry): string {
|
|
93
|
+
return `${entry.timestamp} ${entry.pid} ${entry.tid} ${entry.level} ${entry.tag}: ${entry.message}`;
|
|
94
|
+
}
|