@flrande/browserctl 0.1.0 → 0.2.0-dev.12.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,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,97 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
2
+
3
+ import { stopDaemon } from "./daemon-client";
4
+ import { EXIT_CODES, runCli } from "./main";
5
+ import { reserveLoopbackPort } from "./test-port";
6
+
7
+ let testDaemonPort = 0;
8
+ let activeRelayPort = 0;
9
+
10
+ function createIoCapture() {
11
+ const state = {
12
+ stdout: "",
13
+ stderr: ""
14
+ };
15
+
16
+ return {
17
+ state,
18
+ io: {
19
+ stdout: {
20
+ write(content: string) {
21
+ state.stdout += content;
22
+ }
23
+ },
24
+ stderr: {
25
+ write(content: string) {
26
+ state.stderr += content;
27
+ }
28
+ }
29
+ }
30
+ };
31
+ }
32
+
33
+ function parseJsonLine(state: { stdout: string }): Record<string, unknown> {
34
+ return JSON.parse(state.stdout.trim()) as Record<string, unknown>;
35
+ }
36
+
37
+ beforeEach(async () => {
38
+ testDaemonPort = await reserveLoopbackPort();
39
+ activeRelayPort = await reserveLoopbackPort();
40
+ while (activeRelayPort === testDaemonPort) {
41
+ activeRelayPort = await reserveLoopbackPort();
42
+ }
43
+
44
+ process.env.BROWSERCTL_DAEMON_PORT = String(testDaemonPort);
45
+ process.env.BROWSERD_CHROME_RELAY_URL = `http://127.0.0.1:${activeRelayPort}`;
46
+ delete process.env.BROWSERD_MANAGED_LOCAL_ENABLED;
47
+ delete process.env.BROWSERD_DEFAULT_DRIVER;
48
+ delete process.env.BROWSERD_CHROME_RELAY_MODE;
49
+ delete process.env.BROWSERD_CHROME_RELAY_EXTENSION_TOKEN;
50
+ await stopDaemon(testDaemonPort);
51
+ });
52
+
53
+ afterEach(async () => {
54
+ await stopDaemon(testDaemonPort);
55
+ delete process.env.BROWSERCTL_DAEMON_PORT;
56
+ delete process.env.BROWSERD_CHROME_RELAY_URL;
57
+ delete process.env.BROWSERD_MANAGED_LOCAL_ENABLED;
58
+ delete process.env.BROWSERD_DEFAULT_DRIVER;
59
+ delete process.env.BROWSERD_CHROME_RELAY_MODE;
60
+ delete process.env.BROWSERD_CHROME_RELAY_EXTENSION_TOKEN;
61
+ });
62
+
63
+ describe("browserctl smoke e2e", () => {
64
+ it("starts with extension-first defaults and reports chrome-relay status", async () => {
65
+ const startCapture = createIoCapture();
66
+ const startExitCode = await runCli(["daemon-start", "--json"], startCapture.io);
67
+
68
+ expect(startExitCode).toBe(EXIT_CODES.OK);
69
+ const startPayload = parseJsonLine(startCapture.state);
70
+ expect(startPayload.ok).toBe(true);
71
+
72
+ const statusCapture = createIoCapture();
73
+ const statusExitCode = await runCli(["status", "--json"], statusCapture.io);
74
+
75
+ expect(statusExitCode).toBe(EXIT_CODES.OK);
76
+ const statusPayload = parseJsonLine(statusCapture.state);
77
+ expect(statusPayload.ok).toBe(true);
78
+ const statusData = statusPayload.data as Record<string, unknown>;
79
+ expect(statusData.driver).toBe("chrome-relay");
80
+ expect(statusData.status).toMatchObject({
81
+ kind: "chrome-relay",
82
+ connected: false
83
+ });
84
+ const relayStatus = statusData.status as Record<string, unknown>;
85
+ expect(String(relayStatus.relayUrl ?? "")).toContain(`:${activeRelayPort}`);
86
+
87
+ const stopCapture = createIoCapture();
88
+ const stopExitCode = await runCli(["daemon-stop", "--json"], stopCapture.io);
89
+ expect(stopExitCode).toBe(EXIT_CODES.OK);
90
+ const stopPayload = parseJsonLine(stopCapture.state);
91
+ expect(stopPayload.ok).toBe(true);
92
+ expect(stopPayload.data).toMatchObject({
93
+ stopped: true,
94
+ port: testDaemonPort
95
+ });
96
+ }, 20_000);
97
+ });
@@ -0,0 +1,26 @@
1
+ import { createServer } from "node:net";
2
+
3
+ export async function reserveLoopbackPort(): Promise<number> {
4
+ return await new Promise<number>((resolve, reject) => {
5
+ const server = createServer();
6
+
7
+ server.once("error", reject);
8
+ server.listen(0, "127.0.0.1", () => {
9
+ const address = server.address();
10
+ if (typeof address !== "object" || address === null) {
11
+ server.close(() => reject(new Error("Failed to reserve loopback port.")));
12
+ return;
13
+ }
14
+
15
+ const port = address.port;
16
+ server.close((error) => {
17
+ if (error !== undefined) {
18
+ reject(error);
19
+ return;
20
+ }
21
+
22
+ resolve(port);
23
+ });
24
+ });
25
+ });
26
+ }
@@ -1,33 +1,8 @@
1
- import { createServer } from "node:net";
2
-
3
1
  import { afterEach, describe, expect, it } from "vitest";
