@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,398 @@
1
+ import { PassThrough } from "node:stream";
2
+
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ import { bootstrapBrowserd } from "./bootstrap";
6
+ import { reserveLoopbackPort } from "./test-port";
7
+
8
+ function waitForNextJsonLine(stream: PassThrough): Promise<Record<string, unknown>> {
9
+ return new Promise((resolve, reject) => {
10
+ let buffer = "";
11
+ const timeout = setTimeout(() => {
12
+ stream.off("data", onData);
13
+ reject(new Error("Timed out waiting for JSON line response."));
14
+ }, 1000);
15
+
16
+ const onData = (chunk: string | Buffer) => {
17
+ buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
18
+ const newlineIndex = buffer.indexOf("\n");
19
+ if (newlineIndex < 0) {
20
+ return;
21
+ }
22
+
23
+ clearTimeout(timeout);
24
+ stream.off("data", onData);
25
+ const line = buffer.slice(0, newlineIndex);
26
+
27
+ resolve(JSON.parse(line) as Record<string, unknown>);
28
+ };
29
+
30
+ stream.on("data", onData);
31
+ });
32
+ }
33
+
34
+ function sendToolRequest(
35
+ input: PassThrough,
36
+ output: PassThrough,
37
+ request: Record<string, unknown>
38
+ ): Promise<Record<string, unknown>> {
39
+ const responsePromise = waitForNextJsonLine(output);
40
+ input.write(`${JSON.stringify(request)}\n`);
41
+ return responsePromise;
42
+ }
43
+
44
+ async function createManagedLegacyRuntime() {
45
+ const relayPort = await reserveLoopbackPort();
46
+
47
+ const input = new PassThrough();
48
+ const output = new PassThrough();
49
+ const runtime = bootstrapBrowserd({
50
+ env: {
51
+ BROWSERD_DEFAULT_DRIVER: "managed",
52
+ BROWSERD_CHROME_RELAY_URL: `http://127.0.0.1:${relayPort}`
53
+ },
54
+ input,
55
+ output,
56
+ stdioProtocol: "legacy"
57
+ });
58
+
59
+ return {
60
+ input,
61
+ output,
62
+ runtime
63
+ };
64
+ }
65
+
66
+ describe("browserd tool matrix", () => {
67
+ it("returns E_INVALID_ARG for missing required arguments on routed tools", async () => {
68
+ const { input, output, runtime } = await createManagedLegacyRuntime();
69
+
70
+ try {
71
+ const toolNames = [
72
+ "browser.profile.use",
73
+ "browser.tab.focus",
74
+ "browser.tab.close",
75
+ "browser.snapshot",
76
+ "browser.dom.query",
77
+ "browser.dom.queryAll",
78
+ "browser.element.screenshot",
79
+ "browser.a11y.snapshot",
80
+ "browser.cookie.get",
81
+ "browser.cookie.set",
82
+ "browser.cookie.clear",
83
+ "browser.storage.get",
84
+ "browser.storage.set",
85
+ "browser.frame.list",
86
+ "browser.frame.snapshot",
87
+ "browser.act",
88
+ "browser.dialog.arm",
89
+ "browser.download.trigger",
90
+ "browser.network.waitFor"
91
+ ] as const;
92
+
93
+ for (const [index, toolName] of toolNames.entries()) {
94
+ const response = await sendToolRequest(input, output, {
95
+ id: `request-missing-arg-${index}`,
96
+ name: toolName,
97
+ traceId: `trace:missing-arg:${index}`,
98
+ arguments: {
99
+ sessionId: "session:missing-arg"
100
+ }
101
+ });
102
+
103
+ expect(response.ok).toBe(false);
104
+ expect(response.error).toMatchObject({
105
+ code: "E_INVALID_ARG"
106
+ });
107
+ }
108
+ } finally {
109
+ runtime.close();
110
+ input.end();
111
+ output.end();
112
+ }
113
+ });
114
+
115
+ it("returns E_DRIVER_UNAVAILABLE for structured action tools on managed driver", async () => {
116
+ const { input, output, runtime } = await createManagedLegacyRuntime();
117
+
118
+ try {
119
+ const openResponse = await sendToolRequest(input, output, {
120
+ id: "request-unavailable-open",
121
+ name: "browser.tab.open",
122
+ traceId: "trace:unavailable:open",
123
+ arguments: {
124
+ sessionId: "session:unavailable",
125
+ url: "https://example.com/unavailable"
126
+ }
127
+ });
128
+ const targetId = (openResponse.data as { targetId: string }).targetId;
129
+
130
+ const cases: Array<{ name: string; arguments: Record<string, unknown> }> = [
131
+ {
132
+ name: "browser.dom.query",
133
+ arguments: {
134
+ sessionId: "session:unavailable",
135
+ targetId,
136
+ selector: "#root"
137
+ }
138
+ },
139
+ {
140
+ name: "browser.dom.queryAll",
141
+ arguments: {
142
+ sessionId: "session:unavailable",
143
+ targetId,
144
+ selector: ".item"
145
+ }
146
+ },
147
+ {
148
+ name: "browser.element.screenshot",
149
+ arguments: {
150
+ sessionId: "session:unavailable",
151
+ targetId,
152
+ selector: "#hero"
153
+ }
154
+ },
155
+ {
156
+ name: "browser.a11y.snapshot",
157
+ arguments: {
158
+ sessionId: "session:unavailable",
159
+ targetId
160
+ }
161
+ },
162
+ {
163
+ name: "browser.cookie.get",
164
+ arguments: {
165
+ sessionId: "session:unavailable",
166
+ targetId
167
+ }
168
+ },
169
+ {
170
+ name: "browser.cookie.set",
171
+ arguments: {
172
+ sessionId: "session:unavailable",
173
+ targetId,
174
+ name: "sid",
175
+ value: "abc"
176
+ }
177
+ },
178
+ {
179
+ name: "browser.cookie.clear",
180
+ arguments: {
181
+ sessionId: "session:unavailable",
182
+ targetId
183
+ }
184
+ },
185
+ {
186
+ name: "browser.storage.get",
187
+ arguments: {
188
+ sessionId: "session:unavailable",
189
+ targetId,
190
+ scope: "local",
191
+ key: "theme"
192
+ }
193
+ },
194
+ {
195
+ name: "browser.storage.set",
196
+ arguments: {
197
+ sessionId: "session:unavailable",
198
+ targetId,
199
+ scope: "local",
200
+ key: "theme",
201
+ value: "dark"
202
+ }
203
+ },
204
+ {
205
+ name: "browser.frame.list",
206
+ arguments: {
207
+ sessionId: "session:unavailable",
208
+ targetId
209
+ }
210
+ },
211
+ {
212
+ name: "browser.frame.snapshot",
213
+ arguments: {
214
+ sessionId: "session:unavailable",
215
+ targetId,
216
+ frameId: "frame:0"
217
+ }
218
+ }
219
+ ];
220
+
221
+ for (const [index, testCase] of cases.entries()) {
222
+ const response = await sendToolRequest(input, output, {
223
+ id: `request-driver-unavailable-${index}`,
224
+ name: testCase.name,
225
+ traceId: `trace:driver-unavailable:${index}`,
226
+ arguments: testCase.arguments
227
+ });
228
+
229
+ expect(response.ok).toBe(false);
230
+ expect(response.error).toMatchObject({
231
+ code: "E_DRIVER_UNAVAILABLE"
232
+ });
233
+ }
234
+ } finally {
235
+ runtime.close();
236
+ input.end();
237
+ output.end();
238
+ }
239
+ });
240
+
241
+ it("routes managed-driver tools including profile.use, act, focus/close, and waitFor timeout", async () => {
242
+ const { input, output, runtime } = await createManagedLegacyRuntime();
243
+
244
+ try {
245
+ const profileUseResponse = await sendToolRequest(input, output, {
246
+ id: "request-profile-use",
247
+ name: "browser.profile.use",
248
+ traceId: "trace:matrix:profile-use",
249
+ arguments: {
250
+ sessionId: "session:profile-use",
251
+ profile: "managed"
252
+ }
253
+ });
254
+ expect(profileUseResponse.ok).toBe(true);
255
+ expect(profileUseResponse.data).toEqual({
256
+ profile: "managed"
257
+ });
258
+
259
+ const openResponse = await sendToolRequest(input, output, {
260
+ id: "request-flow-open",
261
+ name: "browser.tab.open",
262
+ traceId: "trace:matrix:open",
263
+ arguments: {
264
+ sessionId: "session:flow",
265
+ url: "https://example.com/flow"
266
+ }
267
+ });
268
+ const targetId = (openResponse.data as { targetId: string }).targetId;
269
+
270
+ const snapshotResponse = await sendToolRequest(input, output, {
271
+ id: "request-flow-snapshot",
272
+ name: "browser.snapshot",
273
+ traceId: "trace:matrix:snapshot",
274
+ arguments: {
275
+ sessionId: "session:flow",
276
+ targetId
277
+ }
278
+ });
279
+ expect(snapshotResponse.ok).toBe(true);
280
+ expect(snapshotResponse.data).toMatchObject({
281
+ driver: "managed",
282
+ targetId
283
+ });
284
+
285
+ const focusResponse = await sendToolRequest(input, output, {
286
+ id: "request-flow-focus",
287
+ name: "browser.tab.focus",
288
+ traceId: "trace:matrix:focus",
289
+ arguments: {
290
+ sessionId: "session:flow",
291
+ targetId
292
+ }
293
+ });
294
+ expect(focusResponse.ok).toBe(true);
295
+ expect(focusResponse.data).toMatchObject({
296
+ driver: "managed",
297
+ targetId,
298
+ focused: true
299
+ });
300
+
301
+ const actResponse = await sendToolRequest(input, output, {
302
+ id: "request-flow-act",
303
+ name: "browser.act",
304
+ traceId: "trace:matrix:act",
305
+ arguments: {
306
+ sessionId: "session:flow",
307
+ targetId,
308
+ action: {
309
+ type: "click",
310
+ payload: {
311
+ selector: "#go"
312
+ }
313
+ }
314
+ }
315
+ });
316
+ expect(actResponse.ok).toBe(true);
317
+ expect(actResponse.data).toMatchObject({
318
+ driver: "managed",
319
+ targetId,
320
+ result: {
321
+ actionType: "click",
322
+ targetId,
323
+ targetKnown: true,
324
+ ok: true
325
+ }
326
+ });
327
+
328
+ const dialogResponse = await sendToolRequest(input, output, {
329
+ id: "request-flow-dialog",
330
+ name: "browser.dialog.arm",
331
+ traceId: "trace:matrix:dialog",
332
+ arguments: {
333
+ sessionId: "session:flow",
334
+ targetId
335
+ }
336
+ });
337
+ expect(dialogResponse.ok).toBe(true);
338
+ expect(dialogResponse.data).toMatchObject({
339
+ driver: "managed",
340
+ targetId,
341
+ armed: true
342
+ });
343
+
344
+ const triggerResponse = await sendToolRequest(input, output, {
345
+ id: "request-flow-trigger",
346
+ name: "browser.download.trigger",
347
+ traceId: "trace:matrix:trigger",
348
+ arguments: {
349
+ sessionId: "session:flow",
350
+ targetId
351
+ }
352
+ });
353
+ expect(triggerResponse.ok).toBe(true);
354
+ expect(triggerResponse.data).toMatchObject({
355
+ driver: "managed",
356
+ targetId,
357
+ triggered: true
358
+ });
359
+
360
+ const waitForResponse = await sendToolRequest(input, output, {
361
+ id: "request-flow-waitfor",
362
+ name: "browser.network.waitFor",
363
+ traceId: "trace:matrix:waitfor",
364
+ arguments: {
365
+ sessionId: "session:flow",
366
+ targetId,
367
+ urlPattern: "/never-match",
368
+ timeoutMs: 5,
369
+ pollMs: 1
370
+ }
371
+ });
372
+ expect(waitForResponse.ok).toBe(false);
373
+ expect(waitForResponse.error).toMatchObject({
374
+ code: "E_TIMEOUT"
375
+ });
376
+
377
+ const closeResponse = await sendToolRequest(input, output, {
378
+ id: "request-flow-close",
379
+ name: "browser.tab.close",
380
+ traceId: "trace:matrix:close",
381
+ arguments: {
382
+ sessionId: "session:flow",
383
+ targetId
384
+ }
385
+ });
386
+ expect(closeResponse.ok).toBe(true);
387
+ expect(closeResponse.data).toMatchObject({
388
+ driver: "managed",
389
+ targetId,
390
+ closed: true
391
+ });
392
+ } finally {
393
+ runtime.close();
394
+ input.end();
395
+ output.end();
396
+ }
397
+ });
398
+ });
@@ -0,0 +1,39 @@
1
+ # BrowserCtl Relay 扩展
2
+
3
+ [English](README.md) | 简体中文
4
+
5
+ 此扩展用于将 Chromium 系浏览器标签页层连接到本地 BrowserCtl 扩展桥接服务。
6
+
7
+ ## 加载已解压扩展
8
+
9
+ 1. 打开 `chrome://extensions`(Edge 使用 `edge://extensions`)。
10
+ 2. 开启**开发者模式**。
11
+ 3. 点击**加载已解压的扩展程序**。
12
+ 4. 选择目录:`extensions/chrome-relay`。
13
+
14
+ 该扩展使用 `debugger` 权限采集控制台/网络遥测,以供 agent 侧诊断与分析。
15
+
16
+ ## 配置
17
+
18
+ 1. 打开扩展弹窗。
19
+ 2. 将 `Bridge URL` 设置为桥接地址。
20
+ 3. 将 token 设置为与 `BROWSERD_CHROME_RELAY_EXTENSION_TOKEN` 一致(必填)。
21
+ 4. 点击 **Save**,然后点击 **Reconnect**。
22
+
23
+ 默认 Bridge URL:
24
+
25
+ - `ws://127.0.0.1:9223/bridge`
26
+ - 默认 token(当 daemon 使用默认配置时):`browserctl-relay`
27
+
28
+ ## 弹窗故障排查
29
+
30
+ 弹窗内置了专门的排查区域:
31
+
32
+ - **Run Diagnostics**:对比扩展侧连接状态与 relay 侧 `/browserctl/relay/status`。
33
+ - **Copy Report**:复制包含状态快照与建议动作的 JSON 诊断报告。
34
+ - **Guidance list**:给出常见问题的定向建议(URL 不匹配、token 不匹配、relay 不可达、连接陈旧等)。
35
+
36
+ ## 语言切换
37
+
38
+ 使用弹窗语言选择器可切换 **English** 与 **中文**。
39
+ 所选语言会保存到扩展本地存储,并在下次打开弹窗时自动应用。
@@ -1,5 +1,7 @@
1
1
  # BrowserCtl Relay Extension
