@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.
Files changed (89) hide show
  1. package/dist/android-adapter.d.ts.map +1 -0
  2. package/dist/android-adapter.js +89 -0
  3. package/dist/index.d.ts.map +1 -0
  4. package/dist/index.js +12 -0
  5. package/dist/tools/accessibility.d.ts.map +1 -0
  6. package/dist/tools/accessibility.js +85 -0
  7. package/dist/tools/adb.d.ts.map +1 -0
  8. package/dist/tools/adb.js +66 -0
  9. package/dist/tools/app-state.d.ts.map +1 -0
  10. package/dist/tools/app-state.js +173 -0
  11. package/dist/tools/diagnose.d.ts.map +1 -0
  12. package/dist/tools/diagnose.js +128 -0
  13. package/dist/tools/hot-reload.d.ts.map +1 -0
  14. package/dist/tools/hot-reload.js +97 -0
  15. package/dist/tools/index.d.ts.map +1 -0
  16. package/dist/tools/index.js +66 -0
  17. package/dist/tools/interaction.d.ts.map +1 -0
  18. package/dist/tools/interaction.js +395 -0
  19. package/dist/tools/logcat.d.ts.map +1 -0
  20. package/dist/tools/logcat.js +216 -0
  21. package/dist/tools/network.d.ts.map +1 -0
  22. package/dist/tools/network.js +123 -0
  23. package/dist/tools/performance.d.ts.map +1 -0
  24. package/dist/tools/performance.js +143 -0
  25. package/dist/tools/recording.d.ts.map +1 -0
  26. package/dist/tools/recording.js +102 -0
  27. package/dist/tools/rn-tools.d.ts.map +1 -0
  28. package/dist/tools/rn-tools.js +120 -0
  29. package/dist/tools/smart-actions.d.ts.map +1 -0
  30. package/dist/tools/smart-actions.js +506 -0
  31. package/dist/tools/ui-tree.d.ts.map +1 -0
  32. package/dist/tools/ui-tree.js +226 -0
  33. package/dist/transport/adb-client.d.ts.map +1 -0
  34. package/dist/transport/adb-client.js +124 -0
  35. package/dist/transport/adb-client.test.d.ts.map +1 -0
  36. package/dist/transport/adb-client.test.js +153 -0
  37. package/dist/transport/agent-client.d.ts.map +1 -0
  38. package/dist/transport/agent-client.js +157 -0
  39. package/dist/transport/agent-client.test.d.ts.map +1 -0
  40. package/dist/transport/agent-client.test.js +199 -0
  41. package/dist/transport/connection-manager.d.ts.map +1 -0
  42. package/dist/transport/connection-manager.js +119 -0
  43. package/dist/util/logcat-parser.d.ts.map +1 -0
  44. package/dist/util/logcat-parser.js +79 -0
  45. package/dist/util/safety.d.ts.map +1 -0
  46. package/dist/util/safety.js +132 -0
  47. package/dist/util/safety.test.d.ts.map +1 -0
  48. package/dist/util/safety.test.js +205 -0
  49. package/dist/util/text-extractor.d.ts.map +1 -0
  50. package/dist/util/text-extractor.js +71 -0
  51. package/dist/util/ui-tree-cache.d.ts.map +1 -0
  52. package/dist/util/ui-tree-cache.js +46 -0
  53. package/dist/util/ui-tree-cache.test.d.ts.map +1 -0
  54. package/dist/util/ui-tree-cache.test.js +84 -0
  55. package/dist/util/ui-tree-parser.d.ts.map +1 -0
  56. package/dist/util/ui-tree-parser.js +123 -0
  57. package/dist/util/ui-tree-parser.test.d.ts.map +1 -0
  58. package/dist/util/ui-tree-parser.test.js +167 -0
  59. package/package.json +22 -0
  60. package/src/android-adapter.ts +124 -0
  61. package/src/index.ts +8 -0
  62. package/src/tools/accessibility.ts +94 -0
  63. package/src/tools/adb.ts +75 -0
  64. package/src/tools/app-state.ts +193 -0
  65. package/src/tools/diagnose.ts +146 -0
  66. package/src/tools/hot-reload.ts +103 -0
  67. package/src/tools/index.ts +66 -0
  68. package/src/tools/interaction.ts +448 -0
  69. package/src/tools/logcat.ts +252 -0
  70. package/src/tools/network.ts +145 -0
  71. package/src/tools/performance.ts +169 -0
  72. package/src/tools/recording.ts +123 -0
  73. package/src/tools/rn-tools.ts +143 -0
  74. package/src/tools/smart-actions.ts +593 -0
  75. package/src/tools/ui-tree.ts +258 -0
  76. package/src/transport/adb-client.test.ts +228 -0
  77. package/src/transport/adb-client.ts +139 -0
  78. package/src/transport/agent-client.test.ts +267 -0
  79. package/src/transport/agent-client.ts +188 -0
  80. package/src/transport/connection-manager.ts +140 -0
  81. package/src/util/logcat-parser.ts +94 -0
  82. package/src/util/safety.test.ts +251 -0
  83. package/src/util/safety.ts +143 -0
  84. package/src/util/text-extractor.ts +87 -0
  85. package/src/util/ui-tree-cache.test.ts +105 -0
  86. package/src/util/ui-tree-cache.ts +54 -0
  87. package/src/util/ui-tree-parser.test.ts +182 -0
  88. package/src/util/ui-tree-parser.ts +169 -0
  89. package/tsconfig.json +11 -0
