@flrande/browserctl 0.5.0-dev.19.1 → 0.5.0-dev.22.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.
@@ -17,6 +17,8 @@ type CapturedRequest = {
17
17
 
18
18
  const ORIGINAL_DAEMON_PORT = process.env.BROWSERCTL_DAEMON_PORT;
19
19
  const ORIGINAL_AUTH_TOKEN = process.env.BROWSERCTL_AUTH_TOKEN;
20
+ const ORIGINAL_DAEMON_STARTUP_TIMEOUT_MS = process.env.BROWSERCTL_DAEMON_STARTUP_TIMEOUT_MS;
21
+ const ORIGINAL_DAEMON_REQUEST_TIMEOUT_MS = process.env.BROWSERCTL_DAEMON_REQUEST_TIMEOUT_MS;
20
22
  const TEST_PID_PORTS = new Set<number>();
21
23
 
22
24
  function resolveRuntimeDirForTests(): string {
@@ -51,6 +53,18 @@ afterEach(() => {
51
53
  process.env.BROWSERCTL_AUTH_TOKEN = ORIGINAL_AUTH_TOKEN;
52
54
  }
53
55
 
56
+ if (ORIGINAL_DAEMON_STARTUP_TIMEOUT_MS === undefined) {
57
+ delete process.env.BROWSERCTL_DAEMON_STARTUP_TIMEOUT_MS;
58
+ } else {
59
+ process.env.BROWSERCTL_DAEMON_STARTUP_TIMEOUT_MS = ORIGINAL_DAEMON_STARTUP_TIMEOUT_MS;
60
+ }
61
+
62
+ if (ORIGINAL_DAEMON_REQUEST_TIMEOUT_MS === undefined) {
63
+ delete process.env.BROWSERCTL_DAEMON_REQUEST_TIMEOUT_MS;
64
+ } else {
65
+ process.env.BROWSERCTL_DAEMON_REQUEST_TIMEOUT_MS = ORIGINAL_DAEMON_REQUEST_TIMEOUT_MS;
66
+ }
67
+
54
68
  for (const port of TEST_PID_PORTS) {
55
69
  rmSync(resolvePidFileForTests(port), { force: true });
56
70
  }
@@ -59,8 +73,12 @@ afterEach(() => {
59
73
  });
60
74
 
61
75
  async function withDaemonHarness(
62
- run: (state: { port: number; requests: CapturedRequest[] }) => Promise<void>
76
+ run: (state: { port: number; requests: CapturedRequest[] }) => Promise<void>,
77
+ options: {
78
+ responseDelayMs?: number;
79
+ } = {}
63
80
  ): Promise<void> {
81
+ const responseDelayMs = options.responseDelayMs ?? 0;
64
82
  const requests: CapturedRequest[] = [];
65
83
  const server = createServer((socket) => {
66
84
  socket.setEncoding("utf8");
@@ -81,20 +99,27 @@ async function withDaemonHarness(
81
99
 
82
100
  const request = JSON.parse(line) as CapturedRequest;
83
101
  requests.push(request);
84
- socket.write(
85
- `${JSON.stringify({
86
- id: request.id,
87
- ok: true,
88
- traceId: request.traceId,
89
- sessionId:
90
- typeof request.arguments.sessionId === "string"
91
- ? request.arguments.sessionId
92
- : "cli:test",
93
- data: {
94
- ok: true
95
- }
96
- })}\n`
97
- );
102
+ const respond = () => {
103
+ socket.write(
104
+ `${JSON.stringify({
105
+ id: request.id,
106
+ ok: true,
107
+ traceId: request.traceId,
108
+ sessionId:
109
+ typeof request.arguments.sessionId === "string"
110
+ ? request.arguments.sessionId
111
+ : "cli:test",
112
+ data: {
113
+ ok: true
114
+ }
115
+ })}\n`
116
+ );
117
+ };
118
+ if (responseDelayMs > 0) {
119
+ setTimeout(respond, responseDelayMs);
120
+ } else {
121
+ respond();
122
+ }
98
123
 
99
124
  lineBreakIndex = buffer.indexOf("\n");
100
125
  }
@@ -127,6 +152,126 @@ async function withDaemonHarness(
127
152
  }
128
153
  }
129
154
 
155
+ async function reservePortForTests(): Promise<number> {
156
+ const server = createServer();
157
+
158
+ await new Promise<void>((resolve, reject) => {
159
+ server.once("error", reject);
160
+ server.listen(0, "127.0.0.1", () => {
161
+ server.off("error", reject);
162
+ resolve();
163
+ });
164
+ });
165
+
166
+ const address = server.address() as AddressInfo;
167
+ await new Promise<void>((resolve, reject) => {
168
+ server.close((error) => {
169
+ if (error !== undefined) {
170
+ reject(error);
171
+ return;
172
+ }
173
+ resolve();
174
+ });
175
+ });
176
+
177
+ return address.port;
178
+ }
179
+
180
+ async function startDaemonHarnessOnPort(
181
+ port: number,
182
+ requests: CapturedRequest[],
183
+ options: {
184
+ responseDelayMs?: number;
185
+ } = {}
186
+ ): Promise<() => Promise<void>> {
187
+ const responseDelayMs = options.responseDelayMs ?? 0;
188
+ const server = createServer((socket) => {
189
+ socket.setEncoding("utf8");
190
+ let buffer = "";
191
+
192
+ socket.on("data", (chunk: string) => {
193
+ buffer += chunk;
194
+
195
+ let lineBreakIndex = buffer.indexOf("\n");
196
+ while (lineBreakIndex >= 0) {
197
+ const line = buffer.slice(0, lineBreakIndex).trim();
198
+ buffer = buffer.slice(lineBreakIndex + 1);
199
+
200
+ if (line.length === 0) {
201
+ lineBreakIndex = buffer.indexOf("\n");
202
+ continue;
203
+ }
204
+
205
+ const request = JSON.parse(line) as CapturedRequest;
206
+ requests.push(request);
207
+ const respond = () => {
208
+ socket.write(
209
+ `${JSON.stringify({
210
+ id: request.id,
211
+ ok: true,
212
+ traceId: request.traceId,
213
+ sessionId:
214
+ typeof request.arguments.sessionId === "string"
215
+ ? request.arguments.sessionId
216
+ : "cli:test",
217
+ data: {
218
+ ok: true
219
+ }
220
+ })}\n`
221
+ );
222
+ };
223
+ if (responseDelayMs > 0) {
224
+ setTimeout(respond, responseDelayMs);
225
+ } else {
226
+ respond();
227
+ }
228
+
229
+ lineBreakIndex = buffer.indexOf("\n");
230
+ }
231
+ });
232
+ });
233
+
234
+ await new Promise<void>((resolve, reject) => {
235
+ server.once("error", reject);
236
+ server.listen(port, "127.0.0.1", () => {
237
+ server.off("error", reject);
238
+ resolve();
239
+ });
240
+ });
241
+
242
+ return async () =>
243
+ await new Promise<void>((resolve, reject) => {
244
+ server.close((error) => {
245
+ if (error !== undefined) {
246
+ reject(error);
247
+ return;
248
+ }
249
+ resolve();
250
+ });
251
+ });
252
+ }
253
+
254
+ async function importDaemonClientWithMockedSpawn(
255
+ spawnImplementation: () => {
256
+ pid: number;
257
+ unref(): void;
258
+ }
259
+ ): Promise<{
260
+ callDaemonToolWithMock: typeof callDaemonTool;
261
+ spawnMock: ReturnType<typeof vi.fn>;
262
+ }> {
263
+ vi.resetModules();
264
+ const spawnMock = vi.fn(spawnImplementation);
265
+ vi.doMock("node:child_process", () => ({
266
+ spawn: spawnMock
267
+ }));
268
+ const daemonClientModule = await import("./daemon-client");
269
+ return {
270
+ callDaemonToolWithMock: daemonClientModule.callDaemonTool,
271
+ spawnMock
272
+ };
273
+ }
274
+
130
275
  describe("daemon client auth token forwarding", () => {
131
276
  it("includes env token in daemon status probes", async () => {
132
277
  await withDaemonHarness(async ({ port, requests }) => {
@@ -251,3 +396,117 @@ describe("daemon client auth token forwarding", () => {
251
396
  expect(killSpy).not.toHaveBeenCalled();
252
397
  });
253
398
  });
399
+
400
+ describe("daemon client timeout env branches", () => {
401
+ it("applies BROWSERCTL_DAEMON_REQUEST_TIMEOUT_MS to daemon responses", async () => {
402
+ await withDaemonHarness(
403
+ async ({ port }) => {
404
+ process.env.BROWSERCTL_DAEMON_PORT = String(port);
405
+ process.env.BROWSERCTL_DAEMON_REQUEST_TIMEOUT_MS = "10";
406
+
407
+ await expect(
408
+ callDaemonTool("browser.tab.list", {
409
+ sessionId: "cli:timeout"
410
+ })
411
+ ).rejects.toThrow("Timed out waiting for daemon response after 10ms");
412
+ },
413
+ {
414
+ responseDelayMs: 80
415
+ }
416
+ );
417
+ });
418
+
419
+ it("falls back to default request timeout when env value is invalid", async () => {
420
+ await withDaemonHarness(
421
+ async ({ port, requests }) => {
422
+ process.env.BROWSERCTL_DAEMON_PORT = String(port);
423
+ process.env.BROWSERCTL_DAEMON_REQUEST_TIMEOUT_MS = "-1";
424
+
425
+ await expect(
426
+ callDaemonTool("browser.tab.list", {
427
+ sessionId: "cli:timeout-fallback"
428
+ })
429
+ ).resolves.toEqual({
430
+ ok: true
431
+ });
432
+ expect(requests).toHaveLength(1);
433
+ },
434
+ {
435
+ responseDelayMs: 80
436
+ }
437
+ );
438
+ });
439
+
440
+ it("applies BROWSERCTL_DAEMON_STARTUP_TIMEOUT_MS while polling spawned daemon readiness", async () => {
441
+ const unavailablePort = await reservePortForTests();
442
+ TEST_PID_PORTS.add(unavailablePort);
443
+ process.env.BROWSERCTL_DAEMON_PORT = String(unavailablePort);
444
+ process.env.BROWSERCTL_DAEMON_STARTUP_TIMEOUT_MS = "50";
445
+ process.env.BROWSERCTL_DAEMON_REQUEST_TIMEOUT_MS = "20";
446
+
447
+ const { callDaemonToolWithMock, spawnMock } = await importDaemonClientWithMockedSpawn(() => ({
448
+ pid: 424242,
449
+ unref: vi.fn()
450
+ }));
451
+
452
+ try {
453
+ const startedAt = Date.now();
454
+ await expect(
455
+ callDaemonToolWithMock("browser.status", {
456
+ sessionId: "cli:startup-timeout"
457
+ })
458
+ ).rejects.toThrow(`Daemon did not become ready on port ${unavailablePort}:`);
459
+ expect(spawnMock).toHaveBeenCalledTimes(1);
460
+ expect(Date.now() - startedAt).toBeLessThan(1_500);
461
+ } finally {
462
+ vi.doUnmock("node:child_process");
463
+ vi.resetModules();
464
+ }
465
+ });
466
+
467
+ it("falls back to default startup timeout when env value is invalid", async () => {
468
+ const port = await reservePortForTests();
469
+ TEST_PID_PORTS.add(port);
470
+ process.env.BROWSERCTL_DAEMON_PORT = String(port);
471
+ process.env.BROWSERCTL_DAEMON_STARTUP_TIMEOUT_MS = "-1";
472
+ process.env.BROWSERCTL_DAEMON_REQUEST_TIMEOUT_MS = "50";
473
+
474
+ const requests: CapturedRequest[] = [];
475
+ let closeServer: (() => Promise<void>) | undefined;
476
+ let startupError: unknown;
477
+ const startupTimer = setTimeout(() => {
478
+ void startDaemonHarnessOnPort(port, requests)
479
+ .then((close) => {
480
+ closeServer = close;
481
+ })
482
+ .catch((error) => {
483
+ startupError = error;
484
+ });
485
+ }, 300);
486
+
487
+ const { callDaemonToolWithMock, spawnMock } = await importDaemonClientWithMockedSpawn(() => ({
488
+ pid: 434343,
489
+ unref: vi.fn()
490
+ }));
491
+
492
+ try {
493
+ await expect(
494
+ callDaemonToolWithMock("browser.status", {
495
+ sessionId: "cli:startup-fallback"
496
+ })
497
+ ).resolves.toEqual({
498
+ ok: true
499
+ });
500
+ expect(spawnMock).toHaveBeenCalledTimes(1);
501
+ expect(requests.some((request) => request.name === "browser.status")).toBe(true);
502
+ expect(startupError).toBeUndefined();
503
+ } finally {
504
+ clearTimeout(startupTimer);
505
+ if (closeServer !== undefined) {
506
+ await closeServer();
507
+ }
508
+ vi.doUnmock("node:child_process");
509
+ vi.resetModules();
510
+ }
511
+ });
512
+ });
@@ -229,6 +229,42 @@ describe("browserd container", () => {
229
229
  expect(config.defaultDriver).toBe("chrome-relay");
230
230
  });
231
231
 
232
+ it("parses managed-local launch env branches for browser/headless/channel/path/timeout/args", () => {
233
+ const config = loadBrowserdConfig({
234
+ BROWSERD_MANAGED_LOCAL_BROWSER: "firefox",
235
+ BROWSERD_MANAGED_LOCAL_HEADLESS: "off",
236
+ BROWSERD_MANAGED_LOCAL_CHANNEL: "msedge",
237
+ BROWSERD_MANAGED_LOCAL_EXECUTABLE_PATH: "C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
238
+ BROWSERD_MANAGED_LOCAL_LAUNCH_TIMEOUT_MS: "45000",
239
+ BROWSERD_MANAGED_LOCAL_ARGS: "--disable-gpu,--lang=en-US"
240
+ });
241
+
242
+ expect(config.managedLocalLaunch).toEqual({
243
+ browserName: "firefox",
244
+ headless: false,
245
+ channel: "msedge",
246
+ executablePath: "C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
247
+ launchTimeoutMs: 45000,
248
+ args: ["--disable-gpu", "--lang=en-US"]
249
+ });
250
+ });
251
+
252
+ it("falls back to managed-local defaults for invalid launch env values", () => {
253
+ const config = loadBrowserdConfig({
254
+ BROWSERD_MANAGED_LOCAL_BROWSER: "invalid-browser",
255
+ BROWSERD_MANAGED_LOCAL_HEADLESS: "maybe",
256
+ BROWSERD_MANAGED_LOCAL_LAUNCH_TIMEOUT_MS: "-10",
257
+ BROWSERD_MANAGED_LOCAL_ARGS: " , , "
258
+ });
259
+
260
+ expect(config.managedLocalLaunch).toMatchObject({
261
+ browserName: "chromium",
262
+ headless: true,
263
+ launchTimeoutMs: undefined,
264
+ args: []
265
+ });
266
+ });
267
+
232
268
  it("does not register managed-local driver when explicitly disabled", () => {
233
269
  const c = createContainer(
234
270
  loadBrowserdConfig({
@@ -292,6 +328,26 @@ describe("browserd bootstrap", () => {
292
328
  runtime.close();
293
329
  });
294
330
 
331
+ it("reads tcp host from BROWSERD_HOST env in tcp transport mode", () => {
332
+ const port = testTcpPort;
333
+ const runtime = bootstrapBrowserd({
334
+ env: createTestEnv({
335
+ BROWSERD_TRANSPORT: "tcp",
336
+ BROWSERD_HOST: "localhost",
337
+ BROWSERD_PORT: String(port),
338
+ BROWSERD_AUTH_TOKEN: "tcp-token"
339
+ })
340
+ });
341
+
342
+ expect(runtime.transport).toBe("tcp");
343
+ expect(runtime.listening).toEqual({
344
+ host: "localhost",
345
+ port
346
+ });
347
+
348
+ runtime.close();
349
+ });
350
+
295
351
  it("rejects tcp transport mode when auth token is not configured", () => {
296
352
  let runtime: ReturnType<typeof bootstrapBrowserd> | undefined;
297
353
  try {
@@ -1388,6 +1444,36 @@ describe("browserd bootstrap", () => {
1388
1444
  output.end();
1389
1445
  });
1390
1446
 
1447
+ it("uses legacy stdio protocol when BROWSERD_STDIO_PROTOCOL=legacy", async () => {
1448
+ const input = new PassThrough();
1449
+ const output = new PassThrough();
1450
+ const runtime = bootstrapBrowserd({
1451
+ env: createTestEnv({
1452
+ BROWSERD_STDIO_PROTOCOL: "legacy"
1453
+ }),
1454
+ input,
1455
+ output
1456
+ });
1457
+
1458
+ const response = await sendToolRequest(input, output, {
1459
+ id: "request-legacy-env",
1460
+ name: "browser.status",
1461
+ traceId: "trace:legacy-env",
1462
+ arguments: {
1463
+ sessionId: "session:legacy-env"
1464
+ }
1465
+ });
1466
+
1467
+ expect(response.ok).toBe(true);
1468
+ expect(response.id).toBe("request-legacy-env");
1469
+ expect(response.traceId).toBe("trace:legacy-env");
1470
+ expect(response.sessionId).toBe("session:legacy-env");
1471
+
1472
+ runtime.close();
1473
+ input.end();
1474
+ output.end();
1475
+ });
1476
+
1391
1477
  it("preserves id and trace/session metadata when queue-level error handling runs", async () => {
1392
1478
  const input = new PassThrough();
1393
1479
  const output = new PassThrough();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flrande/browserctl",
3
- "version": "0.5.0-dev.19.1",
3
+ "version": "0.5.0-dev.22.1",
4
4
  "private": false,
5
5
  "bin": {
6
6
  "browserctl": "bin/browserctl.cjs",
@@ -36,6 +36,7 @@
36
36
  "zod": "^4.3.6"
37
37
  },
38
38
  "devDependencies": {
39
+ "@vitest/coverage-v8": "^2.1.8",
39
40
  "@types/node": "^22.15.30",
40
41
  "typescript": "^5.6.3",
41
42
  "vitest": "^2.1.8"
@@ -49,6 +50,7 @@
49
50
  "test:e2e:full": "vitest run --config vitest.e2e.full.config.ts",
50
51
  "test:e2e:stress": "vitest run --config vitest.e2e.stress.config.ts",
51
52
  "test:smoke": "vitest run --config vitest.smoke.config.ts",
53
+ "test:coverage": "vitest run --config vitest.config.ts --coverage",
52
54
  "test:all": "pnpm run test:unit && pnpm run test:contract && pnpm run test:e2e && pnpm run test:smoke",
53
55
  "build": "npm pack --dry-run",
54
56
  "typecheck": "pnpm exec tsc --noEmit -p tsconfig.typecheck.json",