@flrande/browserctl 0.1.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/LICENSE +21 -0
- package/README-CN.md +1155 -0
- package/README.md +1155 -0
- package/apps/browserctl/src/commands/a11y-snapshot.ts +20 -0
- package/apps/browserctl/src/commands/act.ts +20 -0
- package/apps/browserctl/src/commands/common.test.ts +87 -0
- package/apps/browserctl/src/commands/common.ts +191 -0
- package/apps/browserctl/src/commands/console-list.ts +20 -0
- package/apps/browserctl/src/commands/cookie-clear.ts +18 -0
- package/apps/browserctl/src/commands/cookie-get.ts +18 -0
- package/apps/browserctl/src/commands/cookie-set.ts +22 -0
- package/apps/browserctl/src/commands/dialog-arm.ts +20 -0
- package/apps/browserctl/src/commands/dom-query-all.ts +18 -0
- package/apps/browserctl/src/commands/dom-query.ts +18 -0
- package/apps/browserctl/src/commands/download-trigger.ts +22 -0
- package/apps/browserctl/src/commands/download-wait.test.ts +67 -0
- package/apps/browserctl/src/commands/download-wait.ts +27 -0
- package/apps/browserctl/src/commands/element-screenshot.ts +20 -0
- package/apps/browserctl/src/commands/frame-list.ts +16 -0
- package/apps/browserctl/src/commands/frame-snapshot.ts +18 -0
- package/apps/browserctl/src/commands/network-wait-for.ts +100 -0
- package/apps/browserctl/src/commands/profile-list.ts +16 -0
- package/apps/browserctl/src/commands/profile-use.ts +18 -0
- package/apps/browserctl/src/commands/response-body.ts +24 -0
- package/apps/browserctl/src/commands/screenshot.ts +16 -0
- package/apps/browserctl/src/commands/snapshot.ts +16 -0
- package/apps/browserctl/src/commands/status.ts +10 -0
- package/apps/browserctl/src/commands/storage-get.ts +20 -0
- package/apps/browserctl/src/commands/storage-set.ts +22 -0
- package/apps/browserctl/src/commands/tab-close.ts +20 -0
- package/apps/browserctl/src/commands/tab-focus.ts +20 -0
- package/apps/browserctl/src/commands/tab-open.ts +19 -0
- package/apps/browserctl/src/commands/tabs.ts +13 -0
- package/apps/browserctl/src/commands/upload-arm.ts +26 -0
- package/apps/browserctl/src/daemon-client.test.ts +253 -0
- package/apps/browserctl/src/daemon-client.ts +632 -0
- package/apps/browserctl/src/e2e.test.ts +99 -0
- package/apps/browserctl/src/main.test.ts +215 -0
- package/apps/browserctl/src/main.ts +372 -0
- package/apps/browserctl/src/smoke.test.ts +16 -0
- package/apps/browserctl/src/smoke.ts +5 -0
- package/apps/browserd/src/bootstrap.ts +432 -0
- package/apps/browserd/src/chrome-relay-extension-bridge.test.ts +275 -0
- package/apps/browserd/src/chrome-relay-extension-bridge.ts +506 -0
- package/apps/browserd/src/container.ts +1531 -0
- package/apps/browserd/src/main.test.ts +864 -0
- package/apps/browserd/src/main.ts +7 -0
- package/bin/browserctl.cjs +21 -0
- package/bin/browserd.cjs +21 -0
- package/extensions/chrome-relay/README.md +36 -0
- package/extensions/chrome-relay/background.js +1687 -0
- package/extensions/chrome-relay/manifest.json +15 -0
- package/extensions/chrome-relay/popup.html +369 -0
- package/extensions/chrome-relay/popup.js +972 -0
- package/package.json +51 -0
- package/packages/core/src/bootstrap.test.ts +10 -0
- package/packages/core/src/driver-registry.test.ts +45 -0
- package/packages/core/src/driver-registry.ts +22 -0
- package/packages/core/src/driver.ts +47 -0
- package/packages/core/src/index.ts +5 -0
- package/packages/core/src/ref-cache.test.ts +61 -0
- package/packages/core/src/ref-cache.ts +28 -0
- package/packages/core/src/session-store.test.ts +49 -0
- package/packages/core/src/session-store.ts +33 -0
- package/packages/core/src/types.ts +9 -0
- package/packages/driver-chrome-relay/src/chrome-relay-driver.test.ts +634 -0
- package/packages/driver-chrome-relay/src/chrome-relay-driver.ts +2206 -0
- package/packages/driver-chrome-relay/src/chrome-relay-extension-runtime.test.ts +264 -0
- package/packages/driver-chrome-relay/src/chrome-relay-extension-runtime.ts +521 -0
- package/packages/driver-chrome-relay/src/index.ts +26 -0
- package/packages/driver-managed/src/index.ts +22 -0
- package/packages/driver-managed/src/managed-driver.test.ts +59 -0
- package/packages/driver-managed/src/managed-driver.ts +125 -0
- package/packages/driver-managed/src/managed-local-driver.test.ts +506 -0
- package/packages/driver-managed/src/managed-local-driver.ts +2021 -0
- package/packages/driver-remote-cdp/src/index.ts +19 -0
- package/packages/driver-remote-cdp/src/remote-cdp-driver.test.ts +617 -0
- package/packages/driver-remote-cdp/src/remote-cdp-driver.ts +2042 -0
- package/packages/protocol/src/envelope.test.ts +25 -0
- package/packages/protocol/src/envelope.ts +31 -0
- package/packages/protocol/src/errors.test.ts +17 -0
- package/packages/protocol/src/errors.ts +11 -0
- package/packages/protocol/src/index.ts +3 -0
- package/packages/protocol/src/tools.ts +3 -0
- package/packages/transport-mcp-stdio/src/index.ts +3 -0
- package/packages/transport-mcp-stdio/src/sdk-server.ts +139 -0
- package/packages/transport-mcp-stdio/src/server.test.ts +281 -0
- package/packages/transport-mcp-stdio/src/server.ts +183 -0
- package/packages/transport-mcp-stdio/src/tool-map.ts +67 -0
- package/scripts/smoke.ps1 +127 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { callDaemonTool } from "../daemon-client";
|
|
2
|
+
import { buildToolArguments, parseCommandContext, requirePositionalArg } from "./common";
|
|
3
|
+
|
|
4
|
+
export type NetworkWaitForCommandResult = Record<string, unknown>;
|
|
5
|
+
|
|
6
|
+
function parsePositiveInteger(value: string, optionName: string): number {
|
|
7
|
+
const parsed = Number.parseInt(value, 10);
|
|
8
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
9
|
+
throw new Error(`${optionName} must be a positive integer.`);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return parsed;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function parseNetworkWaitOptions(tokens: string[]): {
|
|
16
|
+
method?: string;
|
|
17
|
+
status?: number;
|
|
18
|
+
timeoutMs?: number;
|
|
19
|
+
pollMs?: number;
|
|
20
|
+
} {
|
|
21
|
+
let method: string | undefined;
|
|
22
|
+
let status: number | undefined;
|
|
23
|
+
let timeoutMs: number | undefined;
|
|
24
|
+
let pollMs: number | undefined;
|
|
25
|
+
|
|
26
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
27
|
+
const token = tokens[index];
|
|
28
|
+
if (token === "--method") {
|
|
29
|
+
const value = tokens[index + 1];
|
|
30
|
+
if (value === undefined) {
|
|
31
|
+
throw new Error("Missing value for --method.");
|
|
32
|
+
}
|
|
33
|
+
method = value.trim().toUpperCase();
|
|
34
|
+
index += 1;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (token === "--status") {
|
|
39
|
+
const value = tokens[index + 1];
|
|
40
|
+
if (value === undefined) {
|
|
41
|
+
throw new Error("Missing value for --status.");
|
|
42
|
+
}
|
|
43
|
+
status = parsePositiveInteger(value, "--status");
|
|
44
|
+
index += 1;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (token === "--timeout-ms") {
|
|
49
|
+
const value = tokens[index + 1];
|
|
50
|
+
if (value === undefined) {
|
|
51
|
+
throw new Error("Missing value for --timeout-ms.");
|
|
52
|
+
}
|
|
53
|
+
timeoutMs = parsePositiveInteger(value, "--timeout-ms");
|
|
54
|
+
index += 1;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (token === "--poll-ms") {
|
|
59
|
+
const value = tokens[index + 1];
|
|
60
|
+
if (value === undefined) {
|
|
61
|
+
throw new Error("Missing value for --poll-ms.");
|
|
62
|
+
}
|
|
63
|
+
pollMs = parsePositiveInteger(value, "--poll-ms");
|
|
64
|
+
index += 1;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (timeoutMs === undefined) {
|
|
69
|
+
timeoutMs = parsePositiveInteger(token, "timeoutMs");
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
throw new Error(`Unknown network-wait-for option: ${token}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
...(method !== undefined ? { method } : {}),
|
|
78
|
+
...(status !== undefined ? { status } : {}),
|
|
79
|
+
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
|
80
|
+
...(pollMs !== undefined ? { pollMs } : {})
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function runNetworkWaitForCommand(
|
|
85
|
+
args: string[]
|
|
86
|
+
): Promise<NetworkWaitForCommandResult> {
|
|
87
|
+
const context = parseCommandContext(args);
|
|
88
|
+
const targetId = requirePositionalArg(context, 0, "targetId");
|
|
89
|
+
const urlPattern = requirePositionalArg(context, 1, "urlPattern");
|
|
90
|
+
const options = parseNetworkWaitOptions(context.positional.slice(2));
|
|
91
|
+
|
|
92
|
+
return await callDaemonTool<NetworkWaitForCommandResult>(
|
|
93
|
+
"browser.network.waitFor",
|
|
94
|
+
buildToolArguments(context, {
|
|
95
|
+
targetId,
|
|
96
|
+
urlPattern,
|
|
97
|
+
...options
|
|
98
|
+
})
|
|
99
|
+
);
|
|
100
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { callDaemonTool } from "../daemon-client";
|
|
2
|
+
import { buildToolArguments, parseCommandContext } from "./common";
|
|
3
|
+
|
|
4
|
+
export type ProfileListCommandResult = {
|
|
5
|
+
driver: string;
|
|
6
|
+
profiles: string[];
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export async function runProfileListCommand(args: string[] = []): Promise<ProfileListCommandResult> {
|
|
10
|
+
const context = parseCommandContext(args);
|
|
11
|
+
|
|
12
|
+
return await callDaemonTool<ProfileListCommandResult>(
|
|
13
|
+
"browser.profile.list",
|
|
14
|
+
buildToolArguments(context)
|
|
15
|
+
);
|
|
16
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { callDaemonTool } from "../daemon-client";
|
|
2
|
+
import { buildToolArguments, parseCommandContext, requirePositionalArg } from "./common";
|
|
3
|
+
|
|
4
|
+
export type ProfileUseCommandResult = {
|
|
5
|
+
profile: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export async function runProfileUseCommand(args: string[]): Promise<ProfileUseCommandResult> {
|
|
9
|
+
const context = parseCommandContext(args);
|
|
10
|
+
const profile = requirePositionalArg(context, 0, "profile");
|
|
11
|
+
|
|
12
|
+
return await callDaemonTool<ProfileUseCommandResult>(
|
|
13
|
+
"browser.profile.use",
|
|
14
|
+
buildToolArguments(context, {
|
|
15
|
+
profile
|
|
16
|
+
})
|
|
17
|
+
);
|
|
18
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { callDaemonTool } from "../daemon-client";
|
|
2
|
+
import { buildToolArguments, parseCommandContext, requirePositionalArg } from "./common";
|
|
3
|
+
|
|
4
|
+
export type ResponseBodyCommandResult = {
|
|
5
|
+
driver: string;
|
|
6
|
+
targetId: string;
|
|
7
|
+
requestId: string;
|
|
8
|
+
body: string;
|
|
9
|
+
encoding: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export async function runResponseBodyCommand(args: string[]): Promise<ResponseBodyCommandResult> {
|
|
13
|
+
const context = parseCommandContext(args);
|
|
14
|
+
const targetId = requirePositionalArg(context, 0, "targetId");
|
|
15
|
+
const requestId = requirePositionalArg(context, 1, "requestId");
|
|
16
|
+
|
|
17
|
+
return await callDaemonTool<ResponseBodyCommandResult>(
|
|
18
|
+
"browser.network.responseBody",
|
|
19
|
+
buildToolArguments(context, {
|
|
20
|
+
targetId,
|
|
21
|
+
requestId
|
|
22
|
+
})
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { callDaemonTool } from "../daemon-client";
|
|
2
|
+
import { buildToolArguments, parseCommandContext, requirePositionalArg } from "./common";
|
|
3
|
+
|
|
4
|
+
export type ScreenshotCommandResult = Record<string, unknown>;
|
|
5
|
+
|
|
6
|
+
export async function runScreenshotCommand(args: string[]): Promise<ScreenshotCommandResult> {
|
|
7
|
+
const context = parseCommandContext(args);
|
|
8
|
+
const targetId = requirePositionalArg(context, 0, "targetId");
|
|
9
|
+
|
|
10
|
+
return await callDaemonTool<ScreenshotCommandResult>(
|
|
11
|
+
"browser.screenshot",
|
|
12
|
+
buildToolArguments(context, {
|
|
13
|
+
targetId
|
|
14
|
+
})
|
|
15
|
+
);
|
|
16
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { callDaemonTool } from "../daemon-client";
|
|
2
|
+
import { buildToolArguments, parseCommandContext, requirePositionalArg } from "./common";
|
|
3
|
+
|
|
4
|
+
export type SnapshotCommandResult = Record<string, unknown>;
|
|
5
|
+
|
|
6
|
+
export async function runSnapshotCommand(args: string[]): Promise<SnapshotCommandResult> {
|
|
7
|
+
const context = parseCommandContext(args);
|
|
8
|
+
const targetId = requirePositionalArg(context, 0, "targetId");
|
|
9
|
+
|
|
10
|
+
return await callDaemonTool<SnapshotCommandResult>(
|
|
11
|
+
"browser.snapshot",
|
|
12
|
+
buildToolArguments(context, {
|
|
13
|
+
targetId
|
|
14
|
+
})
|
|
15
|
+
);
|
|
16
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { callDaemonTool } from "../daemon-client";
|
|
2
|
+
import { buildToolArguments, parseCommandContext } from "./common";
|
|
3
|
+
|
|
4
|
+
export type StatusCommandResult = Record<string, unknown>;
|
|
5
|
+
|
|
6
|
+
export async function runStatusCommand(args: string[] = []): Promise<StatusCommandResult> {
|
|
7
|
+
const context = parseCommandContext(args);
|
|
8
|
+
|
|
9
|
+
return await callDaemonTool<StatusCommandResult>("browser.status", buildToolArguments(context));
|
|
10
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { callDaemonTool } from "../daemon-client";
|
|
2
|
+
import { buildToolArguments, parseCommandContext, requirePositionalArg } from "./common";
|
|
3
|
+
|
|
4
|
+
export type StorageGetCommandResult = Record<string, unknown>;
|
|
5
|
+
|
|
6
|
+
export async function runStorageGetCommand(args: string[]): Promise<StorageGetCommandResult> {
|
|
7
|
+
const context = parseCommandContext(args);
|
|
8
|
+
const targetId = requirePositionalArg(context, 0, "targetId");
|
|
9
|
+
const scope = requirePositionalArg(context, 1, "scope");
|
|
10
|
+
const key = requirePositionalArg(context, 2, "key");
|
|
11
|
+
|
|
12
|
+
return await callDaemonTool<StorageGetCommandResult>(
|
|
13
|
+
"browser.storage.get",
|
|
14
|
+
buildToolArguments(context, {
|
|
15
|
+
targetId,
|
|
16
|
+
scope,
|
|
17
|
+
key
|
|
18
|
+
})
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { callDaemonTool } from "../daemon-client";
|
|
2
|
+
import { buildToolArguments, parseCommandContext, requirePositionalArg } from "./common";
|
|
3
|
+
|
|
4
|
+
export type StorageSetCommandResult = Record<string, unknown>;
|
|
5
|
+
|
|
6
|
+
export async function runStorageSetCommand(args: string[]): Promise<StorageSetCommandResult> {
|
|
7
|
+
const context = parseCommandContext(args);
|
|
8
|
+
const targetId = requirePositionalArg(context, 0, "targetId");
|
|
9
|
+
const scope = requirePositionalArg(context, 1, "scope");
|
|
10
|
+
const key = requirePositionalArg(context, 2, "key");
|
|
11
|
+
const value = requirePositionalArg(context, 3, "value");
|
|
12
|
+
|
|
13
|
+
return await callDaemonTool<StorageSetCommandResult>(
|
|
14
|
+
"browser.storage.set",
|
|
15
|
+
buildToolArguments(context, {
|
|
16
|
+
targetId,
|
|
17
|
+
scope,
|
|
18
|
+
key,
|
|
19
|
+
value
|
|
20
|
+
})
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { callDaemonTool } from "../daemon-client";
|
|
2
|
+
import { buildToolArguments, parseCommandContext, requirePositionalArg } from "./common";
|
|
3
|
+
|
|
4
|
+
export type TabCloseCommandResult = {
|
|
5
|
+
driver: string;
|
|
6
|
+
targetId: string;
|
|
7
|
+
closed: boolean;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export async function runTabCloseCommand(args: string[]): Promise<TabCloseCommandResult> {
|
|
11
|
+
const context = parseCommandContext(args);
|
|
12
|
+
const targetId = requirePositionalArg(context, 0, "targetId");
|
|
13
|
+
|
|
14
|
+
return await callDaemonTool<TabCloseCommandResult>(
|
|
15
|
+
"browser.tab.close",
|
|
16
|
+
buildToolArguments(context, {
|
|
17
|
+
targetId
|
|
18
|
+
})
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { callDaemonTool } from "../daemon-client";
|
|
2
|
+
import { buildToolArguments, parseCommandContext, requirePositionalArg } from "./common";
|
|
3
|
+
|
|
4
|
+
export type TabFocusCommandResult = {
|
|
5
|
+
driver: string;
|
|
6
|
+
targetId: string;
|
|
7
|
+
focused: boolean;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export async function runTabFocusCommand(args: string[]): Promise<TabFocusCommandResult> {
|
|
11
|
+
const context = parseCommandContext(args);
|
|
12
|
+
const targetId = requirePositionalArg(context, 0, "targetId");
|
|
13
|
+
|
|
14
|
+
return await callDaemonTool<TabFocusCommandResult>(
|
|
15
|
+
"browser.tab.focus",
|
|
16
|
+
buildToolArguments(context, {
|
|
17
|
+
targetId
|
|
18
|
+
})
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { callDaemonTool } from "../daemon-client";
|
|
2
|
+
import { buildToolArguments, parseCommandContext, requirePositionalArg } from "./common";
|
|
3
|
+
|
|
4
|
+
export type TabOpenCommandResult = {
|
|
5
|
+
driver: string;
|
|
6
|
+
targetId: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export async function runTabOpenCommand(args: string[]): Promise<TabOpenCommandResult> {
|
|
10
|
+
const context = parseCommandContext(args);
|
|
11
|
+
const url = requirePositionalArg(context, 0, "url");
|
|
12
|
+
|
|
13
|
+
return await callDaemonTool<TabOpenCommandResult>(
|
|
14
|
+
"browser.tab.open",
|
|
15
|
+
buildToolArguments(context, {
|
|
16
|
+
url
|
|
17
|
+
})
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { callDaemonTool } from "../daemon-client";
|
|
2
|
+
import { buildToolArguments, parseCommandContext } from "./common";
|
|
3
|
+
|
|
4
|
+
export type TabsCommandResult = {
|
|
5
|
+
driver: string;
|
|
6
|
+
tabs: string[];
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export async function runTabsCommand(args: string[] = []): Promise<TabsCommandResult> {
|
|
10
|
+
const context = parseCommandContext(args);
|
|
11
|
+
|
|
12
|
+
return await callDaemonTool<TabsCommandResult>("browser.tab.list", buildToolArguments(context));
|
|
13
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { callDaemonTool } from "../daemon-client";
|
|
2
|
+
import { buildToolArguments, parseCommandContext, requirePositionalArg } from "./common";
|
|
3
|
+
|
|
4
|
+
export type UploadArmCommandResult = {
|
|
5
|
+
driver: string;
|
|
6
|
+
targetId: string;
|
|
7
|
+
armed: boolean;
|
|
8
|
+
files: string[];
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export async function runUploadArmCommand(args: string[]): Promise<UploadArmCommandResult> {
|
|
12
|
+
const context = parseCommandContext(args);
|
|
13
|
+
const targetId = requirePositionalArg(context, 0, "targetId");
|
|
14
|
+
const files = context.positional.slice(1);
|
|
15
|
+
if (files.length === 0) {
|
|
16
|
+
throw new Error("Missing required argument: files.");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return await callDaemonTool<UploadArmCommandResult>(
|
|
20
|
+
"browser.upload.arm",
|
|
21
|
+
buildToolArguments(context, {
|
|
22
|
+
targetId,
|
|
23
|
+
files
|
|
24
|
+
})
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { createServer, type AddressInfo } from "node:net";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
|
|
6
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
7
|
+
|
|
8
|
+
import { DAEMON_STARTUP_ARGUMENT } from "./commands/common";
|
|
9
|
+
import { callDaemonTool, getDaemonStatus, stopDaemon } from "./daemon-client";
|
|
10
|
+
|
|
11
|
+
type CapturedRequest = {
|
|
12
|
+
id: string;
|
|
13
|
+
name: string;
|
|
14
|
+
traceId: string;
|
|
15
|
+
arguments: Record<string, unknown>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const ORIGINAL_DAEMON_PORT = process.env.BROWSERCTL_DAEMON_PORT;
|
|
19
|
+
const ORIGINAL_AUTH_TOKEN = process.env.BROWSERCTL_AUTH_TOKEN;
|
|
20
|
+
const TEST_PID_PORTS = new Set<number>();
|
|
21
|
+
|
|
22
|
+
function resolveRuntimeDirForTests(): string {
|
|
23
|
+
const currentFile = fileURLToPath(import.meta.url);
|
|
24
|
+
return resolve(dirname(currentFile), "../../../.browserctl-runtime");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function resolvePidFileForTests(port: number): string {
|
|
28
|
+
return join(resolveRuntimeDirForTests(), `daemon-${port}.pid`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function writePidRecordForTests(port: number, record: number | { pid: number; authToken?: string }): void {
|
|
32
|
+
mkdirSync(resolveRuntimeDirForTests(), { recursive: true });
|
|
33
|
+
writeFileSync(
|
|
34
|
+
resolvePidFileForTests(port),
|
|
35
|
+
typeof record === "number" ? String(record) : JSON.stringify(record),
|
|
36
|
+
{ encoding: "utf8" }
|
|
37
|
+
);
|
|
38
|
+
TEST_PID_PORTS.add(port);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
if (ORIGINAL_DAEMON_PORT === undefined) {
|
|
43
|
+
delete process.env.BROWSERCTL_DAEMON_PORT;
|
|
44
|
+
} else {
|
|
45
|
+
process.env.BROWSERCTL_DAEMON_PORT = ORIGINAL_DAEMON_PORT;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (ORIGINAL_AUTH_TOKEN === undefined) {
|
|
49
|
+
delete process.env.BROWSERCTL_AUTH_TOKEN;
|
|
50
|
+
} else {
|
|
51
|
+
process.env.BROWSERCTL_AUTH_TOKEN = ORIGINAL_AUTH_TOKEN;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
for (const port of TEST_PID_PORTS) {
|
|
55
|
+
rmSync(resolvePidFileForTests(port), { force: true });
|
|
56
|
+
}
|
|
57
|
+
TEST_PID_PORTS.clear();
|
|
58
|
+
vi.restoreAllMocks();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
async function withDaemonHarness(
|
|
62
|
+
run: (state: { port: number; requests: CapturedRequest[] }) => Promise<void>
|
|
63
|
+
): Promise<void> {
|
|
64
|
+
const requests: CapturedRequest[] = [];
|
|
65
|
+
const server = createServer((socket) => {
|
|
66
|
+
socket.setEncoding("utf8");
|
|
67
|
+
let buffer = "";
|
|
68
|
+
|
|
69
|
+
socket.on("data", (chunk: string) => {
|
|
70
|
+
buffer += chunk;
|
|
71
|
+
|
|
72
|
+
let lineBreakIndex = buffer.indexOf("\n");
|
|
73
|
+
while (lineBreakIndex >= 0) {
|
|
74
|
+
const line = buffer.slice(0, lineBreakIndex).trim();
|
|
75
|
+
buffer = buffer.slice(lineBreakIndex + 1);
|
|
76
|
+
|
|
77
|
+
if (line.length === 0) {
|
|
78
|
+
lineBreakIndex = buffer.indexOf("\n");
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const request = JSON.parse(line) as CapturedRequest;
|
|
83
|
+
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
|
+
);
|
|
98
|
+
|
|
99
|
+
lineBreakIndex = buffer.indexOf("\n");
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
await new Promise<void>((resolve, reject) => {
|
|
105
|
+
server.once("error", reject);
|
|
106
|
+
server.listen(0, "127.0.0.1", () => {
|
|
107
|
+
server.off("error", reject);
|
|
108
|
+
resolve();
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const address = server.address() as AddressInfo;
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
await run({ port: address.port, requests });
|
|
116
|
+
} finally {
|
|
117
|
+
await new Promise<void>((resolve, reject) => {
|
|
118
|
+
server.close((error) => {
|
|
119
|
+
if (error !== undefined) {
|
|
120
|
+
reject(error);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
resolve();
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
describe("daemon client auth token forwarding", () => {
|
|
131
|
+
it("includes env token in daemon status probes", async () => {
|
|
132
|
+
await withDaemonHarness(async ({ port, requests }) => {
|
|
133
|
+
process.env.BROWSERCTL_DAEMON_PORT = String(port);
|
|
134
|
+
process.env.BROWSERCTL_AUTH_TOKEN = "env-token";
|
|
135
|
+
|
|
136
|
+
const status = await getDaemonStatus();
|
|
137
|
+
|
|
138
|
+
expect(status.running).toBe(true);
|
|
139
|
+
expect(requests).toHaveLength(1);
|
|
140
|
+
expect(requests[0]).toMatchObject({
|
|
141
|
+
name: "browser.status",
|
|
142
|
+
arguments: {
|
|
143
|
+
sessionId: "cli:daemon-status",
|
|
144
|
+
authToken: "env-token"
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("includes env token in tool calls when not explicitly provided", async () => {
|
|
151
|
+
await withDaemonHarness(async ({ port, requests }) => {
|
|
152
|
+
process.env.BROWSERCTL_DAEMON_PORT = String(port);
|
|
153
|
+
process.env.BROWSERCTL_AUTH_TOKEN = "env-token";
|
|
154
|
+
|
|
155
|
+
await callDaemonTool("browser.tab.list", {
|
|
156
|
+
sessionId: "cli:test"
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
expect(requests).toHaveLength(1);
|
|
160
|
+
expect(requests[0]).toMatchObject({
|
|
161
|
+
name: "browser.tab.list",
|
|
162
|
+
arguments: {
|
|
163
|
+
sessionId: "cli:test",
|
|
164
|
+
authToken: "env-token"
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("preserves explicit authToken in tool calls", async () => {
|
|
171
|
+
await withDaemonHarness(async ({ port, requests }) => {
|
|
172
|
+
process.env.BROWSERCTL_DAEMON_PORT = String(port);
|
|
173
|
+
process.env.BROWSERCTL_AUTH_TOKEN = "env-token";
|
|
174
|
+
|
|
175
|
+
await callDaemonTool("browser.tab.list", {
|
|
176
|
+
sessionId: "cli:test",
|
|
177
|
+
authToken: "cli-token"
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
expect(requests).toHaveLength(1);
|
|
181
|
+
expect(requests[0]).toMatchObject({
|
|
182
|
+
name: "browser.tab.list",
|
|
183
|
+
arguments: {
|
|
184
|
+
sessionId: "cli:test",
|
|
185
|
+
authToken: "cli-token"
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("does not forward internal daemon startup metadata as tool arguments", async () => {
|
|
192
|
+
await withDaemonHarness(async ({ port, requests }) => {
|
|
193
|
+
process.env.BROWSERCTL_DAEMON_PORT = String(port);
|
|
194
|
+
|
|
195
|
+
await callDaemonTool("browser.tab.list", {
|
|
196
|
+
sessionId: "cli:test",
|
|
197
|
+
[DAEMON_STARTUP_ARGUMENT]: {
|
|
198
|
+
managedLocal: {
|
|
199
|
+
browserName: "chromium",
|
|
200
|
+
channel: "msedge"
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
expect(requests).toHaveLength(1);
|
|
206
|
+
expect(requests[0].arguments).toMatchObject({
|
|
207
|
+
sessionId: "cli:test"
|
|
208
|
+
});
|
|
209
|
+
expect(requests[0].arguments).not.toHaveProperty(DAEMON_STARTUP_ARGUMENT);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("reuses persisted daemon auth token when env token is missing", async () => {
|
|
214
|
+
await withDaemonHarness(async ({ port, requests }) => {
|
|
215
|
+
process.env.BROWSERCTL_DAEMON_PORT = String(port);
|
|
216
|
+
delete process.env.BROWSERCTL_AUTH_TOKEN;
|
|
217
|
+
writePidRecordForTests(port, {
|
|
218
|
+
pid: 12345,
|
|
219
|
+
authToken: "persisted-token"
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const status = await getDaemonStatus();
|
|
223
|
+
|
|
224
|
+
expect(status.running).toBe(true);
|
|
225
|
+
expect(requests).toHaveLength(1);
|
|
226
|
+
expect(requests[0]).toMatchObject({
|
|
227
|
+
name: "browser.status",
|
|
228
|
+
arguments: {
|
|
229
|
+
sessionId: "cli:daemon-status",
|
|
230
|
+
authToken: "persisted-token"
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("avoids killing stale pid records when daemon cannot be verified", async () => {
|
|
237
|
+
const unreachablePort = 45999;
|
|
238
|
+
writePidRecordForTests(unreachablePort, {
|
|
239
|
+
pid: 70001,
|
|
240
|
+
authToken: "persisted-token"
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
|
|
244
|
+
const stopResult = await stopDaemon(unreachablePort);
|
|
245
|
+
|
|
246
|
+
expect(stopResult).toMatchObject({
|
|
247
|
+
stopped: false,
|
|
248
|
+
port: unreachablePort,
|
|
249
|
+
pid: 70001
|
|
250
|
+
});
|
|
251
|
+
expect(killSpy).not.toHaveBeenCalled();
|
|
252
|
+
});
|
|
253
|
+
});
|