@@ -0,0 +1,226 @@
1
+ "use strict";
2
+ /**
3
+ * UI Tree Tools - Inspect the Android view hierarchy.
4
+ *
5
+ * Provides three tools:
6
+ * - get_ui_tree: Full UI hierarchy as JSON
7
+ * - get_screen_text: Extract all visible text in reading order
8
+ * - get_element_details: Deep inspection of a specific element
9
+ */
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.getCachedTree = getCachedTree;
12
+ exports.registerUiTreeTools = registerUiTreeTools;
13
+ const zod_1 = require("zod");
14
+ const ui_tree_parser_js_1 = require("../util/ui-tree-parser.js");
15
+ const text_extractor_js_1 = require("../util/text-extractor.js");
16
+ /**
17
+ * Dump the UI hierarchy via ADB uiautomator (raw XML).
18
+ * Uses a unique filename per dump to prevent race conditions on concurrent requests.
19
+ */
20
+ async function dumpUiTreeRaw(cm) {
21
+ const dumpPath = `/sdcard/sensai_dump_${Date.now()}.xml`;
22
+ const dumpOutput = await cm.adb.shell(`uiautomator dump ${dumpPath}`);
23
+ if (dumpOutput.includes("ERROR") || !dumpOutput.includes("dumped")) {
24
+ throw new Error(`uiautomator dump failed: ${dumpOutput.trim()}`);
25
+ }
26
+ const xml = await cm.adb.shell(`cat ${dumpPath}`);
27
+ // Clean up the dump file (fire and forget)
28
+ cm.adb.shell(`rm -f ${dumpPath}`).catch(() => { });
29
+ if (!xml.includes("<hierarchy")) {
30
+ throw new Error("UI dump produced invalid XML");
31
+ }
32
+ return xml;
33
+ }
34
+ /**
35
+ * Get the UI tree with caching support. Shared by all tools that need UI data.
36
+ * Exported so other tool modules can reuse without redundant dumps.
37
+ */
38
+ async function getCachedTree(cm, options) {
39
+ // Phase 2: prefer agent for richer data
40
+ if (cm.agent.isConnected()) {
41
+ try {
42
+ const result = await cm.agent.call("getUiTree", options);
43
+ return result;
44
+ }
45
+ catch {
46
+ // Fall through to ADB
47
+ }
48
+ }
49
+ // Check cache unless force refresh requested
50
+ if (!options.forceRefresh) {
51
+ const cached = cm.uiCache.get();
52
+ if (cached) {
53
+ // Re-parse with current options from cached XML
54
+ return (0, ui_tree_parser_js_1.parseUiTree)(cached.xml, options);
55
+ }
56
+ }
57
+ // Phase 1: ADB uiautomator dump
58
+ const xml = await dumpUiTreeRaw(cm);
59
+ const tree = (0, ui_tree_parser_js_1.parseUiTree)(xml, options);
60
+ // Cache the raw XML and default-options tree
61
+ cm.uiCache.set(tree, xml);
62
+ return tree;
63
+ }
64
+ function registerUiTreeTools(server, cm) {
65
+ /**
66
+ * get_ui_tree - Returns the full UI hierarchy as structured JSON.
67
+ */
68
+ server.tool("get_ui_tree", "Get the Android UI view hierarchy as structured JSON. Returns class names, resource IDs, text, bounds, and interactive state for every view element.", {
69
+ maxDepth: zod_1.z.number().optional().describe("Maximum depth to traverse (0 = unlimited)"),
70
+ visibleOnly: zod_1.z.boolean().optional().describe("Only include visible nodes"),
71
+ includeSystemUI: zod_1.z.boolean().optional().describe("Include status bar, nav bar, etc."),
72
+ interactableOnly: zod_1.z.boolean().optional().describe("Only return nodes that are clickable, focusable, or have text content. Reduces noise significantly."),
73
+ format: zod_1.z.enum(["full", "compact"]).optional().describe("Output format: 'full' (default) returns UiNode objects, 'compact' returns [text, resourceId, bounds, clickable] tuples for ~60% token savings"),
74
+ }, async (params) => {
75
+ try {
76
+ const tree = await getCachedTree(cm, {
77
+ maxDepth: params.maxDepth,
78
+ visibleOnly: params.visibleOnly,
79
+ includeSystemUI: params.includeSystemUI,
80
+ });
81
+ let flat = (0, ui_tree_parser_js_1.flattenTree)(tree);
82
+ const totalNodes = flat.length;
83
+ // Filter to interactable nodes only
84
+ if (params.interactableOnly) {
85
+ flat = flat.filter((n) => n.clickable || n.focusable || !!n.text);
86
+ }
87
+ const summary = {
88
+ totalNodes,
89
+ returnedNodes: flat.length,
90
+ clickableNodes: flat.filter((n) => n.clickable).length,
91
+ textNodes: flat.filter((n) => n.text).length,
92
+ filtered: !!params.interactableOnly,
93
+ format: params.format ?? "full",
94
+ };
95
+ let treeData;
96
+ if (params.format === "compact") {
97
+ // Compact format: [text, resourceId, boundsStr, clickable] tuples
98
+ // Saves ~60% tokens vs full UiNode objects
99
+ treeData = flat.map((n) => [
100
+ n.text || n.contentDescription || "",
101
+ n.resourceId || "",
102
+ n.bounds ? `[${n.bounds.left},${n.bounds.top}][${n.bounds.right},${n.bounds.bottom}]` : "",
103
+ n.clickable,
104
+ ]);
105
+ }
106
+ else {
107
+ // Full format: return tree if not filtered, flat array if filtered
108
+ treeData = params.interactableOnly ? flat : tree;
109
+ }
110
+ return {
111
+ content: [
112
+ {
113
+ type: "text",
114
+ text: JSON.stringify({ summary, tree: treeData }),
115
+ },
116
+ ],
117
+ };
118
+ }
119
+ catch (err) {
120
+ return {
121
+ content: [
122
+ {
123
+ type: "text",
124
+ text: `Error getting UI tree: ${err instanceof Error ? err.message : String(err)}`,
125
+ },
126
+ ],
127
+ isError: true,
128
+ };
129
+ }
130
+ });
131
+ /**
132
+ * get_screen_text - Extract all visible text from the screen in reading order.
133
+ */
134
+ server.tool("get_screen_text", "Extract all visible text from the current screen, ordered top-to-bottom left-to-right (reading order). Useful for understanding what the user sees.", {}, async () => {
135
+ try {
136
+ const tree = await getCachedTree(cm, { visibleOnly: true, includeSystemUI: false });
137
+ const textEntries = (0, text_extractor_js_1.extractText)(tree);
138
+ const textStrings = (0, text_extractor_js_1.extractTextStrings)(tree);
139
+ // Deduplicate: trim whitespace, remove empty strings, remove duplicates
140
+ const deduped = [...new Set(textStrings
141
+ .map((s) => s.trim())
142
+ .filter((s) => s.length > 0))];
143
+ return {
144
+ content: [
145
+ {
146
+ type: "text",
147
+ text: JSON.stringify({
148
+ screenText: deduped,
149
+ detailed: textEntries,
150
+ }),
151
+ },
152
+ ],
153
+ };
154
+ }
155
+ catch (err) {
156
+ return {
157
+ content: [
158
+ {
159
+ type: "text",
160
+ text: `Error extracting screen text: ${err instanceof Error ? err.message : String(err)}`,
161
+ },
162
+ ],
163
+ isError: true,
164
+ };
165
+ }
166
+ });
167
+ /**
168
+ * get_element_details - Deep inspection of elements matching a search query.
169
+ */
170
+ server.tool("get_element_details", "Find and inspect UI elements matching a text query. Returns full details including bounds, class, interactive state, and nearby elements.", {
171
+ query: zod_1.z.string().describe("Text to search for (case-insensitive substring match)"),
172
+ includeParent: zod_1.z.boolean().optional().describe("Include parent context for matched elements"),
173
+ }, async (params) => {
174
+ try {
175
+ const tree = await getCachedTree(cm, { visibleOnly: true, includeSystemUI: false });
176
+ const matches = (0, text_extractor_js_1.findByText)(tree, params.query);
177
+ if (matches.length === 0) {
178
+ return {
179
+ content: [
180
+ {
181
+ type: "text",
182
+ text: JSON.stringify({
183
+ found: 0,
184
+ message: `No elements found matching "${params.query}"`,
185
+ hint: "Try using get_screen_text to see all visible text first.",
186
+ }),
187
+ },
188
+ ],
189
+ };
190
+ }
191
+ const details = matches.map((node) => ({
192
+ text: node.text,
193
+ contentDescription: node.contentDescription,
194
+ className: node.className,
195
+ resourceId: node.resourceId,
196
+ bounds: node.bounds,
197
+ clickable: node.clickable,
198
+ enabled: node.enabled,
199
+ focusable: node.focusable,
200
+ scrollable: node.scrollable,
201
+ centerX: node.bounds ? Math.round((node.bounds.left + node.bounds.right) / 2) : null,
202
+ centerY: node.bounds ? Math.round((node.bounds.top + node.bounds.bottom) / 2) : null,
203
+ }));
204
+ return {
205
+ content: [
206
+ {
207
+ type: "text",
208
+ text: JSON.stringify({ found: matches.length, elements: details }),
209
+ },
210
+ ],
211
+ };
212
+ }
213
+ catch (err) {
214
+ return {
215
+ content: [
216
+ {
217
+ type: "text",
218
+ text: `Error inspecting elements: ${err instanceof Error ? err.message : String(err)}`,
219
+ },
220
+ ],
221
+ isError: true,
222
+ };
223
+ }
224
+ });
225
+ }
226
+ //# sourceMappingURL=ui-tree.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adb-client.d.ts","sourceRoot":"","sources":["../../src/transport/adb-client.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAUH,sDAAsD;AACtD,MAAM,WAAW,UAAU;IACzB,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,cAAc,EAAE,MAAM,CAAC;IACvB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,qBAAa,SAAS;IACpB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,SAAS,CAAS;gBAEd,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM;IAK3C;;;;;OAKG;IACG,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,SAAS,SAAqB,GAAG,OAAO,CAAC,MAAM,CAAC;IAkB3E;;;;OAIG;IACG,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,SAAqB,GAAG,OAAO,CAAC,MAAM,CAAC;IAI7E;;OAEG;IACG,WAAW,IAAI,OAAO,CAAC,OAAO,CAAC;IAWrC;;OAEG;IACG,aAAa,IAAI,OAAO,CAAC,UAAU,CAAC;IAuC1C,mDAAmD;IAC7C,IAAI,CAAC,UAAU,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAIlE,8CAA8C;IACxC,OAAO,CAAC,QAAQ,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAIlE,kEAAkE;IAClE,IAAI,iBAAiB,IAAI,OAAO,CAE/B;CACF"}
@@ -0,0 +1,124 @@
1
+ "use strict";
2
+ /**
3
+ * ADB Client - Executes ADB commands against Android devices/emulators.
4
+ *
5
+ * Provides a typed interface around the `adb` CLI for shell commands,
6
+ * device info queries, and general command execution.
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.AdbClient = void 0;
10
+ const node_child_process_1 = require("node:child_process");
11
+ const node_util_1 = require("node:util");
12
+ const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
13
+ /** Maximum time (ms) to wait for an ADB command before killing it. */
14
+ const DEFAULT_TIMEOUT_MS = 30_000;
15
+ class AdbClient {
16
+ adbPath;
17
+ device;
18
+ connected = false;
19
+ constructor(adbPath, device) {
20
+ this.adbPath = adbPath;
21
+ this.device = device;
22
+ }
23
+ /**
24
+ * Execute an arbitrary ADB command (not a shell command).
25
+ * @param args - Arguments passed directly to `adb -s <device> ...args`.
26
+ * @param timeoutMs - Optional timeout override.
27
+ * @returns stdout of the command.
28
+ */
29
+ async exec(args, timeoutMs = DEFAULT_TIMEOUT_MS) {
30
+ const fullArgs = ["-s", this.device, ...args];
31
+ try {
32
+ const { stdout } = await execFileAsync(this.adbPath, fullArgs, {
33
+ timeout: timeoutMs,
34
+ maxBuffer: 10 * 1024 * 1024, // 10 MB
35
+ });
36
+ this.connected = true;
37
+ return stdout;
38
+ }
39
+ catch (error) {
40
+ const msg = error instanceof Error ? error.message : String(error);
41
+ if (msg.includes("device") && msg.includes("not found")) {
42
+ this.connected = false;
43
+ }
44
+ throw new Error(`ADB command failed [adb ${fullArgs.join(" ")}]: ${msg}`);
45
+ }
46
+ }
47
+ /**
48
+ * Execute a shell command on the device.
49
+ * @param command - Shell command string to run via `adb shell`.
50
+ * @param timeoutMs - Optional timeout override.
51
+ */
52
+ async shell(command, timeoutMs = DEFAULT_TIMEOUT_MS) {
53
+ return this.exec(["shell", command], timeoutMs);
54
+ }
55
+ /**
56
+ * Check whether the device is reachable.
57
+ */
58
+ async isConnected() {
59
+ try {
60
+ const output = await this.exec(["get-state"], 5_000);
61
+ this.connected = output.trim() === "device";
62
+ return this.connected;
63
+ }
64
+ catch {
65
+ this.connected = false;
66
+ return false;
67
+ }
68
+ }
69
+ /**
70
+ * Retrieve structured information about the connected device.
71
+ */
72
+ async getDeviceInfo() {
73
+ const prop = async (key) => {
74
+ try {
75
+ return (await this.shell(`getprop ${key}`)).trim();
76
+ }
77
+ catch {
78
+ return "unknown";
79
+ }
80
+ };
81
+ const screenSize = await (async () => {
82
+ try {
83
+ const raw = await this.shell("wm size");
84
+ const match = raw.match(/(\d+x\d+)/);
85
+ return match ? match[1] : "unknown";
86
+ }
87
+ catch {
88
+ return "unknown";
89
+ }
90
+ })();
91
+ const density = await (async () => {
92
+ try {
93
+ const raw = await this.shell("wm density");
94
+ const match = raw.match(/(\d+)/);
95
+ return match ? match[1] : "unknown";
96
+ }
97
+ catch {
98
+ return "unknown";
99
+ }
100
+ })();
101
+ return {
102
+ serial: this.device,
103
+ model: await prop("ro.product.model"),
104
+ androidVersion: await prop("ro.build.version.release"),
105
+ sdkVersion: await prop("ro.build.version.sdk"),
106
+ screenSize,
107
+ density,
108
+ };
109
+ }
110
+ /** Pull a file from the device to a local path. */
111
+ async pull(remotePath, localPath) {
112
+ return this.exec(["pull", remotePath, localPath]);
113
+ }
114
+ /** Forward a TCP port from host to device. */
115
+ async forward(hostPort, devicePort) {
116
+ await this.exec(["forward", `tcp:${hostPort}`, `tcp:${devicePort}`]);
117
+ }
118
+ /** Get the cached connection state (does not ping the device). */
119
+ get isDeviceConnected() {
120
+ return this.connected;
121
+ }
122
+ }
123
+ exports.AdbClient = AdbClient;
124
+ //# sourceMappingURL=adb-client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adb-client.test.d.ts","sourceRoot":"","sources":["../../src/transport/adb-client.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,153 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const vitest_1 = require("vitest");
4
+ // vi.hoisted runs before vi.mock hoisting, so the fn is available in mock factories
5
+ const mockExecFileAsync = vitest_1.vi.hoisted(() => vitest_1.vi.fn());
6
+ vitest_1.vi.mock("node:child_process", () => ({
7
+ execFile: vitest_1.vi.fn(),
8
+ }));
9
+ vitest_1.vi.mock("node:util", () => ({
10
+ promisify: () => mockExecFileAsync,
11
+ }));
12
+ const adb_client_js_1 = require("./adb-client.js");
13
+ (0, vitest_1.describe)("AdbClient", () => {
14
+ let client;
15
+ (0, vitest_1.beforeEach)(() => {
16
+ vitest_1.vi.clearAllMocks();
17
+ client = new adb_client_js_1.AdbClient("/usr/bin/adb", "emulator-5554");
18
+ });
19
+ (0, vitest_1.describe)("exec()", () => {
20
+ (0, vitest_1.it)("runs adb with correct device serial and args", async () => {
21
+ mockExecFileAsync.mockResolvedValue({ stdout: "ok\n" });
22
+ await client.exec(["shell", "ls"]);
23
+ (0, vitest_1.expect)(mockExecFileAsync).toHaveBeenCalledWith("/usr/bin/adb", ["-s", "emulator-5554", "shell", "ls"], { timeout: 30_000, maxBuffer: 10 * 1024 * 1024 });
24
+ });
25
+ (0, vitest_1.it)("returns stdout from the command", async () => {
26
+ mockExecFileAsync.mockResolvedValue({ stdout: "file1.txt\nfile2.txt\n" });
27
+ const result = await client.exec(["shell", "ls"]);
28
+ (0, vitest_1.expect)(result).toBe("file1.txt\nfile2.txt\n");
29
+ });
30
+ (0, vitest_1.it)("throws with descriptive error on failure", async () => {
31
+ mockExecFileAsync.mockRejectedValue(new Error("command not found"));
32
+ await (0, vitest_1.expect)(client.exec(["shell", "badcmd"])).rejects.toThrow("ADB command failed [adb -s emulator-5554 shell badcmd]: command not found");
33
+ });
34
+ (0, vitest_1.it)("sets connected=true on success", async () => {
35
+ mockExecFileAsync.mockResolvedValue({ stdout: "ok" });
36
+ (0, vitest_1.expect)(client.isDeviceConnected).toBe(false);
37
+ await client.exec(["get-state"]);
38
+ (0, vitest_1.expect)(client.isDeviceConnected).toBe(true);
39
+ });
40
+ (0, vitest_1.it)('sets connected=false on "device not found" error', async () => {
41
+ // First succeed to set connected=true
42
+ mockExecFileAsync.mockResolvedValueOnce({ stdout: "ok" });
43
+ await client.exec(["get-state"]);
44
+ (0, vitest_1.expect)(client.isDeviceConnected).toBe(true);
45
+ // Then fail with device not found
46
+ mockExecFileAsync.mockRejectedValue(new Error("error: device 'emulator-5554' not found"));
47
+ await (0, vitest_1.expect)(client.exec(["get-state"])).rejects.toThrow();
48
+ (0, vitest_1.expect)(client.isDeviceConnected).toBe(false);
49
+ });
50
+ (0, vitest_1.it)("respects custom timeout", async () => {
51
+ mockExecFileAsync.mockResolvedValue({ stdout: "" });
52
+ await client.exec(["shell", "slow-cmd"], 5_000);
53
+ (0, vitest_1.expect)(mockExecFileAsync).toHaveBeenCalledWith("/usr/bin/adb", ["-s", "emulator-5554", "shell", "slow-cmd"], { timeout: 5_000, maxBuffer: 10 * 1024 * 1024 });
54
+ });
55
+ });
56
+ (0, vitest_1.describe)("shell()", () => {
57
+ (0, vitest_1.it)('delegates to exec with "shell" prefix', async () => {
58
+ mockExecFileAsync.mockResolvedValue({ stdout: "result\n" });
59
+ const result = await client.shell("getprop ro.product.model");
60
+ (0, vitest_1.expect)(mockExecFileAsync).toHaveBeenCalledWith("/usr/bin/adb", ["-s", "emulator-5554", "shell", "getprop ro.product.model"], vitest_1.expect.objectContaining({ timeout: 30_000 }));
61
+ (0, vitest_1.expect)(result).toBe("result\n");
62
+ });
63
+ });
64
+ (0, vitest_1.describe)("isConnected()", () => {
65
+ (0, vitest_1.it)('returns true when device responds "device"', async () => {
66
+ mockExecFileAsync.mockResolvedValue({ stdout: "device\n" });
67
+ const result = await client.isConnected();
68
+ (0, vitest_1.expect)(result).toBe(true);
69
+ (0, vitest_1.expect)(client.isDeviceConnected).toBe(true);
70
+ });
71
+ (0, vitest_1.it)("returns false on timeout/error", async () => {
72
+ mockExecFileAsync.mockRejectedValue(new Error("timeout"));
73
+ const result = await client.isConnected();
74
+ (0, vitest_1.expect)(result).toBe(false);
75
+ (0, vitest_1.expect)(client.isDeviceConnected).toBe(false);
76
+ });
77
+ (0, vitest_1.it)("returns false when device state is not 'device'", async () => {
78
+ mockExecFileAsync.mockResolvedValue({ stdout: "offline\n" });
79
+ const result = await client.isConnected();
80
+ (0, vitest_1.expect)(result).toBe(false);
81
+ });
82
+ (0, vitest_1.it)("calls get-state with 5s timeout", async () => {
83
+ mockExecFileAsync.mockResolvedValue({ stdout: "device\n" });
84
+ await client.isConnected();
85
+ (0, vitest_1.expect)(mockExecFileAsync).toHaveBeenCalledWith("/usr/bin/adb", ["-s", "emulator-5554", "get-state"], vitest_1.expect.objectContaining({ timeout: 5_000 }));
86
+ });
87
+ });
88
+ (0, vitest_1.describe)("getDeviceInfo()", () => {
89
+ (0, vitest_1.it)("returns structured device info", async () => {
90
+ mockExecFileAsync.mockImplementation((_path, args) => {
91
+ const cmd = args.join(" ");
92
+ if (cmd.includes("getprop ro.product.model"))
93
+ return Promise.resolve({ stdout: "Pixel 7\n" });
94
+ if (cmd.includes("getprop ro.build.version.release"))
95
+ return Promise.resolve({ stdout: "14\n" });
96
+ if (cmd.includes("getprop ro.build.version.sdk"))
97
+ return Promise.resolve({ stdout: "34\n" });
98
+ if (cmd.includes("wm size"))
99
+ return Promise.resolve({ stdout: "Physical size: 1080x2400\n" });
100
+ if (cmd.includes("wm density"))
101
+ return Promise.resolve({ stdout: "Physical density: 420\n" });
102
+ return Promise.resolve({ stdout: "" });
103
+ });
104
+ const info = await client.getDeviceInfo();
105
+ (0, vitest_1.expect)(info).toEqual({
106
+ serial: "emulator-5554",
107
+ model: "Pixel 7",
108
+ androidVersion: "14",
109
+ sdkVersion: "34",
110
+ screenSize: "1080x2400",
111
+ density: "420",
112
+ });
113
+ });
114
+ (0, vitest_1.it)('returns "unknown" for failed property reads', async () => {
115
+ mockExecFileAsync.mockRejectedValue(new Error("device offline"));
116
+ const info = await client.getDeviceInfo();
117
+ (0, vitest_1.expect)(info).toEqual({
118
+ serial: "emulator-5554",
119
+ model: "unknown",
120
+ androidVersion: "unknown",
121
+ sdkVersion: "unknown",
122
+ screenSize: "unknown",
123
+ density: "unknown",
124
+ });
125
+ });
126
+ });
127
+ (0, vitest_1.describe)("pull()", () => {
128
+ (0, vitest_1.it)("delegates to exec with correct args", async () => {
129
+ mockExecFileAsync.mockResolvedValue({ stdout: "1 file pulled.\n" });
130
+ const result = await client.pull("/sdcard/screenshot.png", "/tmp/shot.png");
131
+ (0, vitest_1.expect)(mockExecFileAsync).toHaveBeenCalledWith("/usr/bin/adb", ["-s", "emulator-5554", "pull", "/sdcard/screenshot.png", "/tmp/shot.png"], vitest_1.expect.objectContaining({ timeout: 30_000 }));
132
+ (0, vitest_1.expect)(result).toBe("1 file pulled.\n");
133
+ });
134
+ });
135
+ (0, vitest_1.describe)("forward()", () => {
136
+ (0, vitest_1.it)("delegates to exec with tcp format", async () => {
137
+ mockExecFileAsync.mockResolvedValue({ stdout: "" });
138
+ await client.forward(8081, 8081);
139
+ (0, vitest_1.expect)(mockExecFileAsync).toHaveBeenCalledWith("/usr/bin/adb", ["-s", "emulator-5554", "forward", "tcp:8081", "tcp:8081"], vitest_1.expect.objectContaining({ timeout: 30_000 }));
140
+ });
141
+ });
142
+ (0, vitest_1.describe)("isDeviceConnected getter", () => {
143
+ (0, vitest_1.it)("returns cached state (false initially)", () => {
144
+ (0, vitest_1.expect)(client.isDeviceConnected).toBe(false);
145
+ });
146
+ (0, vitest_1.it)("returns true after a successful exec", async () => {
147
+ mockExecFileAsync.mockResolvedValue({ stdout: "ok" });
148
+ await client.exec(["version"]);
149
+ (0, vitest_1.expect)(client.isDeviceConnected).toBe(true);
150
+ });
151
+ });
152
+ });
153
+ //# sourceMappingURL=adb-client.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"agent-client.d.ts","sourceRoot":"","sources":["../../src/transport/agent-client.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAkBH,qBAAa,WAAW;IACtB,OAAO,CAAC,MAAM,CAAuB;IACrC,OAAO,CAAC,SAAS,CAAS;IAC1B,OAAO,CAAC,MAAM,CAAK;IACnB,OAAO,CAAC,OAAO,CAAqC;IACpD,OAAO,CAAC,MAAM,CAAM;IACpB,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAS;IAC9B,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAS;gBAElB,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM;IAKtC;;;OAGG;IACH,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAyCxB;;;;;OAKG;IACH,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,SAAS,SAAiB,GAAG,OAAO,CAAC,OAAO,CAAC;IAiCpG;;OAEG;IACH,UAAU,IAAI,IAAI;IAOlB;;OAEG;IACH,WAAW,IAAI,OAAO;IAMtB,+DAA+D;IAC/D,OAAO,CAAC,aAAa;IAgCrB,iEAAiE;IACjE,OAAO,CAAC,gBAAgB;CAUzB"}
@@ -0,0 +1,157 @@
1
+ "use strict";
2
+ /**
3
+ * Agent Client - Communicates with the on-device EmuDebug agent via TCP JSON-RPC.
4
+ *
5
+ * The agent runs inside the target app process and provides deep introspection
6
+ * capabilities (React Native component tree, app state, network interception, etc.)
7
+ * that are not available through ADB alone.
8
+ *
9
+ * Protocol: newline-delimited JSON-RPC 2.0 over a raw TCP socket.
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.AgentClient = void 0;
13
+ const node_net_1 = require("node:net");
14
+ /** Default timeout for a single RPC call (ms). */
15
+ const RPC_TIMEOUT_MS = 15_000;
16
+ /** Default TCP connection timeout (ms). */
17
+ const CONNECT_TIMEOUT_MS = 5_000;
18
+ class AgentClient {
19
+ socket = null;
20
+ connected = false;
21
+ nextId = 1;
22
+ pending = new Map();
23
+ buffer = "";
24
+ host;
25
+ port;
26
+ constructor(host, port) {
27
+ this.host = host;
28
+ this.port = port;
29
+ }
30
+ /**
31
+ * Open a TCP connection to the on-device agent.
32
+ * Resolves when the connection is established, rejects on timeout or error.
33
+ */
34
+ connect() {
35
+ return new Promise((resolve, reject) => {
36
+ if (this.connected && this.socket) {
37
+ resolve();
38
+ return;
39
+ }
40
+ const socket = (0, node_net_1.createConnection)({ host: this.host, port: this.port });
41
+ const connectTimer = setTimeout(() => {
42
+ socket.destroy();
43
+ reject(new Error(`Agent connection timed out after ${CONNECT_TIMEOUT_MS}ms`));
44
+ }, CONNECT_TIMEOUT_MS);
45
+ socket.on("connect", () => {
46
+ clearTimeout(connectTimer);
47
+ this.socket = socket;
48
+ this.connected = true;
49
+ this.buffer = "";
50
+ resolve();
51
+ });
52
+ socket.on("data", (chunk) => {
53
+ this.buffer += chunk.toString("utf-8");
54
+ this.processBuffer();
55
+ });
56
+ socket.on("close", () => {
57
+ this.handleDisconnect("Socket closed");
58
+ });
59
+ socket.on("error", (err) => {
60
+ clearTimeout(connectTimer);
61
+ this.handleDisconnect(err.message);
62
+ if (!this.connected) {
63
+ reject(new Error(`Agent connection failed: ${err.message}`));
64
+ }
65
+ });
66
+ });
67
+ }
68
+ /**
69
+ * Send a JSON-RPC call to the agent and await the response.
70
+ * @param method - The RPC method name.
71
+ * @param params - Optional parameters object.
72
+ * @param timeoutMs - Per-call timeout.
73
+ */
74
+ call(method, params, timeoutMs = RPC_TIMEOUT_MS) {
75
+ return new Promise((resolve, reject) => {
76
+ if (!this.connected || !this.socket) {
77
+ reject(new Error("Agent not connected. Call connect() first."));
78
+ return;
79
+ }
80
+ const id = this.nextId++;
81
+ const request = {
82
+ jsonrpc: "2.0",
83
+ id,
84
+ method,
85
+ params: params ?? {},
86
+ };
87
+ const timer = setTimeout(() => {
88
+ this.pending.delete(id);
89
+ reject(new Error(`RPC call '${method}' timed out after ${timeoutMs}ms`));
90
+ }, timeoutMs);
91
+ this.pending.set(id, { resolve, reject, timer });
92
+ const payload = JSON.stringify(request) + "\n";
93
+ this.socket.write(payload, "utf-8", (err) => {
94
+ if (err) {
95
+ clearTimeout(timer);
96
+ this.pending.delete(id);
97
+ reject(new Error(`Failed to send RPC: ${err.message}`));
98
+ }
99
+ });
100
+ });
101
+ }
102
+ /**
103
+ * Gracefully disconnect from the agent.
104
+ */
105
+ disconnect() {
106
+ if (this.socket) {
107
+ this.socket.destroy();
108
+ }
109
+ this.handleDisconnect("Disconnect requested");
110
+ }
111
+ /**
112
+ * Check whether the agent TCP connection is alive.
113
+ */
114
+ isConnected() {
115
+ return this.connected;
116
+ }
117
+ // --- Private helpers ---
118
+ /** Process newline-delimited JSON messages from the buffer. */
119
+ processBuffer() {
120
+ let newlineIdx;
121
+ while ((newlineIdx = this.buffer.indexOf("\n")) !== -1) {
122
+ const line = this.buffer.slice(0, newlineIdx).trim();
123
+ this.buffer = this.buffer.slice(newlineIdx + 1);
124
+ if (!line)
125
+ continue;
126
+ try {
127
+ const msg = JSON.parse(line);
128
+ if (msg.id != null && this.pending.has(msg.id)) {
129
+ const req = this.pending.get(msg.id);
130
+ this.pending.delete(msg.id);
131
+ clearTimeout(req.timer);
132
+ if (msg.error) {
133
+ req.reject(new Error(`RPC error (${msg.error.code}): ${msg.error.message}`));
134
+ }
135
+ else {
136
+ req.resolve(msg.result);
137
+ }
138
+ }
139
+ }
140
+ catch {
141
+ // Ignore unparseable lines
142
+ }
143
+ }
144
+ }
145
+ /** Clean up connection state and reject all pending requests. */
146
+ handleDisconnect(reason) {
147
+ this.connected = false;
148
+ this.socket = null;
149
+ for (const [id, req] of this.pending) {
150
+ clearTimeout(req.timer);
151
+ req.reject(new Error(`Agent disconnected: ${reason}`));
152
+ this.pending.delete(id);
153
+ }
154
+ }
155
+ }
156
+ exports.AgentClient = AgentClient;
157
+ //# sourceMappingURL=agent-client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"agent-client.test.d.ts","sourceRoot":"","sources":["../../src/transport/agent-client.test.ts"],"names":[],"mappings":""}