2
2
 
3
+ English | [简体中文](README-CN.md)
4
+
3
5
  This extension connects a Chromium-based browser tab layer to the local BrowserCtl extension bridge.
4
6
 
5
7
  ## Load Unpacked
@@ -21,6 +23,7 @@ This extension uses the `debugger` permission to collect console/network telemet
21
23
  Default bridge URL:
22
24
 
23
25
  - `ws://127.0.0.1:9223/bridge`
26
+ - Default token (if daemon uses defaults): `browserctl-relay`
24
27
 
25
28
  ## Troubleshooting In Popup
26
29
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flrande/browserctl",
3
- "version": "0.1.0",
3
+ "version": "0.2.0-dev.12.1",
4
4
  "private": false,
5
5
  "bin": {
6
6
  "browserctl": "bin/browserctl.cjs",
@@ -16,7 +16,8 @@
16
16
  "packages/protocol/src",
17
17
  "packages/transport-mcp-stdio/src",
18
18
  "extensions/chrome-relay",
19
- "scripts/smoke.ps1",
19
+ "INSTALL.md",
20
+ "INSTALL-CN.md",
20
21
  "bin",
21
22
  "README.md",
22
23
  "README-CN.md",
@@ -43,8 +44,9 @@
43
44
  "test:unit": "vitest run --config vitest.config.ts",
44
45
  "test:contract": "vitest run --config vitest.contract.config.ts",
45
46
  "test:e2e": "vitest run --config vitest.e2e.config.ts",
46
- "test:all": "pnpm run test:unit && pnpm run test:contract && pnpm run test:e2e",
47
- "build": "pnpm publish --dry-run --no-git-checks",
47
+ "test:smoke": "vitest run --config vitest.smoke.config.ts",
48
+ "test:all": "pnpm run test:unit && pnpm run test:contract && pnpm run test:e2e && pnpm run test:smoke",
49
+ "build": "npm pack --dry-run",
48
50
  "typecheck": "pnpm exec tsc --noEmit -p tsconfig.typecheck.json",
49
51
  "lint": "node ./scripts/lint.mjs"
50
52
  }
