@flrande/browserctl 0.2.0-dev.12.1 → 0.2.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.
- package/README-CN.md +44 -29
- package/README.md +44 -29
- package/apps/browserctl/src/e2e.test.ts +5 -9
- package/apps/browserctl/src/smoke.test.ts +16 -0
- package/apps/browserctl/src/smoke.ts +5 -0
- package/apps/browserd/src/chrome-relay-extension-bridge.test.ts +31 -6
- package/apps/browserd/src/container.ts +10 -12
- package/apps/browserd/src/main.test.ts +46 -81
- package/apps/browserd/src/tool-matrix.test.ts +5 -9
- package/extensions/chrome-relay/README-CN.md +0 -1
- package/extensions/chrome-relay/README.md +0 -1
- package/package.json +4 -6
- package/scripts/smoke.ps1 +127 -0
- package/INSTALL-CN.md +0 -28
- package/INSTALL.md +0 -28
- package/apps/browserctl/src/smoke.e2e.test.ts +0 -97
- package/apps/browserctl/src/test-port.ts +0 -26
- package/apps/browserd/src/test-port.ts +0 -26
package/README-CN.md
CHANGED
|
@@ -2,50 +2,65 @@
|
|
|
2
2
|
|
|
3
3
|
[English](README.md) | 简体中文
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
最快路径:通过 relay 扩展直接控制你已打开的 Edge/Chrome。
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## 扩展模式最简上手(推荐)
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
前置条件:
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
平台快捷入口:
|
|
16
|
-
|
|
17
|
-
- Codex:`https://raw.githubusercontent.com/Flrande/browserctl/refs/heads/master/.codex/INSTALL.md`
|
|
18
|
-
- Claude Code:`https://raw.githubusercontent.com/Flrande/browserctl/refs/heads/master/.claude-plugin/INSTALL.md`
|
|
19
|
-
- Cursor:`https://raw.githubusercontent.com/Flrande/browserctl/refs/heads/master/.cursor-plugin/INSTALL.md`
|
|
20
|
-
- OpenCode:`https://raw.githubusercontent.com/Flrande/browserctl/refs/heads/master/.opencode/INSTALL.md`
|
|
11
|
+
- Node.js 22+
|
|
12
|
+
- npm 或 pnpm
|
|
13
|
+
- PowerShell 7 (`pwsh`)
|
|
14
|
+
- Edge 或 Chrome
|
|
21
15
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
- 名称:`browserctl`
|
|
25
|
-
- 路径:`skills/browserctl`
|
|
26
|
-
|
|
27
|
-
## 浏览器快速开始(扩展默认)
|
|
16
|
+
1. 安装已发布包:
|
|
28
17
|
|
|
29
18
|
```powershell
|
|
30
19
|
npm install --global @flrande/browserctl
|
|
20
|
+
# 或:pnpm add --global @flrande/browserctl
|
|
21
|
+
```
|
|
31
22
|
|
|
23
|
+
2. 用扩展 relay 模式启动 daemon:
|
|
24
|
+
|
|
25
|
+
```powershell
|
|
32
26
|
$env:BROWSERD_DEFAULT_DRIVER = "chrome-relay"
|
|
33
27
|
$env:BROWSERD_CHROME_RELAY_MODE = "extension"
|
|
34
28
|
$env:BROWSERD_CHROME_RELAY_URL = "http://127.0.0.1:9223"
|
|
35
|
-
$env:BROWSERD_CHROME_RELAY_EXTENSION_TOKEN = "
|
|
36
|
-
|
|
29
|
+
$env:BROWSERD_CHROME_RELAY_EXTENSION_TOKEN = "relay-secret"
|
|
37
30
|
browserctl daemon-stop --json
|
|
38
31
|
browserctl daemon-start --json
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
3. 加载扩展:
|
|
35
|
+
|
|
36
|
+
1. 打开 `edge://extensions` 或 `chrome://extensions`。
|
|
37
|
+
2. 开启开发者模式。
|
|
38
|
+
3. 选择“加载已解压的扩展程序”,目录为 `extensions/chrome-relay`。
|
|
39
|
+
4. 在扩展弹窗中设置:
|
|
40
|
+
- `Bridge URL`: `ws://127.0.0.1:9223/bridge`
|
|
41
|
+
- `Token`: `relay-secret`
|
|
42
|
+
5. 点击 `Save`,再点击 `Reconnect`。
|
|
43
|
+
|
|
44
|
+
4. 连接验证与首个命令:
|
|
45
|
+
|
|
46
|
+
```powershell
|
|
39
47
|
browserctl status --json --session relay --profile chrome-relay
|
|
48
|
+
browserctl tab-open --json --session relay --profile chrome-relay https://example.com
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## 30 秒排查
|
|
52
|
+
|
|
53
|
+
```powershell
|
|
54
|
+
browserctl daemon-status --json
|
|
55
|
+
Invoke-WebRequest -UseBasicParsing -Uri "http://127.0.0.1:9223/browserctl/relay/status" -TimeoutSec 3
|
|
40
56
|
```
|
|
41
57
|
|
|
58
|
+
若 `connected` 为 `false`,重新打开扩展弹窗并点击 `Reconnect`。
|
|
59
|
+
|
|
42
60
|
## 完整文档
|
|
43
61
|
|
|
44
|
-
- [
|
|
45
|
-
- [
|
|
46
|
-
- [
|
|
47
|
-
- [
|
|
48
|
-
|
|
49
|
-
- [CLI 参考(中文)](docs/cli-reference-cn.md)
|
|
50
|
-
- [Maintainer Onboarding (English)](docs/maintainer-onboarding.md)
|
|
51
|
-
- [维护者上手文档(中文)](docs/maintainer-onboarding-cn.md)
|
|
62
|
+
- [Full User Guide (English)](docs/user-guide.md)
|
|
63
|
+
- [完整使用文档(中文)](docs/user-guide-cn.md)
|
|
64
|
+
- [Chrome Relay Extension README (English)](extensions/chrome-relay/README.md)
|
|
65
|
+
- [Chrome Relay 扩展文档(中文)](extensions/chrome-relay/README-CN.md)
|
|
66
|
+
|
package/README.md
CHANGED
|
@@ -2,50 +2,65 @@
|
|
|
2
2
|
|
|
3
3
|
English | [简体中文](README-CN.md)
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Fastest path: control your current Edge/Chrome through the relay extension.
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## Extension Quick Start (Recommended)
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Prerequisites:
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
Platform shortcuts:
|
|
16
|
-
|
|
17
|
-
- Codex: `https://raw.githubusercontent.com/Flrande/browserctl/refs/heads/master/.codex/INSTALL.md`
|
|
18
|
-
- Claude Code: `https://raw.githubusercontent.com/Flrande/browserctl/refs/heads/master/.claude-plugin/INSTALL.md`
|
|
19
|
-
- Cursor: `https://raw.githubusercontent.com/Flrande/browserctl/refs/heads/master/.cursor-plugin/INSTALL.md`
|
|
20
|
-
- OpenCode: `https://raw.githubusercontent.com/Flrande/browserctl/refs/heads/master/.opencode/INSTALL.md`
|
|
11
|
+
- Node.js 22+
|
|
12
|
+
- npm or pnpm
|
|
13
|
+
- PowerShell 7 (`pwsh`)
|
|
14
|
+
- Edge or Chrome
|
|
21
15
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
- Name: `browserctl`
|
|
25
|
-
- Path: `skills/browserctl`
|
|
26
|
-
|
|
27
|
-
## Browser Quick Start (Extension Default)
|
|
16
|
+
1. Install the released package:
|
|
28
17
|
|
|
29
18
|
```powershell
|
|
30
19
|
npm install --global @flrande/browserctl
|
|
20
|
+
# or: pnpm add --global @flrande/browserctl
|
|
21
|
+
```
|
|
31
22
|
|
|
23
|
+
2. Start daemon in extension relay mode:
|
|
24
|
+
|
|
25
|
+
```powershell
|
|
32
26
|
$env:BROWSERD_DEFAULT_DRIVER = "chrome-relay"
|
|
33
27
|
$env:BROWSERD_CHROME_RELAY_MODE = "extension"
|
|
34
28
|
$env:BROWSERD_CHROME_RELAY_URL = "http://127.0.0.1:9223"
|
|
35
|
-
$env:BROWSERD_CHROME_RELAY_EXTENSION_TOKEN = "
|
|
36
|
-
|
|
29
|
+
$env:BROWSERD_CHROME_RELAY_EXTENSION_TOKEN = "relay-secret"
|
|
37
30
|
browserctl daemon-stop --json
|
|
38
31
|
browserctl daemon-start --json
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
3. Load extension:
|
|
35
|
+
|
|
36
|
+
1. Open `edge://extensions` or `chrome://extensions`.
|
|
37
|
+
2. Enable Developer mode.
|
|
38
|
+
3. Load unpacked extension from `extensions/chrome-relay`.
|
|
39
|
+
4. In extension popup, set:
|
|
40
|
+
- `Bridge URL`: `ws://127.0.0.1:9223/bridge`
|
|
41
|
+
- `Token`: `relay-secret`
|
|
42
|
+
5. Click `Save` then `Reconnect`.
|
|
43
|
+
|
|
44
|
+
4. Verify and run first command:
|
|
45
|
+
|
|
46
|
+
```powershell
|
|
39
47
|
browserctl status --json --session relay --profile chrome-relay
|
|
48
|
+
browserctl tab-open --json --session relay --profile chrome-relay https://example.com
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## 30-Second Troubleshooting
|
|
52
|
+
|
|
53
|
+
```powershell
|
|
54
|
+
browserctl daemon-status --json
|
|
55
|
+
Invoke-WebRequest -UseBasicParsing -Uri "http://127.0.0.1:9223/browserctl/relay/status" -TimeoutSec 3
|
|
40
56
|
```
|
|
41
57
|
|
|
58
|
+
If `connected` is `false`, reopen extension popup and click `Reconnect`.
|
|
59
|
+
|
|
42
60
|
## Full Docs
|
|
43
61
|
|
|
44
|
-
- [
|
|
45
|
-
- [
|
|
46
|
-
- [
|
|
47
|
-
- [
|
|
48
|
-
|
|
49
|
-
- [CLI 参考(中文)](docs/cli-reference-cn.md)
|
|
50
|
-
- [Maintainer Onboarding (English)](docs/maintainer-onboarding.md)
|
|
51
|
-
- [维护者上手文档(中文)](docs/maintainer-onboarding-cn.md)
|
|
62
|
+
- [Full User Guide (English)](docs/user-guide.md)
|
|
63
|
+
- [完整使用文档(中文)](docs/user-guide-cn.md)
|
|
64
|
+
- [Chrome Relay Extension README (English)](extensions/chrome-relay/README.md)
|
|
65
|
+
- [Chrome Relay 扩展文档(中文)](extensions/chrome-relay/README-CN.md)
|
|
66
|
+
|
|
@@ -2,10 +2,9 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
|
2
2
|
|
|
3
3
|
import { stopDaemon } from "./daemon-client";
|
|
4
4
|
import { EXIT_CODES, runCli } from "./main";
|
|
5
|
-
import { reserveLoopbackPort } from "./test-port";
|
|
6
5
|
|
|
6
|
+
const TEST_DAEMON_PORT = "42491";
|
|
7
7
|
const TEST_SESSION_ID = "session:e2e";
|
|
8
|
-
let testDaemonPort = 0;
|
|
9
8
|
|
|
10
9
|
function createIoCapture() {
|
|
11
10
|
const state = {
|
|
@@ -35,20 +34,17 @@ function parseJsonLine(state: { stdout: string }): Record<string, unknown> {
|
|
|
35
34
|
}
|
|
36
35
|
|
|
37
36
|
beforeEach(async () => {
|
|
38
|
-
|
|
39
|
-
process.env.BROWSERCTL_DAEMON_PORT = String(testDaemonPort);
|
|
37
|
+
process.env.BROWSERCTL_DAEMON_PORT = TEST_DAEMON_PORT;
|
|
40
38
|
process.env.BROWSERD_MANAGED_LOCAL_ENABLED = "false";
|
|
41
39
|
process.env.BROWSERD_DEFAULT_DRIVER = "managed";
|
|
42
|
-
|
|
43
|
-
await stopDaemon(testDaemonPort);
|
|
40
|
+
await stopDaemon(Number.parseInt(TEST_DAEMON_PORT, 10));
|
|
44
41
|
});
|
|
45
42
|
|
|
46
43
|
afterEach(async () => {
|
|
47
|
-
await stopDaemon(
|
|
44
|
+
await stopDaemon(Number.parseInt(TEST_DAEMON_PORT, 10));
|
|
48
45
|
delete process.env.BROWSERCTL_DAEMON_PORT;
|
|
49
46
|
delete process.env.BROWSERD_MANAGED_LOCAL_ENABLED;
|
|
50
47
|
delete process.env.BROWSERD_DEFAULT_DRIVER;
|
|
51
|
-
delete process.env.BROWSERD_CHROME_RELAY_MODE;
|
|
52
48
|
});
|
|
53
49
|
|
|
54
50
|
describe("browserctl e2e", () => {
|
|
@@ -61,7 +57,7 @@ describe("browserctl e2e", () => {
|
|
|
61
57
|
expect(startPayload.ok).toBe(true);
|
|
62
58
|
const startData = startPayload.data as Record<string, unknown>;
|
|
63
59
|
expect(startData.running).toBe(true);
|
|
64
|
-
expect(startData.port).toBe(
|
|
60
|
+
expect(startData.port).toBe(Number.parseInt(TEST_DAEMON_PORT, 10));
|
|
65
61
|
|
|
66
62
|
const openCapture = createIoCapture();
|
|
67
63
|
const openExitCode = await runCli(
|
|
@@ -0,0 +1,16 @@
|
|
|
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,8 +1,33 @@
|
|
|
1
|
+
import { createServer } from "node:net";
|
|
2
|
+
|
|
1
3
|
import { afterEach, describe, expect, it } from "vitest";
|
|
2
4
|
import { WebSocket } from "ws";
|
|
3
5
|
|
|
4
6
|
import { createChromeRelayExtensionBridge } from "./chrome-relay-extension-bridge";
|
|
5
|
-
|
|
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
|
+
}
|
|
6
31
|
|
|
7
32
|
async function waitForCondition(predicate: () => boolean, timeoutMs = 1000): Promise<void> {
|
|
8
33
|
const start = Date.now();
|
|
@@ -36,7 +61,7 @@ afterEach(async () => {
|
|
|
36
61
|
|
|
37
62
|
describe("createChromeRelayExtensionBridge", () => {
|
|
38
63
|
it("requires token configuration for extension bridge startup", async () => {
|
|
39
|
-
const port = await
|
|
64
|
+
const port = await reservePort();
|
|
40
65
|
|
|
41
66
|
expect(() =>
|
|
42
67
|
createChromeRelayExtensionBridge({
|
|
@@ -46,7 +71,7 @@ describe("createChromeRelayExtensionBridge", () => {
|
|
|
46
71
|
});
|
|
47
72
|
|
|
48
73
|
it("sends requests to extension client and receives responses", async () => {
|
|
49
|
-
const port = await
|
|
74
|
+
const port = await reservePort();
|
|
50
75
|
const bridge = createChromeRelayExtensionBridge({
|
|
51
76
|
relayUrl: `http://127.0.0.1:${port}`,
|
|
52
77
|
token: "secret-token",
|
|
@@ -109,7 +134,7 @@ describe("createChromeRelayExtensionBridge", () => {
|
|
|
109
134
|
});
|
|
110
135
|
|
|
111
136
|
it("rejects invoke when extension is not connected", async () => {
|
|
112
|
-
const port = await
|
|
137
|
+
const port = await reservePort();
|
|
113
138
|
const bridge = createChromeRelayExtensionBridge({
|
|
114
139
|
relayUrl: `http://127.0.0.1:${port}`,
|
|
115
140
|
token: "secret-token",
|
|
@@ -123,7 +148,7 @@ describe("createChromeRelayExtensionBridge", () => {
|
|
|
123
148
|
});
|
|
124
149
|
|
|
125
150
|
it("enforces token validation for extension websocket connection", async () => {
|
|
126
|
-
const port = await
|
|
151
|
+
const port = await reservePort();
|
|
127
152
|
const bridge = createChromeRelayExtensionBridge({
|
|
128
153
|
relayUrl: `http://127.0.0.1:${port}`,
|
|
129
154
|
token: "secret-token",
|
|
@@ -163,7 +188,7 @@ describe("createChromeRelayExtensionBridge", () => {
|
|
|
163
188
|
});
|
|
164
189
|
|
|
165
190
|
it("forwards extension event envelopes to bridge listeners", async () => {
|
|
166
|
-
const port = await
|
|
191
|
+
const port = await reservePort();
|
|
167
192
|
const bridge = createChromeRelayExtensionBridge({
|
|
168
193
|
relayUrl: `http://127.0.0.1:${port}`,
|
|
169
194
|
token: "secret-token",
|
|
@@ -53,11 +53,9 @@ 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";
|
|
57
56
|
const DEFAULT_MANAGED_LOCAL_BROWSER_NAME: ManagedLocalBrowserName = "chromium";
|
|
58
57
|
const DEFAULT_MANAGED_LOCAL_HEADLESS = true;
|
|
59
|
-
const DEFAULT_CHROME_RELAY_MODE: ChromeRelayMode = "
|
|
60
|
-
const DEFAULT_CHROME_RELAY_EXTENSION_TOKEN = "browserctl-relay";
|
|
58
|
+
const DEFAULT_CHROME_RELAY_MODE: ChromeRelayMode = "cdp";
|
|
61
59
|
const DEFAULT_CHROME_RELAY_EXTENSION_REQUEST_TIMEOUT_MS = 5_000;
|
|
62
60
|
const DEFAULT_NETWORK_WAIT_TIMEOUT_MS = 10_000;
|
|
63
61
|
const DEFAULT_NETWORK_WAIT_POLL_MS = 200;
|
|
@@ -183,11 +181,7 @@ function parseManagedLocalBrowserName(value: string | undefined): ManagedLocalBr
|
|
|
183
181
|
|
|
184
182
|
function parseChromeRelayMode(value: string | undefined): ChromeRelayMode {
|
|
185
183
|
const normalizedValue = value?.trim().toLowerCase();
|
|
186
|
-
|
|
187
|
-
return normalizedValue;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
return DEFAULT_CHROME_RELAY_MODE;
|
|
184
|
+
return normalizedValue === "extension" ? "extension" : DEFAULT_CHROME_RELAY_MODE;
|
|
191
185
|
}
|
|
192
186
|
|
|
193
187
|
export type BrowserdContainer = {
|
|
@@ -204,12 +198,16 @@ export function loadBrowserdConfig(
|
|
|
204
198
|
): BrowserdConfig {
|
|
205
199
|
const managedLocalEnabled = parseBooleanFlag(env.BROWSERD_MANAGED_LOCAL_ENABLED, true);
|
|
206
200
|
const chromeRelayMode = parseChromeRelayMode(env.BROWSERD_CHROME_RELAY_MODE);
|
|
207
|
-
const chromeRelayExtensionToken =
|
|
208
|
-
|
|
209
|
-
|
|
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
|
+
}
|
|
210
207
|
|
|
211
208
|
const defaultDriver =
|
|
212
|
-
resolveNonEmptyString(env.BROWSERD_DEFAULT_DRIVER) ??
|
|
209
|
+
resolveNonEmptyString(env.BROWSERD_DEFAULT_DRIVER) ??
|
|
210
|
+
(managedLocalEnabled ? MANAGED_LOCAL_DRIVER_KEY : DEFAULT_DRIVER_KEY);
|
|
213
211
|
|
|
214
212
|
return {
|
|
215
213
|
chromeRelayUrl: env.BROWSERD_CHROME_RELAY_URL ?? "http://127.0.0.1:9223",
|
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
2
|
import { PassThrough } from "node:stream";
|
|
3
3
|
import { createConnection } from "node:net";
|
|
4
4
|
|
|
5
5
|
import { createContainer, loadBrowserdConfig } from "./container";
|
|
6
6
|
import { bootstrapBrowserd } from "./bootstrap";
|
|
7
|
-
import { reserveLoopbackPort } from "./test-port";
|
|
8
7
|
|
|
9
8
|
function waitForNextJsonLine(stream: PassThrough): Promise<Record<string, unknown>> {
|
|
10
9
|
return new Promise((resolve, reject) => {
|
|
@@ -79,33 +78,11 @@ function sendTcpToolRequest(
|
|
|
79
78
|
});
|
|
80
79
|
}
|
|
81
80
|
|
|
82
|
-
let testRelayPort = 0;
|
|
83
|
-
let testTcpPort = 0;
|
|
84
|
-
|
|
85
|
-
beforeEach(async () => {
|
|
86
|
-
testRelayPort = await reserveLoopbackPort();
|
|
87
|
-
testTcpPort = await reserveLoopbackPort();
|
|
88
|
-
while (testTcpPort === testRelayPort) {
|
|
89
|
-
testTcpPort = await reserveLoopbackPort();
|
|
90
|
-
}
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
function createTestEnv(
|
|
94
|
-
overrides: Record<string, string | undefined> = {}
|
|
95
|
-
): Record<string, string | undefined> {
|
|
96
|
-
return {
|
|
97
|
-
BROWSERD_CHROME_RELAY_URL: `http://127.0.0.1:${testRelayPort}`,
|
|
98
|
-
...overrides
|
|
99
|
-
};
|
|
100
|
-
}
|
|
101
|
-
|
|
102
81
|
describe("browserd container", () => {
|
|
103
82
|
it("uses secure defaults for new security config fields", () => {
|
|
104
83
|
const config = loadBrowserdConfig({});
|
|
105
84
|
|
|
106
|
-
expect(config.defaultDriver).toBe("
|
|
107
|
-
expect(config.chromeRelayMode).toBe("extension");
|
|
108
|
-
expect(config.chromeRelayExtensionToken).toBe("browserctl-relay");
|
|
85
|
+
expect(config.defaultDriver).toBe("managed-local");
|
|
109
86
|
expect(config.managedLocalEnabled).toBe(true);
|
|
110
87
|
expect(config.uploadRoot).toBeUndefined();
|
|
111
88
|
expect(config.downloadRoot).toBeUndefined();
|
|
@@ -114,13 +91,12 @@ describe("browserd container", () => {
|
|
|
114
91
|
});
|
|
115
92
|
|
|
116
93
|
it("registers managed-local, managed, chrome-relay, and remote-cdp drivers by default", () => {
|
|
117
|
-
const c = createContainer(
|
|
94
|
+
const c = createContainer();
|
|
118
95
|
|
|
119
96
|
expect(c.drivers.has("managed-local")).toBe(true);
|
|
120
97
|
expect(c.drivers.has("managed")).toBe(true);
|
|
121
98
|
expect(c.drivers.has("chrome-relay")).toBe(true);
|
|
122
99
|
expect(c.drivers.has("remote-cdp")).toBe(true);
|
|
123
|
-
c.close();
|
|
124
100
|
});
|
|
125
101
|
|
|
126
102
|
it("uses env override in loadBrowserdConfig", () => {
|
|
@@ -134,7 +110,7 @@ describe("browserd container", () => {
|
|
|
134
110
|
|
|
135
111
|
expect(config.remoteCdpUrl).toBe("http://127.0.0.1:9333/devtools/browser/override");
|
|
136
112
|
expect(config.chromeRelayUrl).toBe("http://127.0.0.1:9223");
|
|
137
|
-
expect(config.defaultDriver).toBe("
|
|
113
|
+
expect(config.defaultDriver).toBe("managed-local");
|
|
138
114
|
expect(config.managedLocalEnabled).toBe(true);
|
|
139
115
|
expect(config.uploadRoot).toBe("C:\\safe\\uploads");
|
|
140
116
|
expect(config.downloadRoot).toBe("C:\\safe\\downloads");
|
|
@@ -156,42 +132,31 @@ describe("browserd container", () => {
|
|
|
156
132
|
expect(config.chromeRelayExtensionRequestTimeoutMs).toBe(7000);
|
|
157
133
|
});
|
|
158
134
|
|
|
159
|
-
it("
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
it("uses default extension relay token when extension mode is enabled", () => {
|
|
168
|
-
const config = loadBrowserdConfig({
|
|
169
|
-
BROWSERD_CHROME_RELAY_MODE: "extension"
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
expect(config.chromeRelayMode).toBe("extension");
|
|
173
|
-
expect(config.chromeRelayExtensionToken).toBe("browserctl-relay");
|
|
135
|
+
it("requires extension relay token when extension mode is enabled", () => {
|
|
136
|
+
expect(() =>
|
|
137
|
+
loadBrowserdConfig({
|
|
138
|
+
BROWSERD_CHROME_RELAY_MODE: "extension"
|
|
139
|
+
})
|
|
140
|
+
).toThrow("BROWSERD_CHROME_RELAY_EXTENSION_TOKEN");
|
|
174
141
|
});
|
|
175
142
|
|
|
176
|
-
it("
|
|
143
|
+
it("defaults to managed when managed-local is explicitly disabled", () => {
|
|
177
144
|
const config = loadBrowserdConfig({
|
|
178
145
|
BROWSERD_MANAGED_LOCAL_ENABLED: "false"
|
|
179
146
|
});
|
|
180
147
|
|
|
181
148
|
expect(config.managedLocalEnabled).toBe(false);
|
|
182
|
-
expect(config.defaultDriver).toBe("
|
|
149
|
+
expect(config.defaultDriver).toBe("managed");
|
|
183
150
|
});
|
|
184
151
|
|
|
185
152
|
it("does not register managed-local driver when explicitly disabled", () => {
|
|
186
153
|
const c = createContainer(
|
|
187
154
|
loadBrowserdConfig({
|
|
188
|
-
...createTestEnv(),
|
|
189
155
|
BROWSERD_MANAGED_LOCAL_ENABLED: "false"
|
|
190
156
|
})
|
|
191
157
|
);
|
|
192
158
|
|
|
193
159
|
expect(c.drivers.has("managed-local")).toBe(false);
|
|
194
|
-
c.close();
|
|
195
160
|
});
|
|
196
161
|
});
|
|
197
162
|
|
|
@@ -200,7 +165,7 @@ describe("browserd bootstrap", () => {
|
|
|
200
165
|
const input = new PassThrough();
|
|
201
166
|
const output = new PassThrough();
|
|
202
167
|
|
|
203
|
-
const runtime = bootstrapBrowserd({
|
|
168
|
+
const runtime = bootstrapBrowserd({ input, output });
|
|
204
169
|
|
|
205
170
|
expect(runtime.mcpStdioStarted).toBe(true);
|
|
206
171
|
expect(runtime.container.drivers.has("managed")).toBe(true);
|
|
@@ -214,13 +179,13 @@ describe("browserd bootstrap", () => {
|
|
|
214
179
|
});
|
|
215
180
|
|
|
216
181
|
it("supports tcp transport mode for persistent daemon use", async () => {
|
|
217
|
-
const port =
|
|
182
|
+
const port = 41419;
|
|
218
183
|
const runtime = bootstrapBrowserd({
|
|
219
|
-
env:
|
|
184
|
+
env: {
|
|
220
185
|
BROWSERD_TRANSPORT: "tcp",
|
|
221
186
|
BROWSERD_PORT: String(port),
|
|
222
187
|
BROWSERD_AUTH_TOKEN: "tcp-token"
|
|
223
|
-
}
|
|
188
|
+
}
|
|
224
189
|
});
|
|
225
190
|
|
|
226
191
|
const response = await sendTcpToolRequest(port, {
|
|
@@ -250,10 +215,10 @@ describe("browserd bootstrap", () => {
|
|
|
250
215
|
try {
|
|
251
216
|
expect(() => {
|
|
252
217
|
runtime = bootstrapBrowserd({
|
|
253
|
-
env:
|
|
218
|
+
env: {
|
|
254
219
|
BROWSERD_TRANSPORT: "tcp",
|
|
255
|
-
BROWSERD_PORT:
|
|
256
|
-
}
|
|
220
|
+
BROWSERD_PORT: "41420"
|
|
221
|
+
}
|
|
257
222
|
});
|
|
258
223
|
}).toThrow("BROWSERD_AUTH_TOKEN");
|
|
259
224
|
} finally {
|
|
@@ -264,7 +229,7 @@ describe("browserd bootstrap", () => {
|
|
|
264
229
|
it("processes line-delimited requests and writes response with id", async () => {
|
|
265
230
|
const input = new PassThrough();
|
|
266
231
|
const output = new PassThrough();
|
|
267
|
-
const runtime = bootstrapBrowserd({
|
|
232
|
+
const runtime = bootstrapBrowserd({ input, output, stdioProtocol: "legacy" });
|
|
268
233
|
|
|
269
234
|
const response = await sendToolRequest(input, output, {
|
|
270
235
|
id: "request-1",
|
|
@@ -282,7 +247,7 @@ describe("browserd bootstrap", () => {
|
|
|
282
247
|
expect(response.data).toMatchObject({
|
|
283
248
|
kind: "browserd",
|
|
284
249
|
ready: true,
|
|
285
|
-
driver: "
|
|
250
|
+
driver: "managed-local"
|
|
286
251
|
});
|
|
287
252
|
|
|
288
253
|
runtime.close();
|
|
@@ -294,10 +259,10 @@ describe("browserd bootstrap", () => {
|
|
|
294
259
|
const input = new PassThrough();
|
|
295
260
|
const output = new PassThrough();
|
|
296
261
|
const runtime = bootstrapBrowserd({
|
|
297
|
-
env:
|
|
262
|
+
env: {
|
|
298
263
|
BROWSERD_MANAGED_LOCAL_ENABLED: "true",
|
|
299
264
|
BROWSERD_DEFAULT_DRIVER: "managed-local"
|
|
300
|
-
}
|
|
265
|
+
},
|
|
301
266
|
input,
|
|
302
267
|
output,
|
|
303
268
|
stdioProtocol: "legacy"
|
|
@@ -332,10 +297,10 @@ describe("browserd bootstrap", () => {
|
|
|
332
297
|
const input = new PassThrough();
|
|
333
298
|
const output = new PassThrough();
|
|
334
299
|
const runtime = bootstrapBrowserd({
|
|
335
|
-
env:
|
|
300
|
+
env: {
|
|
336
301
|
BROWSERD_DEFAULT_DRIVER: "managed-local",
|
|
337
302
|
BROWSERD_MANAGED_LOCAL_ENABLED: "false"
|
|
338
|
-
}
|
|
303
|
+
},
|
|
339
304
|
input,
|
|
340
305
|
output,
|
|
341
306
|
stdioProtocol: "legacy"
|
|
@@ -366,9 +331,9 @@ describe("browserd bootstrap", () => {
|
|
|
366
331
|
const input = new PassThrough();
|
|
367
332
|
const output = new PassThrough();
|
|
368
333
|
const runtime = bootstrapBrowserd({
|
|
369
|
-
env:
|
|
334
|
+
env: {
|
|
370
335
|
BROWSERD_AUTH_TOKEN: "secret-token"
|
|
371
|
-
}
|
|
336
|
+
},
|
|
372
337
|
input,
|
|
373
338
|
output,
|
|
374
339
|
stdioProtocol: "legacy"
|
|
@@ -397,9 +362,9 @@ describe("browserd bootstrap", () => {
|
|
|
397
362
|
const input = new PassThrough();
|
|
398
363
|
const output = new PassThrough();
|
|
399
364
|
const runtime = bootstrapBrowserd({
|
|
400
|
-
env:
|
|
365
|
+
env: {
|
|
401
366
|
BROWSERD_AUTH_TOKEN: "secret-token"
|
|
402
|
-
}
|
|
367
|
+
},
|
|
403
368
|
input,
|
|
404
369
|
output,
|
|
405
370
|
stdioProtocol: "legacy"
|
|
@@ -429,11 +394,11 @@ describe("browserd bootstrap", () => {
|
|
|
429
394
|
const input = new PassThrough();
|
|
430
395
|
const output = new PassThrough();
|
|
431
396
|
const runtime = bootstrapBrowserd({
|
|
432
|
-
env:
|
|
397
|
+
env: {
|
|
433
398
|
BROWSERD_DEFAULT_DRIVER: "managed",
|
|
434
399
|
BROWSERD_AUTH_TOKEN: "secret-token",
|
|
435
400
|
BROWSERD_AUTH_SCOPES: "read"
|
|
436
|
-
}
|
|
401
|
+
},
|
|
437
402
|
input,
|
|
438
403
|
output,
|
|
439
404
|
stdioProtocol: "legacy"
|
|
@@ -464,11 +429,11 @@ describe("browserd bootstrap", () => {
|
|
|
464
429
|
const input = new PassThrough();
|
|
465
430
|
const output = new PassThrough();
|
|
466
431
|
const runtime = bootstrapBrowserd({
|
|
467
|
-
env:
|
|
432
|
+
env: {
|
|
468
433
|
BROWSERD_DEFAULT_DRIVER: "managed",
|
|
469
434
|
BROWSERD_AUTH_TOKEN: "secret-token",
|
|
470
435
|
BROWSERD_AUTH_SCOPES: "read,act"
|
|
471
|
-
}
|
|
436
|
+
},
|
|
472
437
|
input,
|
|
473
438
|
output,
|
|
474
439
|
stdioProtocol: "legacy"
|
|
@@ -529,10 +494,10 @@ describe("browserd bootstrap", () => {
|
|
|
529
494
|
const input = new PassThrough();
|
|
530
495
|
const output = new PassThrough();
|
|
531
496
|
const runtime = bootstrapBrowserd({
|
|
532
|
-
env:
|
|
497
|
+
env: {
|
|
533
498
|
BROWSERD_DEFAULT_DRIVER: "managed",
|
|
534
499
|
BROWSERD_UPLOAD_ROOT: "C:\\allowed\\uploads"
|
|
535
|
-
}
|
|
500
|
+
},
|
|
536
501
|
input,
|
|
537
502
|
output,
|
|
538
503
|
stdioProtocol: "legacy"
|
|
@@ -574,9 +539,9 @@ describe("browserd bootstrap", () => {
|
|
|
574
539
|
const input = new PassThrough();
|
|
575
540
|
const output = new PassThrough();
|
|
576
541
|
const runtime = bootstrapBrowserd({
|
|
577
|
-
env:
|
|
542
|
+
env: {
|
|
578
543
|
BROWSERD_DEFAULT_DRIVER: "managed"
|
|
579
|
-
}
|
|
544
|
+
},
|
|
580
545
|
input,
|
|
581
546
|
output,
|
|
582
547
|
stdioProtocol: "legacy"
|
|
@@ -619,10 +584,10 @@ describe("browserd bootstrap", () => {
|
|
|
619
584
|
const input = new PassThrough();
|
|
620
585
|
const output = new PassThrough();
|
|
621
586
|
const runtime = bootstrapBrowserd({
|
|
622
|
-
env:
|
|
587
|
+
env: {
|
|
623
588
|
BROWSERD_DEFAULT_DRIVER: "managed",
|
|
624
589
|
BROWSERD_DOWNLOAD_ROOT: "C:\\allowed\\downloads"
|
|
625
|
-
}
|
|
590
|
+
},
|
|
626
591
|
input,
|
|
627
592
|
output,
|
|
628
593
|
stdioProtocol: "legacy"
|
|
@@ -681,9 +646,9 @@ describe("browserd bootstrap", () => {
|
|
|
681
646
|
const input = new PassThrough();
|
|
682
647
|
const output = new PassThrough();
|
|
683
648
|
const runtime = bootstrapBrowserd({
|
|
684
|
-
env:
|
|
649
|
+
env: {
|
|
685
650
|
BROWSERD_DEFAULT_DRIVER: "managed"
|
|
686
|
-
}
|
|
651
|
+
},
|
|
687
652
|
input,
|
|
688
653
|
output,
|
|
689
654
|
stdioProtocol: "legacy"
|
|
@@ -725,9 +690,9 @@ describe("browserd bootstrap", () => {
|
|
|
725
690
|
const input = new PassThrough();
|
|
726
691
|
const output = new PassThrough();
|
|
727
692
|
const runtime = bootstrapBrowserd({
|
|
728
|
-
env:
|
|
693
|
+
env: {
|
|
729
694
|
BROWSERD_DEFAULT_DRIVER: "managed"
|
|
730
|
-
}
|
|
695
|
+
},
|
|
731
696
|
input,
|
|
732
697
|
output,
|
|
733
698
|
stdioProtocol: "legacy"
|
|
@@ -774,9 +739,9 @@ describe("browserd bootstrap", () => {
|
|
|
774
739
|
const input = new PassThrough();
|
|
775
740
|
const output = new PassThrough();
|
|
776
741
|
const runtime = bootstrapBrowserd({
|
|
777
|
-
env:
|
|
742
|
+
env: {
|
|
778
743
|
BROWSERD_DEFAULT_DRIVER: "managed"
|
|
779
|
-
}
|
|
744
|
+
},
|
|
780
745
|
input,
|
|
781
746
|
output,
|
|
782
747
|
stdioProtocol: "legacy"
|
|
@@ -854,7 +819,7 @@ describe("browserd bootstrap", () => {
|
|
|
854
819
|
it("preserves id and trace/session metadata when queue-level error handling runs", async () => {
|
|
855
820
|
const input = new PassThrough();
|
|
856
821
|
const output = new PassThrough();
|
|
857
|
-
const runtime = bootstrapBrowserd({
|
|
822
|
+
const runtime = bootstrapBrowserd({ input, output, stdioProtocol: "legacy" });
|
|
858
823
|
|
|
859
824
|
const originalWrite = output.write.bind(output);
|
|
860
825
|
let shouldThrow = true;
|
|
@@ -3,7 +3,6 @@ import { PassThrough } from "node:stream";
|
|
|
3
3
|
import { describe, expect, it } from "vitest";
|
|
4
4
|
|
|
5
5
|
import { bootstrapBrowserd } from "./bootstrap";
|
|
6
|
-
import { reserveLoopbackPort } from "./test-port";
|
|
7
6
|
|
|
8
7
|
function waitForNextJsonLine(stream: PassThrough): Promise<Record<string, unknown>> {
|
|
9
8
|
return new Promise((resolve, reject) => {
|
|
@@ -41,15 +40,12 @@ function sendToolRequest(
|
|
|
41
40
|
return responsePromise;
|
|
42
41
|
}
|
|
43
42
|
|
|
44
|
-
|
|
45
|
-
const relayPort = await reserveLoopbackPort();
|
|
46
|
-
|
|
43
|
+
function createManagedLegacyRuntime() {
|
|
47
44
|
const input = new PassThrough();
|
|
48
45
|
const output = new PassThrough();
|
|
49
46
|
const runtime = bootstrapBrowserd({
|
|
50
47
|
env: {
|
|
51
|
-
BROWSERD_DEFAULT_DRIVER: "managed"
|
|
52
|
-
BROWSERD_CHROME_RELAY_URL: `http://127.0.0.1:${relayPort}`
|
|
48
|
+
BROWSERD_DEFAULT_DRIVER: "managed"
|
|
53
49
|
},
|
|
54
50
|
input,
|
|
55
51
|
output,
|
|
@@ -65,7 +61,7 @@ async function createManagedLegacyRuntime() {
|
|
|
65
61
|
|
|
66
62
|
describe("browserd tool matrix", () => {
|
|
67
63
|
it("returns E_INVALID_ARG for missing required arguments on routed tools", async () => {
|
|
68
|
-
const { input, output, runtime } =
|
|
64
|
+
const { input, output, runtime } = createManagedLegacyRuntime();
|
|
69
65
|
|
|
70
66
|
try {
|
|
71
67
|
const toolNames = [
|
|
@@ -113,7 +109,7 @@ describe("browserd tool matrix", () => {
|
|
|
113
109
|
});
|
|
114
110
|
|
|
115
111
|
it("returns E_DRIVER_UNAVAILABLE for structured action tools on managed driver", async () => {
|
|
116
|
-
const { input, output, runtime } =
|
|
112
|
+
const { input, output, runtime } = createManagedLegacyRuntime();
|
|
117
113
|
|
|
118
114
|
try {
|
|
119
115
|
const openResponse = await sendToolRequest(input, output, {
|
|
@@ -239,7 +235,7 @@ describe("browserd tool matrix", () => {
|
|
|
239
235
|
});
|
|
240
236
|
|
|
241
237
|
it("routes managed-driver tools including profile.use, act, focus/close, and waitFor timeout", async () => {
|
|
242
|
-
const { input, output, runtime } =
|
|
238
|
+
const { input, output, runtime } = createManagedLegacyRuntime();
|
|
243
239
|
|
|
244
240
|
try {
|
|
245
241
|
const profileUseResponse = await sendToolRequest(input, output, {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flrande/browserctl",
|
|
3
|
-
"version": "0.2.0
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"bin": {
|
|
6
6
|
"browserctl": "bin/browserctl.cjs",
|
|
@@ -16,8 +16,7 @@
|
|
|
16
16
|
"packages/protocol/src",
|
|
17
17
|
"packages/transport-mcp-stdio/src",
|
|
18
18
|
"extensions/chrome-relay",
|
|
19
|
-
"
|
|
20
|
-
"INSTALL-CN.md",
|
|
19
|
+
"scripts/smoke.ps1",
|
|
21
20
|
"bin",
|
|
22
21
|
"README.md",
|
|
23
22
|
"README-CN.md",
|
|
@@ -44,9 +43,8 @@
|
|
|
44
43
|
"test:unit": "vitest run --config vitest.config.ts",
|
|
45
44
|
"test:contract": "vitest run --config vitest.contract.config.ts",
|
|
46
45
|
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
|
47
|
-
"test:
|
|
48
|
-
"
|
|
49
|
-
"build": "npm pack --dry-run",
|
|
46
|
+
"test:all": "pnpm run test:unit && pnpm run test:contract && pnpm run test:e2e",
|
|
47
|
+
"build": "pnpm publish --dry-run --no-git-checks",
|
|
50
48
|
"typecheck": "pnpm exec tsc --noEmit -p tsconfig.typecheck.json",
|
|
51
49
|
"lint": "node ./scripts/lint.mjs"
|
|
52
50
|
}
|
|
@@ -0,0 +1,127 @@
|
|
|
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
|
+
}
|
package/INSTALL-CN.md
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
# 安装 browserctl Skills
|
|
2
|
-
|
|
3
|
-
[English](INSTALL.md) | 简体中文
|
|
4
|
-
|
|
5
|
-
本仓库发布一个默认可安装 skill:
|
|
6
|
-
|
|
7
|
-
- `skills/browserctl`
|
|
8
|
-
|
|
9
|
-
## 给 Agent 的主提示词
|
|
10
|
-
|
|
11
|
-
把下面文本贴给 agent:
|
|
12
|
-
|
|
13
|
-
```text
|
|
14
|
-
请抓取并遵循 https://raw.githubusercontent.com/Flrande/browserctl/refs/heads/master/INSTALL.md 中的安装说明。
|
|
15
|
-
```
|
|
16
|
-
|
|
17
|
-
## 平台安装入口
|
|
18
|
-
|
|
19
|
-
- Codex:`https://raw.githubusercontent.com/Flrande/browserctl/refs/heads/master/.codex/INSTALL.md`
|
|
20
|
-
- Claude Code:`https://raw.githubusercontent.com/Flrande/browserctl/refs/heads/master/.claude-plugin/INSTALL.md`
|
|
21
|
-
- Cursor:`https://raw.githubusercontent.com/Flrande/browserctl/refs/heads/master/.cursor-plugin/INSTALL.md`
|
|
22
|
-
- OpenCode:`https://raw.githubusercontent.com/Flrande/browserctl/refs/heads/master/.opencode/INSTALL.md`
|
|
23
|
-
|
|
24
|
-
## 兜底约定
|
|
25
|
-
|
|
26
|
-
如果平台安装入口不可用,agent 应直接抓取并遵循:
|
|
27
|
-
|
|
28
|
-
- `https://raw.githubusercontent.com/Flrande/browserctl/refs/heads/master/skills/browserctl/SKILL.md`
|
package/INSTALL.md
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
# Install browserctl Skills
|
|
2
|
-
|
|
3
|
-
English | [简体中文](INSTALL-CN.md)
|
|
4
|
-
|
|
5
|
-
This repository publishes one default installable skill:
|
|
6
|
-
|
|
7
|
-
- `skills/browserctl`
|
|
8
|
-
|
|
9
|
-
## Primary Agent Prompt
|
|
10
|
-
|
|
11
|
-
Paste this to your agent:
|
|
12
|
-
|
|
13
|
-
```text
|
|
14
|
-
Fetch and follow instructions from https://raw.githubusercontent.com/Flrande/browserctl/refs/heads/master/INSTALL.md
|
|
15
|
-
```
|
|
16
|
-
|
|
17
|
-
## Platform Guides
|
|
18
|
-
|
|
19
|
-
- Codex: `https://raw.githubusercontent.com/Flrande/browserctl/refs/heads/master/.codex/INSTALL.md`
|
|
20
|
-
- Claude Code: `https://raw.githubusercontent.com/Flrande/browserctl/refs/heads/master/.claude-plugin/INSTALL.md`
|
|
21
|
-
- Cursor: `https://raw.githubusercontent.com/Flrande/browserctl/refs/heads/master/.cursor-plugin/INSTALL.md`
|
|
22
|
-
- OpenCode: `https://raw.githubusercontent.com/Flrande/browserctl/refs/heads/master/.opencode/INSTALL.md`
|
|
23
|
-
|
|
24
|
-
## Fallback Contract
|
|
25
|
-
|
|
26
|
-
If platform-specific install is unavailable, agents should fetch and follow:
|
|
27
|
-
|
|
28
|
-
- `https://raw.githubusercontent.com/Flrande/browserctl/refs/heads/master/skills/browserctl/SKILL.md`
|
|
@@ -1,97 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,26 +0,0 @@
|
|
|
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,26 +0,0 @@
|
|
|
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
|
-
}
|