4
2
  import { WebSocket } from "ws";
5
3
 
6
4
  import { createChromeRelayExtensionBridge } from "./chrome-relay-extension-bridge";
7
-
8
- async function reservePort(): Promise<number> {
9
- return await new Promise<number>((resolve, reject) => {
10
- const server = createServer();
11
- server.once("error", reject);
12
- server.listen(0, "127.0.0.1", () => {
13
- const address = server.address();
14
- if (typeof address !== "object" || address === null) {
15
- server.close(() => resolve(0));
16
- return;
17
- }
18
-
19
- const port = address.port;
20
- server.close((closeError) => {
21
- if (closeError !== undefined) {
22
- reject(closeError);
23
- return;
24
- }
25
-
26
- resolve(port);
27
- });
28
- });
29
- });
30
- }
5
+ import { reserveLoopbackPort } from "./test-port";
31
6
 
32
7
  async function waitForCondition(predicate: () => boolean, timeoutMs = 1000): Promise<void> {
33
8
  const start = Date.now();
@@ -61,7 +36,7 @@ afterEach(async () => {
61
36
 
62
37
  describe("createChromeRelayExtensionBridge", () => {
63
38
  it("requires token configuration for extension bridge startup", async () => {
64
- const port = await reservePort();
39
+ const port = await reserveLoopbackPort();
65
40
 
66
41
  expect(() =>
67
42
  createChromeRelayExtensionBridge({
@@ -71,7 +46,7 @@ describe("createChromeRelayExtensionBridge", () => {
71
46
  });
72
47
 
73
48
  it("sends requests to extension client and receives responses", async () => {
74
- const port = await reservePort();
49
+ const port = await reserveLoopbackPort();
75
50
  const bridge = createChromeRelayExtensionBridge({
76
51
  relayUrl: `http://127.0.0.1:${port}`,
77
52
  token: "secret-token",
@@ -134,7 +109,7 @@ describe("createChromeRelayExtensionBridge", () => {
134
109
  });
135
110
 
136
111
  it("rejects invoke when extension is not connected", async () => {
137
- const port = await reservePort();
112
+ const port = await reserveLoopbackPort();
138
113
  const bridge = createChromeRelayExtensionBridge({
139
114
  relayUrl: `http://127.0.0.1:${port}`,
140
115
  token: "secret-token",
@@ -148,7 +123,7 @@ describe("createChromeRelayExtensionBridge", () => {
148
123
  });
149
124
 
150
125
  it("enforces token validation for extension websocket connection", async () => {
151
- const port = await reservePort();
126
+ const port = await reserveLoopbackPort();
152
127
  const bridge = createChromeRelayExtensionBridge({
153
128
  relayUrl: `http://127.0.0.1:${port}`,
154
129
  token: "secret-token",
@@ -188,7 +163,7 @@ describe("createChromeRelayExtensionBridge", () => {
188
163
  });
189
164
 
190
165
  it("forwards extension event envelopes to bridge listeners", async () => {
191
- const port = await reservePort();
166
+ const port = await reserveLoopbackPort();
192
167
  const bridge = createChromeRelayExtensionBridge({
193
168
  relayUrl: `http://127.0.0.1:${port}`,
194
169
  token: "secret-token",
@@ -53,9 +53,11 @@ export type BrowserdConfig = {
53
53
 
54
54
  const DEFAULT_DRIVER_KEY = "managed";
55
55
  const MANAGED_LOCAL_DRIVER_KEY = "managed-local";
56
+ const DEFAULT_CONFIG_DRIVER_KEY = "chrome-relay";
56
57
  const DEFAULT_MANAGED_LOCAL_BROWSER_NAME: ManagedLocalBrowserName = "chromium";
57
58
  const DEFAULT_MANAGED_LOCAL_HEADLESS = true;
58
- const DEFAULT_CHROME_RELAY_MODE: ChromeRelayMode = "cdp";
59
+ const DEFAULT_CHROME_RELAY_MODE: ChromeRelayMode = "extension";
60
+ const DEFAULT_CHROME_RELAY_EXTENSION_TOKEN = "browserctl-relay";
59
61
  const DEFAULT_CHROME_RELAY_EXTENSION_REQUEST_TIMEOUT_MS = 5_000;
60
62
  const DEFAULT_NETWORK_WAIT_TIMEOUT_MS = 10_000;
61
63
  const DEFAULT_NETWORK_WAIT_POLL_MS = 200;
@@ -181,7 +183,11 @@ function parseManagedLocalBrowserName(value: string | undefined): ManagedLocalBr
181
183
 
182
184
  function parseChromeRelayMode(value: string | undefined): ChromeRelayMode {
183
185
  const normalizedValue = value?.trim().toLowerCase();
184
- return normalizedValue === "extension" ? "extension" : DEFAULT_CHROME_RELAY_MODE;
186
+ if (normalizedValue === "extension" || normalizedValue === "cdp") {
187
+ return normalizedValue;
188
+ }
189
+
190
+ return DEFAULT_CHROME_RELAY_MODE;
185
191
  }
186
192
 
187
193
  export type BrowserdContainer = {
@@ -198,16 +204,12 @@ export function loadBrowserdConfig(
198
204
  ): BrowserdConfig {
199
205
  const managedLocalEnabled = parseBooleanFlag(env.BROWSERD_MANAGED_LOCAL_ENABLED, true);
200
206
  const chromeRelayMode = parseChromeRelayMode(env.BROWSERD_CHROME_RELAY_MODE);
201
- const chromeRelayExtensionToken = resolveNonEmptyString(env.BROWSERD_CHROME_RELAY_EXTENSION_TOKEN);
202
- if (chromeRelayMode === "extension" && chromeRelayExtensionToken === undefined) {
203
- throw new Error(
204
- "BROWSERD_CHROME_RELAY_EXTENSION_TOKEN is required when BROWSERD_CHROME_RELAY_MODE=extension."
205
- );
206
- }
207
+ const chromeRelayExtensionToken =
208
+ resolveNonEmptyString(env.BROWSERD_CHROME_RELAY_EXTENSION_TOKEN) ??
209
+ (chromeRelayMode === "extension" ? DEFAULT_CHROME_RELAY_EXTENSION_TOKEN : undefined);
207
210
 
208
211
  const defaultDriver =
209
- resolveNonEmptyString(env.BROWSERD_DEFAULT_DRIVER) ??
210
- (managedLocalEnabled ? MANAGED_LOCAL_DRIVER_KEY : DEFAULT_DRIVER_KEY);
212
+ resolveNonEmptyString(env.BROWSERD_DEFAULT_DRIVER) ?? DEFAULT_CONFIG_DRIVER_KEY;
211
213
 
212
214
  return {
213
215
  chromeRelayUrl: env.BROWSERD_CHROME_RELAY_URL ?? "http://127.0.0.1:9223",