@flrande/browserctl 0.1.0-dev.7.1 → 0.1.0-dev.8.1

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.
@@ -0,0 +1,386 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const { callDaemonToolMock } = vi.hoisted(() => ({
4
+ callDaemonToolMock: vi.fn()
5
+ }));
6
+
7
+ vi.mock("../daemon-client", () => ({
8
+ callDaemonTool: callDaemonToolMock
9
+ }));
10
+
11
+ import { DAEMON_STARTUP_ARGUMENT } from "./common";
12
+ import { runA11ySnapshotCommand } from "./a11y-snapshot";
13
+ import { runActCommand } from "./act";
14
+ import { runConsoleListCommand } from "./console-list";
15
+ import { runCookieClearCommand } from "./cookie-clear";
16
+ import { runCookieGetCommand } from "./cookie-get";
17
+ import { runCookieSetCommand } from "./cookie-set";
18
+ import { runDialogArmCommand } from "./dialog-arm";
19
+ import { runDomQueryAllCommand } from "./dom-query-all";
20
+ import { runDomQueryCommand } from "./dom-query";
21
+ import { runDownloadTriggerCommand } from "./download-trigger";
22
+ import { runDownloadWaitCommand } from "./download-wait";
23
+ import { runElementScreenshotCommand } from "./element-screenshot";
24
+ import { runFrameListCommand } from "./frame-list";
25
+ import { runFrameSnapshotCommand } from "./frame-snapshot";
26
+ import { runNetworkWaitForCommand } from "./network-wait-for";
27
+ import { runProfileListCommand } from "./profile-list";
28
+ import { runProfileUseCommand } from "./profile-use";
29
+ import { runResponseBodyCommand } from "./response-body";
30
+ import { runScreenshotCommand } from "./screenshot";
31
+ import { runSnapshotCommand } from "./snapshot";
32
+ import { runStatusCommand } from "./status";
33
+ import { runStorageGetCommand } from "./storage-get";
34
+ import { runStorageSetCommand } from "./storage-set";
35
+ import { runTabCloseCommand } from "./tab-close";
36
+ import { runTabFocusCommand } from "./tab-focus";
37
+ import { runTabOpenCommand } from "./tab-open";
38
+ import { runTabsCommand } from "./tabs";
39
+ import { runUploadArmCommand } from "./upload-arm";
40
+
41
+ type WrapperCase = {
42
+ name: string;
43
+ run: () => Promise<unknown>;
44
+ expectedTool: string;
45
+ expectedArgs: Record<string, unknown>;
46
+ };
47
+
48
+ const BASE_ARGS = {
49
+ sessionId: "cli:local"
50
+ };
51
+
52
+ const CASES: WrapperCase[] = [
53
+ {
54
+ name: "status",
55
+ run: async () => await runStatusCommand(),
56
+ expectedTool: "browser.status",
57
+ expectedArgs: { ...BASE_ARGS }
58
+ },
59
+ {
60
+ name: "tabs",
61
+ run: async () => await runTabsCommand(),
62
+ expectedTool: "browser.tab.list",
63
+ expectedArgs: { ...BASE_ARGS }
64
+ },
65
+ {
66
+ name: "profile-list",
67
+ run: async () => await runProfileListCommand(),
68
+ expectedTool: "browser.profile.list",
69
+ expectedArgs: { ...BASE_ARGS }
70
+ },
71
+ {
72
+ name: "profile-use",
73
+ run: async () => await runProfileUseCommand(["managed"]),
74
+ expectedTool: "browser.profile.use",
75
+ expectedArgs: {
76
+ ...BASE_ARGS,
77
+ profile: "managed"
78
+ }
79
+ },
80
+ {
81
+ name: "tab-open",
82
+ run: async () => await runTabOpenCommand(["https://example.com"]),
83
+ expectedTool: "browser.tab.open",
84
+ expectedArgs: {
85
+ ...BASE_ARGS,
86
+ url: "https://example.com"
87
+ }
88
+ },
89
+ {
90
+ name: "tab-focus",
91
+ run: async () => await runTabFocusCommand(["target:1"]),
92
+ expectedTool: "browser.tab.focus",
93
+ expectedArgs: {
94
+ ...BASE_ARGS,
95
+ targetId: "target:1"
96
+ }
97
+ },
98
+ {
99
+ name: "tab-close",
100
+ run: async () => await runTabCloseCommand(["target:1"]),
101
+ expectedTool: "browser.tab.close",
102
+ expectedArgs: {
103
+ ...BASE_ARGS,
104
+ targetId: "target:1"
105
+ }
106
+ },
107
+ {
108
+ name: "snapshot",
109
+ run: async () => await runSnapshotCommand(["target:1"]),
110
+ expectedTool: "browser.snapshot",
111
+ expectedArgs: {
112
+ ...BASE_ARGS,
113
+ targetId: "target:1"
114
+ }
115
+ },
116
+ {
117
+ name: "screenshot",
118
+ run: async () => await runScreenshotCommand(["target:1"]),
119
+ expectedTool: "browser.screenshot",
120
+ expectedArgs: {
121
+ ...BASE_ARGS,
122
+ targetId: "target:1"
123
+ }
124
+ },
125
+ {
126
+ name: "dom-query",
127
+ run: async () => await runDomQueryCommand(["target:1", "#root"]),
128
+ expectedTool: "browser.dom.query",
129
+ expectedArgs: {
130
+ ...BASE_ARGS,
131
+ targetId: "target:1",
132
+ selector: "#root"
133
+ }
134
+ },
135
+ {
136
+ name: "dom-query-all",
137
+ run: async () => await runDomQueryAllCommand(["target:1", ".item"]),
138
+ expectedTool: "browser.dom.queryAll",
139
+ expectedArgs: {
140
+ ...BASE_ARGS,
141
+ targetId: "target:1",
142
+ selector: ".item"
143
+ }
144
+ },
145
+ {
146
+ name: "element-screenshot",
147
+ run: async () => await runElementScreenshotCommand(["target:1", "#hero"]),
148
+ expectedTool: "browser.element.screenshot",
149
+ expectedArgs: {
150
+ ...BASE_ARGS,
151
+ targetId: "target:1",
152
+ selector: "#hero"
153
+ }
154
+ },
155
+ {
156
+ name: "a11y-snapshot",
157
+ run: async () => await runA11ySnapshotCommand(["target:1", " #main "]),
158
+ expectedTool: "browser.a11y.snapshot",
159
+ expectedArgs: {
160
+ ...BASE_ARGS,
161
+ targetId: "target:1",
162
+ selector: "#main"
163
+ }
164
+ },
165
+ {
166
+ name: "network-wait-for",
167
+ run: async () =>
168
+ await runNetworkWaitForCommand([
169
+ "target:1",
170
+ "/api/items",
171
+ "--method",
172
+ "post",
173
+ "--status",
174
+ "201",
175
+ "--timeout-ms",
176
+ "3000",
177
+ "--poll-ms",
178
+ "50"
179
+ ]),
180
+ expectedTool: "browser.network.waitFor",
181
+ expectedArgs: {
182
+ ...BASE_ARGS,
183
+ targetId: "target:1",
184
+ urlPattern: "/api/items",
185
+ method: "POST",
186
+ status: 201,
187
+ timeoutMs: 3000,
188
+ pollMs: 50
189
+ }
190
+ },
191
+ {
192
+ name: "cookie-get",
193
+ run: async () => await runCookieGetCommand(["target:1", " sid "]),
194
+ expectedTool: "browser.cookie.get",
195
+ expectedArgs: {
196
+ ...BASE_ARGS,
197
+ targetId: "target:1",
198
+ name: "sid"
199
+ }
200
+ },
201
+ {
202
+ name: "cookie-set",
203
+ run: async () => await runCookieSetCommand(["target:1", "sid", "abc", " https://example.com "]),
204
+ expectedTool: "browser.cookie.set",
205
+ expectedArgs: {
206
+ ...BASE_ARGS,
207
+ targetId: "target:1",
208
+ name: "sid",
209
+ value: "abc",
210
+ url: "https://example.com"
211
+ }
212
+ },
213
+ {
214
+ name: "cookie-clear",
215
+ run: async () => await runCookieClearCommand(["target:1", " sid "]),
216
+ expectedTool: "browser.cookie.clear",
217
+ expectedArgs: {
218
+ ...BASE_ARGS,
219
+ targetId: "target:1",
220
+ name: "sid"
221
+ }
222
+ },
223
+ {
224
+ name: "storage-get",
225
+ run: async () => await runStorageGetCommand(["target:1", "local", "theme"]),
226
+ expectedTool: "browser.storage.get",
227
+ expectedArgs: {
228
+ ...BASE_ARGS,
229
+ targetId: "target:1",
230
+ scope: "local",
231
+ key: "theme"
232
+ }
233
+ },
234
+ {
235
+ name: "storage-set",
236
+ run: async () => await runStorageSetCommand(["target:1", "session", "token", "abc"]),
237
+ expectedTool: "browser.storage.set",
238
+ expectedArgs: {
239
+ ...BASE_ARGS,
240
+ targetId: "target:1",
241
+ scope: "session",
242
+ key: "token",
243
+ value: "abc"
244
+ }
245
+ },
246
+ {
247
+ name: "frame-list",
248
+ run: async () => await runFrameListCommand(["target:1"]),
249
+ expectedTool: "browser.frame.list",
250
+ expectedArgs: {
251
+ ...BASE_ARGS,
252
+ targetId: "target:1"
253
+ }
254
+ },
255
+ {
256
+ name: "frame-snapshot",
257
+ run: async () => await runFrameSnapshotCommand(["target:1", "frame:0"]),
258
+ expectedTool: "browser.frame.snapshot",
259
+ expectedArgs: {
260
+ ...BASE_ARGS,
261
+ targetId: "target:1",
262
+ frameId: "frame:0"
263
+ }
264
+ },
265
+ {
266
+ name: "act",
267
+ run: async () => await runActCommand(["click", "target:1"]),
268
+ expectedTool: "browser.act",
269
+ expectedArgs: {
270
+ ...BASE_ARGS,
271
+ targetId: "target:1",
272
+ action: {
273
+ type: "click"
274
+ }
275
+ }
276
+ },
277
+ {
278
+ name: "upload-arm",
279
+ run: async () => await runUploadArmCommand(["target:1", "a.txt", "b.txt"]),
280
+ expectedTool: "browser.upload.arm",
281
+ expectedArgs: {
282
+ ...BASE_ARGS,
283
+ targetId: "target:1",
284
+ files: ["a.txt", "b.txt"]
285
+ }
286
+ },
287
+ {
288
+ name: "dialog-arm",
289
+ run: async () => await runDialogArmCommand(["target:1"]),
290
+ expectedTool: "browser.dialog.arm",
291
+ expectedArgs: {
292
+ ...BASE_ARGS,
293
+ targetId: "target:1"
294
+ }
295
+ },
296
+ {
297
+ name: "download-trigger",
298
+ run: async () => await runDownloadTriggerCommand(["target:1"]),
299
+ expectedTool: "browser.download.trigger",
300
+ expectedArgs: {
301
+ ...BASE_ARGS,
302
+ targetId: "target:1"
303
+ }
304
+ },
305
+ {
306
+ name: "download-wait",
307
+ run: async () => await runDownloadWaitCommand(["target:1", "downloads/file.bin"]),
308
+ expectedTool: "browser.download.wait",
309
+ expectedArgs: {
310
+ ...BASE_ARGS,
311
+ targetId: "target:1",
312
+ path: "downloads/file.bin"
313
+ }
314
+ },
315
+ {
316
+ name: "console-list",
317
+ run: async () => await runConsoleListCommand(["target:1"]),
318
+ expectedTool: "browser.console.list",
319
+ expectedArgs: {
320
+ ...BASE_ARGS,
321
+ targetId: "target:1"
322
+ }
323
+ },
324
+ {
325
+ name: "response-body",
326
+ run: async () => await runResponseBodyCommand(["target:1", "request:1"]),
327
+ expectedTool: "browser.network.responseBody",
328
+ expectedArgs: {
329
+ ...BASE_ARGS,
330
+ targetId: "target:1",
331
+ requestId: "request:1"
332
+ }
333
+ }
334
+ ];
335
+
336
+ afterEach(() => {
337
+ callDaemonToolMock.mockReset();
338
+ delete process.env.BROWSERCTL_AUTH_TOKEN;
339
+ delete process.env.BROWSERCTL_BROWSER;
340
+ });
341
+
342
+ describe("command wrappers", () => {
343
+ it.each(CASES)("forwards $name to daemon tool", async ({ run, expectedTool, expectedArgs }) => {
344
+ callDaemonToolMock.mockResolvedValue({ ok: true });
345
+
346
+ await run();
347
+
348
+ expect(callDaemonToolMock).toHaveBeenCalledTimes(1);
349
+ expect(callDaemonToolMock).toHaveBeenCalledWith(expectedTool, expectedArgs);
350
+ });
351
+
352
+ it("forwards session/profile/token/browser context switches", async () => {
353
+ callDaemonToolMock.mockResolvedValue({ ok: true });
354
+
355
+ await runStatusCommand([
356
+ "--session",
357
+ "session:matrix",
358
+ "--profile",
359
+ "chrome-relay",
360
+ "--token",
361
+ "cli-token",
362
+ "--browser",
363
+ "edge"
364
+ ]);
365
+
366
+ expect(callDaemonToolMock).toHaveBeenCalledTimes(1);
367
+ expect(callDaemonToolMock).toHaveBeenCalledWith("browser.status", {
368
+ sessionId: "session:matrix",
369
+ profile: "chrome-relay",
370
+ authToken: "cli-token",
371
+ [DAEMON_STARTUP_ARGUMENT]: {
372
+ managedLocal: {
373
+ browserName: "chromium",
374
+ channel: "msedge"
375
+ }
376
+ }
377
+ });
378
+ });
379
+
380
+ it("throws for upload-arm when files are missing", async () => {
381
+ await expect(runUploadArmCommand(["target:1"]))
382
+ .rejects.toThrow("Missing required argument: files.");
383
+
384
+ expect(callDaemonToolMock).not.toHaveBeenCalled();
385
+ });
386
+ });
@@ -0,0 +1,90 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const { callDaemonToolMock } = vi.hoisted(() => ({
4
+ callDaemonToolMock: vi.fn()
5
+ }));
6
+
7
+ vi.mock("../daemon-client", () => ({
8
+ callDaemonTool: callDaemonToolMock
9
+ }));
10
+
11
+ import { runNetworkWaitForCommand } from "./network-wait-for";
12
+
13
+ afterEach(() => {
14
+ callDaemonToolMock.mockReset();
15
+ });
16
+
17
+ describe("runNetworkWaitForCommand", () => {
18
+ it("supports positional timeout shorthand", async () => {
19
+ callDaemonToolMock.mockResolvedValue({ ok: true });
20
+
21
+ await runNetworkWaitForCommand(["target:1", "/api", "2500"]);
22
+
23
+ expect(callDaemonToolMock).toHaveBeenCalledTimes(1);
24
+ expect(callDaemonToolMock).toHaveBeenCalledWith("browser.network.waitFor", {
25
+ sessionId: "cli:local",
26
+ targetId: "target:1",
27
+ urlPattern: "/api",
28
+ timeoutMs: 2500
29
+ });
30
+ });
31
+
32
+ it.each([
33
+ {
34
+ label: "--method",
35
+ args: ["target:1", "/api", "--method"],
36
+ message: "Missing value for --method."
37
+ },
38
+ {
39
+ label: "--status",
40
+ args: ["target:1", "/api", "--status"],
41
+ message: "Missing value for --status."
42
+ },
43
+ {
44
+ label: "--timeout-ms",
45
+ args: ["target:1", "/api", "--timeout-ms"],
46
+ message: "Missing value for --timeout-ms."
47
+ },
48
+ {
49
+ label: "--poll-ms",
50
+ args: ["target:1", "/api", "--poll-ms"],
51
+ message: "Missing value for --poll-ms."
52
+ }
53
+ ])("throws when $label is missing a value", async ({ args, message }) => {
54
+ await expect(runNetworkWaitForCommand(args)).rejects.toThrow(message);
55
+ expect(callDaemonToolMock).not.toHaveBeenCalled();
56
+ });
57
+
58
+ it.each([
59
+ {
60
+ label: "status <= 0",
61
+ args: ["target:1", "/api", "--status", "0"],
62
+ message: "--status must be a positive integer."
63
+ },
64
+ {
65
+ label: "timeout <= 0",
66
+ args: ["target:1", "/api", "--timeout-ms", "-10"],
67
+ message: "--timeout-ms must be a positive integer."
68
+ },
69
+ {
70
+ label: "poll <= 0",
71
+ args: ["target:1", "/api", "--poll-ms", "0"],
72
+ message: "--poll-ms must be a positive integer."
73
+ },
74
+ {
75
+ label: "positional timeout <= 0",
76
+ args: ["target:1", "/api", "0"],
77
+ message: "timeoutMs must be a positive integer."
78
+ }
79
+ ])("throws on non-positive numeric option: $label", async ({ args, message }) => {
80
+ await expect(runNetworkWaitForCommand(args)).rejects.toThrow(message);
81
+ expect(callDaemonToolMock).not.toHaveBeenCalled();
82
+ });
83
+
84
+ it("throws on unknown option token", async () => {
85
+ await expect(runNetworkWaitForCommand(["target:1", "/api", "1200", "--unknown"]))
86
+ .rejects.toThrow("Unknown network-wait-for option: --unknown");
87
+
88
+ expect(callDaemonToolMock).not.toHaveBeenCalled();
89
+ });
90
+ });
@@ -0,0 +1,256 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import * as a11ySnapshotCommand from "./commands/a11y-snapshot";
4
+ import * as consoleListCommand from "./commands/console-list";
5
+ import * as cookieClearCommand from "./commands/cookie-clear";
6
+ import * as cookieGetCommand from "./commands/cookie-get";
7
+ import * as cookieSetCommand from "./commands/cookie-set";
8
+ import * as daemonClient from "./daemon-client";
9
+ import * as dialogArmCommand from "./commands/dialog-arm";
10
+ import * as domQueryAllCommand from "./commands/dom-query-all";
11
+ import * as domQueryCommand from "./commands/dom-query";
12
+ import * as downloadTriggerCommand from "./commands/download-trigger";
13
+ import * as downloadWaitCommand from "./commands/download-wait";
14
+ import * as elementScreenshotCommand from "./commands/element-screenshot";
15
+ import * as frameListCommand from "./commands/frame-list";
16
+ import * as frameSnapshotCommand from "./commands/frame-snapshot";
17
+ import * as networkWaitForCommand from "./commands/network-wait-for";
18
+ import * as profileListCommand from "./commands/profile-list";
19
+ import * as profileUseCommand from "./commands/profile-use";
20
+ import * as responseBodyCommand from "./commands/response-body";
21
+ import * as snapshotCommand from "./commands/snapshot";
22
+ import * as storageGetCommand from "./commands/storage-get";
23
+ import * as storageSetCommand from "./commands/storage-set";
24
+ import * as tabCloseCommand from "./commands/tab-close";
25
+ import * as tabFocusCommand from "./commands/tab-focus";
26
+ import * as tabOpenCommand from "./commands/tab-open";
27
+ import * as uploadArmCommand from "./commands/upload-arm";
28
+ import { EXIT_CODES, runCli } from "./main";
29
+
30
+ function createIoCapture() {
31
+ const state = {
32
+ stdout: "",
33
+ stderr: ""
34
+ };
35
+
36
+ return {
37
+ state,
38
+ io: {
39
+ stdout: {
40
+ write(content: string) {
41
+ state.stdout += content;
42
+ }
43
+ },
44
+ stderr: {
45
+ write(content: string) {
46
+ state.stderr += content;
47
+ }
48
+ }
49
+ }
50
+ };
51
+ }
52
+
53
+ type DispatchCase = {
54
+ command: string;
55
+ commandArgs: string[];
56
+ moduleRef: Record<string, unknown>;
57
+ handlerName: string;
58
+ };
59
+
60
+ const COMMAND_DISPATCH_CASES: DispatchCase[] = [
61
+ {
62
+ command: "a11y-snapshot",
63
+ commandArgs: ["target:1"],
64
+ moduleRef: a11ySnapshotCommand as unknown as Record<string, unknown>,
65
+ handlerName: "runA11ySnapshotCommand"
66
+ },
67
+ {
68
+ command: "console-list",
69
+ commandArgs: ["target:1"],
70
+ moduleRef: consoleListCommand as unknown as Record<string, unknown>,
71
+ handlerName: "runConsoleListCommand"
72
+ },
73
+ {
74
+ command: "cookie-clear",
75
+ commandArgs: ["target:1"],
76
+ moduleRef: cookieClearCommand as unknown as Record<string, unknown>,
77
+ handlerName: "runCookieClearCommand"
78
+ },
79
+ {
80
+ command: "cookie-get",
81
+ commandArgs: ["target:1"],
82
+ moduleRef: cookieGetCommand as unknown as Record<string, unknown>,
83
+ handlerName: "runCookieGetCommand"
84
+ },
85
+ {
86
+ command: "cookie-set",
87
+ commandArgs: ["target:1", "sid", "abc"],
88
+ moduleRef: cookieSetCommand as unknown as Record<string, unknown>,
89
+ handlerName: "runCookieSetCommand"
90
+ },
91
+ {
92
+ command: "dialog-arm",
93
+ commandArgs: ["target:1"],
94
+ moduleRef: dialogArmCommand as unknown as Record<string, unknown>,
95
+ handlerName: "runDialogArmCommand"
96
+ },
97
+ {
98
+ command: "dom-query",
99
+ commandArgs: ["target:1", "#root"],
100
+ moduleRef: domQueryCommand as unknown as Record<string, unknown>,
101
+ handlerName: "runDomQueryCommand"
102
+ },
103
+ {
104
+ command: "dom-query-all",
105
+ commandArgs: ["target:1", ".item"],
106
+ moduleRef: domQueryAllCommand as unknown as Record<string, unknown>,
107
+ handlerName: "runDomQueryAllCommand"
108
+ },
109
+ {
110
+ command: "download-trigger",
111
+ commandArgs: ["target:1"],
112
+ moduleRef: downloadTriggerCommand as unknown as Record<string, unknown>,
113
+ handlerName: "runDownloadTriggerCommand"
114
+ },
115
+ {
116
+ command: "download-wait",
117
+ commandArgs: ["target:1"],
118
+ moduleRef: downloadWaitCommand as unknown as Record<string, unknown>,
119
+ handlerName: "runDownloadWaitCommand"
120
+ },
121
+ {
122
+ command: "element-screenshot",
123
+ commandArgs: ["target:1", "#hero"],
124
+ moduleRef: elementScreenshotCommand as unknown as Record<string, unknown>,
125
+ handlerName: "runElementScreenshotCommand"
126
+ },
127
+ {
128
+ command: "frame-list",
129
+ commandArgs: ["target:1"],
130
+ moduleRef: frameListCommand as unknown as Record<string, unknown>,
131
+ handlerName: "runFrameListCommand"
132
+ },
133
+ {
134
+ command: "frame-snapshot",
135
+ commandArgs: ["target:1", "frame:0"],
136
+ moduleRef: frameSnapshotCommand as unknown as Record<string, unknown>,
137
+ handlerName: "runFrameSnapshotCommand"
138
+ },
139
+ {
140
+ command: "network-wait-for",
141
+ commandArgs: ["target:1", "/api", "1000"],
142
+ moduleRef: networkWaitForCommand as unknown as Record<string, unknown>,
143
+ handlerName: "runNetworkWaitForCommand"
144
+ },
145
+ {
146
+ command: "profile-list",
147
+ commandArgs: [],
148
+ moduleRef: profileListCommand as unknown as Record<string, unknown>,
149
+ handlerName: "runProfileListCommand"
150
+ },
151
+ {
152
+ command: "profile-use",
153
+ commandArgs: ["managed"],
154
+ moduleRef: profileUseCommand as unknown as Record<string, unknown>,
155
+ handlerName: "runProfileUseCommand"
156
+ },
157
+ {
158
+ command: "response-body",
159
+ commandArgs: ["target:1", "request:1"],
160
+ moduleRef: responseBodyCommand as unknown as Record<string, unknown>,
161
+ handlerName: "runResponseBodyCommand"
162
+ },
163
+ {
164
+ command: "snapshot",
165
+ commandArgs: ["target:1"],
166
+ moduleRef: snapshotCommand as unknown as Record<string, unknown>,
167
+ handlerName: "runSnapshotCommand"
168
+ },
169
+ {
170
+ command: "storage-get",
171
+ commandArgs: ["target:1", "local", "theme"],
172
+ moduleRef: storageGetCommand as unknown as Record<string, unknown>,
173
+ handlerName: "runStorageGetCommand"
174
+ },
175
+ {
176
+ command: "storage-set",
177
+ commandArgs: ["target:1", "local", "theme", "dark"],
178
+ moduleRef: storageSetCommand as unknown as Record<string, unknown>,
179
+ handlerName: "runStorageSetCommand"
180
+ },
181
+ {
182
+ command: "tab-close",
183
+ commandArgs: ["target:1"],
184
+ moduleRef: tabCloseCommand as unknown as Record<string, unknown>,
185
+ handlerName: "runTabCloseCommand"
186
+ },
187
+ {
188
+ command: "tab-focus",
189
+ commandArgs: ["target:1"],
190
+ moduleRef: tabFocusCommand as unknown as Record<string, unknown>,
191
+ handlerName: "runTabFocusCommand"
192
+ },
193
+ {
194
+ command: "tab-open",
195
+ commandArgs: ["https://example.com"],
196
+ moduleRef: tabOpenCommand as unknown as Record<string, unknown>,
197
+ handlerName: "runTabOpenCommand"
198
+ },
199
+ {
200
+ command: "upload-arm",
201
+ commandArgs: ["target:1", "upload.txt"],
202
+ moduleRef: uploadArmCommand as unknown as Record<string, unknown>,
203
+ handlerName: "runUploadArmCommand"
204
+ }
205
+ ];
206
+
207
+ afterEach(() => {
208
+ vi.restoreAllMocks();
209
+ });
210
+
211
+ describe("cli dispatch matrix", () => {
212
+ it.each(COMMAND_DISPATCH_CASES)(
213
+ "dispatches $command to $handlerName",
214
+ async ({ command, commandArgs, moduleRef, handlerName }) => {
215
+ const handler = moduleRef[handlerName] as ((args: string[]) => Promise<unknown>) | undefined;
216
+ if (typeof handler !== "function") {
217
+ throw new Error(`Expected ${handlerName} to be a function.`);
218
+ }
219
+
220
+ const handlerSpy = vi.spyOn(moduleRef as Record<string, (...args: unknown[]) => unknown>, handlerName)
221
+ .mockResolvedValue({ ok: true });
222
+ const { io, state } = createIoCapture();
223
+
224
+ const exitCode = await runCli([command, ...commandArgs], io);
225
+
226
+ expect(exitCode).toBe(EXIT_CODES.OK);
227
+ expect(state.stderr).toBe("");
228
+ expect(state.stdout).toMatch(new RegExp(`^${command}: `));
229
+ expect(handlerSpy).toHaveBeenCalledTimes(1);
230
+ expect(handlerSpy).toHaveBeenCalledWith(commandArgs);
231
+ }
232
+ );
233
+
234
+ it("dispatches daemon-stop through daemon client", async () => {
235
+ const stopSpy = vi.spyOn(daemonClient, "stopDaemon").mockResolvedValue({
236
+ stopped: true,
237
+ port: 41337,
238
+ pid: 12345
239
+ });
240
+ const { io, state } = createIoCapture();
241
+
242
+ const exitCode = await runCli(["daemon-stop", "--json"], io);
243
+
244
+ expect(exitCode).toBe(EXIT_CODES.OK);
245
+ expect(stopSpy).toHaveBeenCalledTimes(1);
246
+ expect(JSON.parse(state.stdout)).toEqual({
247
+ ok: true,
248
+ command: "daemon-stop",
249
+ data: {
250
+ stopped: true,
251
+ port: 41337,
252
+ pid: 12345
253
+ }
254
+ });
255
+ });
256
+ });
@@ -0,0 +1,394 @@
1
+ import { PassThrough } from "node:stream";
2
+
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ import { bootstrapBrowserd } from "./bootstrap";
6
+
7
+ function waitForNextJsonLine(stream: PassThrough): Promise<Record<string, unknown>> {
8
+ return new Promise((resolve, reject) => {
9
+ let buffer = "";
10
+ const timeout = setTimeout(() => {
11
+ stream.off("data", onData);
12
+ reject(new Error("Timed out waiting for JSON line response."));
13
+ }, 1000);
14
+
15
+ const onData = (chunk: string | Buffer) => {
16
+ buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
17
+ const newlineIndex = buffer.indexOf("\n");
18
+ if (newlineIndex < 0) {
19
+ return;
20
+ }
21
+
22
+ clearTimeout(timeout);
23
+ stream.off("data", onData);
24
+ const line = buffer.slice(0, newlineIndex);
25
+
26
+ resolve(JSON.parse(line) as Record<string, unknown>);
27
+ };
28
+
29
+ stream.on("data", onData);
30
+ });
31
+ }
32
+
33
+ function sendToolRequest(
34
+ input: PassThrough,
35
+ output: PassThrough,
36
+ request: Record<string, unknown>
37
+ ): Promise<Record<string, unknown>> {
38
+ const responsePromise = waitForNextJsonLine(output);
39
+ input.write(`${JSON.stringify(request)}\n`);
40
+ return responsePromise;
41
+ }
42
+
43
+ function createManagedLegacyRuntime() {
44
+ const input = new PassThrough();
45
+ const output = new PassThrough();
46
+ const runtime = bootstrapBrowserd({
47
+ env: {
48
+ BROWSERD_DEFAULT_DRIVER: "managed"
49
+ },
50
+ input,
51
+ output,
52
+ stdioProtocol: "legacy"
53
+ });
54
+
55
+ return {
56
+ input,
57
+ output,
58
+ runtime
59
+ };
60
+ }
61
+
62
+ describe("browserd tool matrix", () => {
63
+ it("returns E_INVALID_ARG for missing required arguments on routed tools", async () => {
64
+ const { input, output, runtime } = createManagedLegacyRuntime();
65
+
66
+ try {
67
+ const toolNames = [
68
+ "browser.profile.use",
69
+ "browser.tab.focus",
70
+ "browser.tab.close",
71
+ "browser.snapshot",
72
+ "browser.dom.query",
73
+ "browser.dom.queryAll",
74
+ "browser.element.screenshot",
75
+ "browser.a11y.snapshot",
76
+ "browser.cookie.get",
77
+ "browser.cookie.set",
78
+ "browser.cookie.clear",
79
+ "browser.storage.get",
80
+ "browser.storage.set",
81
+ "browser.frame.list",
82
+ "browser.frame.snapshot",
83
+ "browser.act",
84
+ "browser.dialog.arm",
85
+ "browser.download.trigger",
86
+ "browser.network.waitFor"
87
+ ] as const;
88
+
89
+ for (const [index, toolName] of toolNames.entries()) {
90
+ const response = await sendToolRequest(input, output, {
91
+ id: `request-missing-arg-${index}`,
92
+ name: toolName,
93
+ traceId: `trace:missing-arg:${index}`,
94
+ arguments: {
95
+ sessionId: "session:missing-arg"
96
+ }
97
+ });
98
+
99
+ expect(response.ok).toBe(false);
100
+ expect(response.error).toMatchObject({
101
+ code: "E_INVALID_ARG"
102
+ });
103
+ }
104
+ } finally {
105
+ runtime.close();
106
+ input.end();
107
+ output.end();
108
+ }
109
+ });
110
+
111
+ it("returns E_DRIVER_UNAVAILABLE for structured action tools on managed driver", async () => {
112
+ const { input, output, runtime } = createManagedLegacyRuntime();
113
+
114
+ try {
115
+ const openResponse = await sendToolRequest(input, output, {
116
+ id: "request-unavailable-open",
117
+ name: "browser.tab.open",
118
+ traceId: "trace:unavailable:open",
119
+ arguments: {
120
+ sessionId: "session:unavailable",
121
+ url: "https://example.com/unavailable"
122
+ }
123
+ });
124
+ const targetId = (openResponse.data as { targetId: string }).targetId;
125
+
126
+ const cases: Array<{ name: string; arguments: Record<string, unknown> }> = [
127
+ {
128
+ name: "browser.dom.query",
129
+ arguments: {
130
+ sessionId: "session:unavailable",
131
+ targetId,
132
+ selector: "#root"
133
+ }
134
+ },
135
+ {
136
+ name: "browser.dom.queryAll",
137
+ arguments: {
138
+ sessionId: "session:unavailable",
139
+ targetId,
140
+ selector: ".item"
141
+ }
142
+ },
143
+ {
144
+ name: "browser.element.screenshot",
145
+ arguments: {
146
+ sessionId: "session:unavailable",
147
+ targetId,
148
+ selector: "#hero"
149
+ }
150
+ },
151
+ {
152
+ name: "browser.a11y.snapshot",
153
+ arguments: {
154
+ sessionId: "session:unavailable",
155
+ targetId
156
+ }
157
+ },
158
+ {
159
+ name: "browser.cookie.get",
160
+ arguments: {
161
+ sessionId: "session:unavailable",
162
+ targetId
163
+ }
164
+ },
165
+ {
166
+ name: "browser.cookie.set",
167
+ arguments: {
168
+ sessionId: "session:unavailable",
169
+ targetId,
170
+ name: "sid",
171
+ value: "abc"
172
+ }
173
+ },
174
+ {
175
+ name: "browser.cookie.clear",
176
+ arguments: {
177
+ sessionId: "session:unavailable",
178
+ targetId
179
+ }
180
+ },
181
+ {
182
+ name: "browser.storage.get",
183
+ arguments: {
184
+ sessionId: "session:unavailable",
185
+ targetId,
186
+ scope: "local",
187
+ key: "theme"
188
+ }
189
+ },
190
+ {
191
+ name: "browser.storage.set",
192
+ arguments: {
193
+ sessionId: "session:unavailable",
194
+ targetId,
195
+ scope: "local",
196
+ key: "theme",
197
+ value: "dark"
198
+ }
199
+ },
200
+ {
201
+ name: "browser.frame.list",
202
+ arguments: {
203
+ sessionId: "session:unavailable",
204
+ targetId
205
+ }
206
+ },
207
+ {
208
+ name: "browser.frame.snapshot",
209
+ arguments: {
210
+ sessionId: "session:unavailable",
211
+ targetId,
212
+ frameId: "frame:0"
213
+ }
214
+ }
215
+ ];
216
+
217
+ for (const [index, testCase] of cases.entries()) {
218
+ const response = await sendToolRequest(input, output, {
219
+ id: `request-driver-unavailable-${index}`,
220
+ name: testCase.name,
221
+ traceId: `trace:driver-unavailable:${index}`,
222
+ arguments: testCase.arguments
223
+ });
224
+
225
+ expect(response.ok).toBe(false);
226
+ expect(response.error).toMatchObject({
227
+ code: "E_DRIVER_UNAVAILABLE"
228
+ });
229
+ }
230
+ } finally {
231
+ runtime.close();
232
+ input.end();
233
+ output.end();
234
+ }
235
+ });
236
+
237
+ it("routes managed-driver tools including profile.use, act, focus/close, and waitFor timeout", async () => {
238
+ const { input, output, runtime } = createManagedLegacyRuntime();
239
+
240
+ try {
241
+ const profileUseResponse = await sendToolRequest(input, output, {
242
+ id: "request-profile-use",
243
+ name: "browser.profile.use",
244
+ traceId: "trace:matrix:profile-use",
245
+ arguments: {
246
+ sessionId: "session:profile-use",
247
+ profile: "managed"
248
+ }
249
+ });
250
+ expect(profileUseResponse.ok).toBe(true);
251
+ expect(profileUseResponse.data).toEqual({
252
+ profile: "managed"
253
+ });
254
+
255
+ const openResponse = await sendToolRequest(input, output, {
256
+ id: "request-flow-open",
257
+ name: "browser.tab.open",
258
+ traceId: "trace:matrix:open",
259
+ arguments: {
260
+ sessionId: "session:flow",
261
+ url: "https://example.com/flow"
262
+ }
263
+ });
264
+ const targetId = (openResponse.data as { targetId: string }).targetId;
265
+
266
+ const snapshotResponse = await sendToolRequest(input, output, {
267
+ id: "request-flow-snapshot",
268
+ name: "browser.snapshot",
269
+ traceId: "trace:matrix:snapshot",
270
+ arguments: {
271
+ sessionId: "session:flow",
272
+ targetId
273
+ }
274
+ });
275
+ expect(snapshotResponse.ok).toBe(true);
276
+ expect(snapshotResponse.data).toMatchObject({
277
+ driver: "managed",
278
+ targetId
279
+ });
280
+
281
+ const focusResponse = await sendToolRequest(input, output, {
282
+ id: "request-flow-focus",
283
+ name: "browser.tab.focus",
284
+ traceId: "trace:matrix:focus",
285
+ arguments: {
286
+ sessionId: "session:flow",
287
+ targetId
288
+ }
289
+ });
290
+ expect(focusResponse.ok).toBe(true);
291
+ expect(focusResponse.data).toMatchObject({
292
+ driver: "managed",
293
+ targetId,
294
+ focused: true
295
+ });
296
+
297
+ const actResponse = await sendToolRequest(input, output, {
298
+ id: "request-flow-act",
299
+ name: "browser.act",
300
+ traceId: "trace:matrix:act",
301
+ arguments: {
302
+ sessionId: "session:flow",
303
+ targetId,
304
+ action: {
305
+ type: "click",
306
+ payload: {
307
+ selector: "#go"
308
+ }
309
+ }
310
+ }
311
+ });
312
+ expect(actResponse.ok).toBe(true);
313
+ expect(actResponse.data).toMatchObject({
314
+ driver: "managed",
315
+ targetId,
316
+ result: {
317
+ actionType: "click",
318
+ targetId,
319
+ targetKnown: true,
320
+ ok: true
321
+ }
322
+ });
323
+
324
+ const dialogResponse = await sendToolRequest(input, output, {
325
+ id: "request-flow-dialog",
326
+ name: "browser.dialog.arm",
327
+ traceId: "trace:matrix:dialog",
328
+ arguments: {
329
+ sessionId: "session:flow",
330
+ targetId
331
+ }
332
+ });
333
+ expect(dialogResponse.ok).toBe(true);
334
+ expect(dialogResponse.data).toMatchObject({
335
+ driver: "managed",
336
+ targetId,
337
+ armed: true
338
+ });
339
+
340
+ const triggerResponse = await sendToolRequest(input, output, {
341
+ id: "request-flow-trigger",
342
+ name: "browser.download.trigger",
343
+ traceId: "trace:matrix:trigger",
344
+ arguments: {
345
+ sessionId: "session:flow",
346
+ targetId
347
+ }
348
+ });
349
+ expect(triggerResponse.ok).toBe(true);
350
+ expect(triggerResponse.data).toMatchObject({
351
+ driver: "managed",
352
+ targetId,
353
+ triggered: true
354
+ });
355
+
356
+ const waitForResponse = await sendToolRequest(input, output, {
357
+ id: "request-flow-waitfor",
358
+ name: "browser.network.waitFor",
359
+ traceId: "trace:matrix:waitfor",
360
+ arguments: {
361
+ sessionId: "session:flow",
362
+ targetId,
363
+ urlPattern: "/never-match",
364
+ timeoutMs: 5,
365
+ pollMs: 1
366
+ }
367
+ });
368
+ expect(waitForResponse.ok).toBe(false);
369
+ expect(waitForResponse.error).toMatchObject({
370
+ code: "E_TIMEOUT"
371
+ });
372
+
373
+ const closeResponse = await sendToolRequest(input, output, {
374
+ id: "request-flow-close",
375
+ name: "browser.tab.close",
376
+ traceId: "trace:matrix:close",
377
+ arguments: {
378
+ sessionId: "session:flow",
379
+ targetId
380
+ }
381
+ });
382
+ expect(closeResponse.ok).toBe(true);
383
+ expect(closeResponse.data).toMatchObject({
384
+ driver: "managed",
385
+ targetId,
386
+ closed: true
387
+ });
388
+ } finally {
389
+ runtime.close();
390
+ input.end();
391
+ output.end();
392
+ }
393
+ });
394
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flrande/browserctl",
3
- "version": "0.1.0-dev.7.1",
3
+ "version": "0.1.0-dev.8.1",
4
4
  "private": false,
5
5
  "bin": {
6
6
  "browserctl": "bin/browserctl.cjs",