@@ -1,16 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
-
3
- import { getSmokeCommand } from "./smoke";
4
-
5
- describe("smoke wiring", () => {
6
- it("returns a PowerShell command with required flags and default script path", () => {
7
- const command = getSmokeCommand();
8
-
9
- expect(command).toContain("pwsh");
10
- expect(command).toContain("-NoLogo");
11
- expect(command).toContain("-NoProfile");
12
- expect(command).toContain("-NonInteractive");
13
- expect(command).toContain("-File");
14
- expect(command).toContain('".\\scripts\\smoke.ps1"');
15
- });
16
- });
@@ -1,5 +0,0 @@
1
- const DEFAULT_SMOKE_SCRIPT_PATH = ".\\scripts\\smoke.ps1";
2
-
3
- export function getSmokeCommand(scriptPath: string = DEFAULT_SMOKE_SCRIPT_PATH): string {
4
- return `pwsh -NoLogo -NoProfile -NonInteractive -File "${scriptPath}"`;
5
- }
package/scripts/smoke.ps1 DELETED
@@ -1,127 +0,0 @@
1
- $ErrorActionPreference = 'Stop'
2
- Set-StrictMode -Version Latest
3
-
4
- $scriptDir = Split-Path -Parent $PSCommandPath
5
- $repoRoot = Resolve-Path -LiteralPath (Join-Path $scriptDir '..')
6
- $browserdEntry = Join-Path $repoRoot 'apps/browserd/src/main.ts'
7
- $browserctlEntry = Join-Path $repoRoot 'apps/browserctl/src/main.ts'
8
-
9
- $daemonProcess = $null
10
- $daemonPort = 41337
11
- $timeoutMs = 10000
12
- $pollIntervalMs = 250
13
- $pnpmExecutable = if ($IsWindows) { 'pnpm.cmd' } else { 'pnpm' }
14
-
15
- function Invoke-SmokeStatus {
16
- param(
17
- [Parameter(Mandatory = $true)]
18
- [string]$BrowserctlEntry
19
- )
20
-
21
- $previousPort = $env:BROWSERCTL_DAEMON_PORT
22
- $env:BROWSERCTL_DAEMON_PORT = "$daemonPort"
23
-
24
- $statusOutput = & pnpm exec tsx $BrowserctlEntry status --json 2>&1
25
- $exitCode = $LASTEXITCODE
26
- $statusText = [string]::Join([Environment]::NewLine, @($statusOutput))
27
-
28
- if ($null -eq $previousPort) {
29
- Remove-Item Env:\BROWSERCTL_DAEMON_PORT -ErrorAction SilentlyContinue
30
- } else {
31
- $env:BROWSERCTL_DAEMON_PORT = $previousPort
32
- }
33
-
34
- if ($exitCode -ne 0) {
35
- return @{
36
- Ok = $false
37
- Reason = "browserctl status exited with code $exitCode. Output: $statusText"
38
- }
39
- }
40
-
41
- $statusPayload = $null
42
- try {
43
- $statusPayload = $statusText | ConvertFrom-Json -ErrorAction Stop
44
- } catch {
45
- return @{
46
- Ok = $false
47
- Reason = "browserctl status returned invalid JSON. Output: $statusText"
48
- }
49
- }
50
-
51
- if (-not $statusPayload.ok) {
52
- return @{
53
- Ok = $false
54
- Reason = 'browserctl status returned ok=false.'
55
- }
56
- }
57
-
58
- $errorProperty = $statusPayload.PSObject.Properties['error']
59
- if ($null -ne $errorProperty -and $null -ne $errorProperty.Value) {
60
- $errorJson = $errorProperty.Value | ConvertTo-Json -Compress
61
- return @{
62
- Ok = $false
63
- Reason = "browserctl status returned error payload: $errorJson"
64
- }
65
- }
66
-
67
- return @{
68
- Ok = $true
69
- Payload = $statusPayload
70
- }
71
- }
72
-
73
- try {
74
- $previousPort = $env:BROWSERCTL_DAEMON_PORT
75
- $env:BROWSERCTL_DAEMON_PORT = "$daemonPort"
76
- & pnpm exec tsx $browserctlEntry daemon-stop --json *> $null
77
- if ($null -eq $previousPort) {
78
- Remove-Item Env:\BROWSERCTL_DAEMON_PORT -ErrorAction SilentlyContinue
79
- } else {
80
- $env:BROWSERCTL_DAEMON_PORT = $previousPort
81
- }
82
-
83
- $daemonEnv = @{
84
- BROWSERD_TRANSPORT = 'tcp'
85
- BROWSERD_PORT = "$daemonPort"
86
- }
87
-
88
- $daemonProcess = Start-Process -FilePath $pnpmExecutable `
89
- -ArgumentList @('exec', 'tsx', $browserdEntry) `
90
- -WorkingDirectory $repoRoot `
91
- -Environment $daemonEnv `
92
- -PassThru
93
-
94
- $stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
95
- $lastFailure = 'status probe did not run'
96
- $statusResult = $null
97
-
98
- while ($stopwatch.ElapsedMilliseconds -lt $timeoutMs) {
99
- $statusResult = Invoke-SmokeStatus -BrowserctlEntry $browserctlEntry
100
- if ($statusResult.Ok) {
101
- break
102
- }
103
-
104
- $lastFailure = [string]$statusResult.Reason
105
- Start-Sleep -Milliseconds $pollIntervalMs
106
- }
107
-
108
- if ($null -eq $statusResult -or -not $statusResult.Ok) {
109
- $elapsedMs = [int]$stopwatch.ElapsedMilliseconds
110
- throw "Smoke failed: browserctl status --json was not ready within ${timeoutMs}ms (elapsed ${elapsedMs}ms). Last failure: $lastFailure"
111
- }
112
-
113
- Write-Host 'Smoke passed: daemon started and browserctl status --json returned success.'
114
- } finally {
115
- if ($null -ne $daemonProcess -and -not $daemonProcess.HasExited) {
116
- Stop-Process -Id $daemonProcess.Id -ErrorAction SilentlyContinue
117
- }
118
-
119
- $previousPort = $env:BROWSERCTL_DAEMON_PORT
120
- $env:BROWSERCTL_DAEMON_PORT = "$daemonPort"
121
- & pnpm exec tsx $browserctlEntry daemon-stop --json *> $null
122
- if ($null -eq $previousPort) {
123
- Remove-Item Env:\BROWSERCTL_DAEMON_PORT -ErrorAction SilentlyContinue
124
- } else {
125
- $env:BROWSERCTL_DAEMON_PORT = $previousPort
126
- }
127
- }