@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,632 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { Socket } from "node:net";
|
|
7
|
+
|
|
8
|
+
import { DAEMON_STARTUP_ARGUMENT, type DaemonStartupConfig } from "./commands/common";
|
|
9
|
+
|
|
10
|
+
const DEFAULT_DAEMON_HOST = "127.0.0.1";
|
|
11
|
+
const DEFAULT_DAEMON_PORT = 41337;
|
|
12
|
+
const DEFAULT_DAEMON_STARTUP_TIMEOUT_MS = 10_000;
|
|
13
|
+
const DEFAULT_DAEMON_REQUEST_TIMEOUT_MS = 5_000;
|
|
14
|
+
const STARTUP_POLL_INTERVAL_MS = 200;
|
|
15
|
+
|
|
16
|
+
type ErrorPayload = {
|
|
17
|
+
code: string;
|
|
18
|
+
message: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type ToolEnvelope<TData = Record<string, unknown>> = {
|
|
22
|
+
id?: string;
|
|
23
|
+
ok: boolean;
|
|
24
|
+
traceId: string;
|
|
25
|
+
sessionId: string;
|
|
26
|
+
profile?: string;
|
|
27
|
+
targetId?: string;
|
|
28
|
+
data?: TData;
|
|
29
|
+
error?: ErrorPayload;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type ToolRequest = {
|
|
33
|
+
id: string;
|
|
34
|
+
name: string;
|
|
35
|
+
traceId: string;
|
|
36
|
+
arguments: Record<string, unknown>;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
type DaemonLifecycleStatus = {
|
|
40
|
+
running: boolean;
|
|
41
|
+
port: number;
|
|
42
|
+
pid?: number;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
type DaemonRequestOptions = {
|
|
46
|
+
port?: number;
|
|
47
|
+
authToken?: string;
|
|
48
|
+
startup?: DaemonStartupConfig;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
type DaemonRuntimeRecord = {
|
|
52
|
+
pid: number;
|
|
53
|
+
authToken?: string;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const startupLocks = new Map<number, Promise<void>>();
|
|
57
|
+
|
|
58
|
+
function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
|
59
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function toNumber(value: string | undefined): number | undefined {
|
|
63
|
+
if (value === undefined) {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const parsed = Number.parseInt(value, 10);
|
|
68
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function resolveDaemonPort(): number {
|
|
72
|
+
return toNumber(process.env.BROWSERCTL_DAEMON_PORT) ?? DEFAULT_DAEMON_PORT;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function resolveDaemonStartupTimeoutMs(): number {
|
|
76
|
+
return (
|
|
77
|
+
toNumber(process.env.BROWSERCTL_DAEMON_STARTUP_TIMEOUT_MS) ??
|
|
78
|
+
DEFAULT_DAEMON_STARTUP_TIMEOUT_MS
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function resolveDaemonRequestTimeoutMs(): number {
|
|
83
|
+
return (
|
|
84
|
+
toNumber(process.env.BROWSERCTL_DAEMON_REQUEST_TIMEOUT_MS) ??
|
|
85
|
+
DEFAULT_DAEMON_REQUEST_TIMEOUT_MS
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function normalizeAuthToken(value: unknown): string | undefined {
|
|
90
|
+
if (typeof value !== "string") {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const trimmedValue = value.trim();
|
|
95
|
+
return trimmedValue.length === 0 ? undefined : trimmedValue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function resolveDefaultAuthToken(port = resolveDaemonPort()): string | undefined {
|
|
99
|
+
return normalizeAuthToken(process.env.BROWSERCTL_AUTH_TOKEN) ?? readDaemonRuntimeRecord(port)?.authToken;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function withAuthToken(
|
|
103
|
+
args: Record<string, unknown>,
|
|
104
|
+
explicitAuthToken?: string
|
|
105
|
+
): Record<string, unknown> {
|
|
106
|
+
const authToken =
|
|
107
|
+
normalizeAuthToken(explicitAuthToken) ??
|
|
108
|
+
normalizeAuthToken(args.authToken) ??
|
|
109
|
+
resolveDefaultAuthToken();
|
|
110
|
+
|
|
111
|
+
if (authToken === undefined) {
|
|
112
|
+
return args;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
...args,
|
|
117
|
+
authToken
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function isDaemonStartupConfig(value: unknown): value is DaemonStartupConfig {
|
|
122
|
+
if (!isObjectRecord(value)) {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const managedLocal = value.managedLocal;
|
|
127
|
+
if (!isObjectRecord(managedLocal)) {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (managedLocal.browserName !== "chromium") {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (managedLocal.channel !== undefined && typeof managedLocal.channel !== "string") {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function extractDaemonStartupConfig(args: Record<string, unknown>): {
|
|
143
|
+
requestArgs: Record<string, unknown>;
|
|
144
|
+
startup?: DaemonStartupConfig;
|
|
145
|
+
} {
|
|
146
|
+
const rawStartup = args[DAEMON_STARTUP_ARGUMENT];
|
|
147
|
+
if (rawStartup === undefined) {
|
|
148
|
+
return {
|
|
149
|
+
requestArgs: args
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const { [DAEMON_STARTUP_ARGUMENT]: _ignored, ...requestArgs } = args;
|
|
154
|
+
if (!isDaemonStartupConfig(rawStartup)) {
|
|
155
|
+
return {
|
|
156
|
+
requestArgs
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
requestArgs,
|
|
162
|
+
startup: {
|
|
163
|
+
managedLocal: {
|
|
164
|
+
browserName: "chromium",
|
|
165
|
+
...(rawStartup.managedLocal.channel !== undefined
|
|
166
|
+
? { channel: rawStartup.managedLocal.channel }
|
|
167
|
+
: {})
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function resolveRepoRoot(): string {
|
|
174
|
+
const currentFile = fileURLToPath(import.meta.url);
|
|
175
|
+
return resolve(dirname(currentFile), "../../..");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function resolveBrowserdEntry(): string {
|
|
179
|
+
return resolve(resolveRepoRoot(), "apps/browserd/src/main.ts");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function resolveRuntimeDir(): string {
|
|
183
|
+
return join(resolveRepoRoot(), ".browserctl-runtime");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function resolvePidFile(port: number): string {
|
|
187
|
+
return join(resolveRuntimeDir(), `daemon-${port}.pid`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function ensureRuntimeDir(): void {
|
|
191
|
+
mkdirSync(resolveRuntimeDir(), { recursive: true });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function delay(ms: number): Promise<void> {
|
|
195
|
+
return new Promise((resolvePromise) => {
|
|
196
|
+
setTimeout(resolvePromise, ms);
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function getErrorCode(error: unknown): string | undefined {
|
|
201
|
+
if (!isObjectRecord(error)) {
|
|
202
|
+
return undefined;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const code = error.code;
|
|
206
|
+
return typeof code === "string" ? code : undefined;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function isConnectionError(error: unknown): boolean {
|
|
210
|
+
const code = getErrorCode(error);
|
|
211
|
+
return code === "ECONNREFUSED" || code === "ENOENT" || code === "ECONNRESET";
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function parseResponseLine(stdoutLine: string, requestId: string): ToolEnvelope {
|
|
215
|
+
const parsed = JSON.parse(stdoutLine);
|
|
216
|
+
if (!isObjectRecord(parsed)) {
|
|
217
|
+
throw new Error("Daemon returned a non-object response.");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (typeof parsed.ok !== "boolean") {
|
|
221
|
+
throw new Error("Daemon response is missing boolean ok.");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (typeof parsed.id === "string" && parsed.id !== requestId) {
|
|
225
|
+
throw new Error(`Daemon response id mismatch. expected=${requestId} got=${parsed.id}`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return parsed as ToolEnvelope;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function createToolRequest(name: string, args: Record<string, unknown>): ToolRequest {
|
|
232
|
+
return {
|
|
233
|
+
id: randomUUID(),
|
|
234
|
+
name,
|
|
235
|
+
traceId: `trace:browserctl:${randomUUID()}`,
|
|
236
|
+
arguments: args
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function requestDaemon(
|
|
241
|
+
request: ToolRequest,
|
|
242
|
+
port: number,
|
|
243
|
+
timeoutMs: number
|
|
244
|
+
): Promise<ToolEnvelope> {
|
|
245
|
+
return await new Promise<ToolEnvelope>((resolvePromise, rejectPromise) => {
|
|
246
|
+
const socket = new Socket();
|
|
247
|
+
socket.setEncoding("utf8");
|
|
248
|
+
|
|
249
|
+
let settled = false;
|
|
250
|
+
let buffer = "";
|
|
251
|
+
|
|
252
|
+
const timer = setTimeout(() => {
|
|
253
|
+
if (settled) {
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
settled = true;
|
|
258
|
+
socket.destroy();
|
|
259
|
+
rejectPromise(
|
|
260
|
+
new Error(`Timed out waiting for daemon response after ${timeoutMs}ms (port ${port}).`)
|
|
261
|
+
);
|
|
262
|
+
}, timeoutMs);
|
|
263
|
+
|
|
264
|
+
function settleResolve(value: ToolEnvelope): void {
|
|
265
|
+
if (settled) {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
settled = true;
|
|
270
|
+
clearTimeout(timer);
|
|
271
|
+
socket.end();
|
|
272
|
+
resolvePromise(value);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function settleReject(error: unknown): void {
|
|
276
|
+
if (settled) {
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
settled = true;
|
|
281
|
+
clearTimeout(timer);
|
|
282
|
+
socket.destroy();
|
|
283
|
+
rejectPromise(error);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
socket.on("error", settleReject);
|
|
287
|
+
|
|
288
|
+
socket.on("connect", () => {
|
|
289
|
+
socket.write(`${JSON.stringify(request)}\n`);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
socket.on("data", (chunk: string) => {
|
|
293
|
+
buffer += chunk;
|
|
294
|
+
|
|
295
|
+
let lineBreakIndex = buffer.indexOf("\n");
|
|
296
|
+
while (lineBreakIndex >= 0) {
|
|
297
|
+
const line = buffer.slice(0, lineBreakIndex).trim();
|
|
298
|
+
buffer = buffer.slice(lineBreakIndex + 1);
|
|
299
|
+
|
|
300
|
+
if (line.length === 0) {
|
|
301
|
+
lineBreakIndex = buffer.indexOf("\n");
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
try {
|
|
306
|
+
settleResolve(parseResponseLine(line, request.id));
|
|
307
|
+
} catch (error) {
|
|
308
|
+
settleReject(error);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
socket.connect(port, DEFAULT_DAEMON_HOST);
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function parseDaemonRuntimeRecord(raw: string): DaemonRuntimeRecord | undefined {
|
|
320
|
+
const trimmedRaw = raw.trim();
|
|
321
|
+
if (trimmedRaw.length === 0) {
|
|
322
|
+
return undefined;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const legacyPid = Number.parseInt(trimmedRaw, 10);
|
|
326
|
+
if (Number.isFinite(legacyPid) && String(legacyPid) === trimmedRaw) {
|
|
327
|
+
return {
|
|
328
|
+
pid: legacyPid
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
try {
|
|
333
|
+
const parsed = JSON.parse(trimmedRaw);
|
|
334
|
+
if (!isObjectRecord(parsed)) {
|
|
335
|
+
return undefined;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const rawPid = parsed.pid;
|
|
339
|
+
if (typeof rawPid !== "number" || !Number.isFinite(rawPid) || rawPid <= 0) {
|
|
340
|
+
return undefined;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
pid: rawPid,
|
|
345
|
+
authToken: normalizeAuthToken(parsed.authToken)
|
|
346
|
+
};
|
|
347
|
+
} catch {
|
|
348
|
+
return undefined;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function persistDaemonRuntimeRecord(port: number, record: DaemonRuntimeRecord): void {
|
|
353
|
+
ensureRuntimeDir();
|
|
354
|
+
writeFileSync(resolvePidFile(port), JSON.stringify(record), { encoding: "utf8" });
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function readDaemonRuntimeRecord(port: number): DaemonRuntimeRecord | undefined {
|
|
358
|
+
try {
|
|
359
|
+
const content = readFileSync(resolvePidFile(port), { encoding: "utf8" });
|
|
360
|
+
return parseDaemonRuntimeRecord(content);
|
|
361
|
+
} catch {
|
|
362
|
+
return undefined;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function readDaemonPid(port: number): number | undefined {
|
|
367
|
+
return readDaemonRuntimeRecord(port)?.pid;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function clearDaemonPid(port: number): void {
|
|
371
|
+
rmSync(resolvePidFile(port), { force: true });
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function createDaemonSpawnEnv(
|
|
375
|
+
port: number,
|
|
376
|
+
authToken: string,
|
|
377
|
+
startup?: DaemonStartupConfig
|
|
378
|
+
): NodeJS.ProcessEnv {
|
|
379
|
+
const env: NodeJS.ProcessEnv = {
|
|
380
|
+
...process.env,
|
|
381
|
+
BROWSERD_TRANSPORT: "tcp",
|
|
382
|
+
BROWSERD_PORT: String(port),
|
|
383
|
+
BROWSERD_AUTH_TOKEN: authToken
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
if (startup !== undefined) {
|
|
387
|
+
env.BROWSERD_MANAGED_LOCAL_ENABLED = "true";
|
|
388
|
+
env.BROWSERD_MANAGED_LOCAL_BROWSER = startup.managedLocal.browserName;
|
|
389
|
+
if (startup.managedLocal.channel === undefined) {
|
|
390
|
+
delete env.BROWSERD_MANAGED_LOCAL_CHANNEL;
|
|
391
|
+
} else {
|
|
392
|
+
env.BROWSERD_MANAGED_LOCAL_CHANNEL = startup.managedLocal.channel;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return env;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function spawnDaemon(port: number, authToken: string, startup?: DaemonStartupConfig): void {
|
|
400
|
+
const daemonEntry = resolveBrowserdEntry();
|
|
401
|
+
const child = spawn(process.execPath, ["--import", "tsx", daemonEntry], {
|
|
402
|
+
cwd: resolveRepoRoot(),
|
|
403
|
+
detached: true,
|
|
404
|
+
stdio: "ignore",
|
|
405
|
+
windowsHide: true,
|
|
406
|
+
env: createDaemonSpawnEnv(port, authToken, startup)
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
if (child.pid === undefined) {
|
|
410
|
+
throw new Error("Failed to spawn daemon: missing pid.");
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
persistDaemonRuntimeRecord(port, {
|
|
414
|
+
pid: child.pid,
|
|
415
|
+
authToken
|
|
416
|
+
});
|
|
417
|
+
child.unref();
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async function waitForDaemonReady(port: number, authToken?: string): Promise<void> {
|
|
421
|
+
const timeoutMs = resolveDaemonStartupTimeoutMs();
|
|
422
|
+
const requestTimeoutMs = resolveDaemonRequestTimeoutMs();
|
|
423
|
+
const start = Date.now();
|
|
424
|
+
let lastError: unknown;
|
|
425
|
+
|
|
426
|
+
while (Date.now() - start <= timeoutMs) {
|
|
427
|
+
try {
|
|
428
|
+
await requestDaemon(
|
|
429
|
+
createToolRequest(
|
|
430
|
+
"browser.status",
|
|
431
|
+
withAuthToken(
|
|
432
|
+
{
|
|
433
|
+
sessionId: "cli:daemon-ready-check"
|
|
434
|
+
},
|
|
435
|
+
authToken
|
|
436
|
+
)
|
|
437
|
+
),
|
|
438
|
+
port,
|
|
439
|
+
requestTimeoutMs
|
|
440
|
+
);
|
|
441
|
+
return;
|
|
442
|
+
} catch (error) {
|
|
443
|
+
lastError = error;
|
|
444
|
+
await delay(STARTUP_POLL_INTERVAL_MS);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const message = lastError instanceof Error ? lastError.message : String(lastError);
|
|
449
|
+
throw new Error(`Daemon did not become ready on port ${port}: ${message}`);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async function ensureDaemonStarted(
|
|
453
|
+
port: number,
|
|
454
|
+
authToken?: string,
|
|
455
|
+
startup?: DaemonStartupConfig
|
|
456
|
+
): Promise<void> {
|
|
457
|
+
const existingLock = startupLocks.get(port);
|
|
458
|
+
if (existingLock !== undefined) {
|
|
459
|
+
await existingLock;
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const lock = (async () => {
|
|
464
|
+
const startupAuthToken = normalizeAuthToken(authToken) ?? `daemon-token:${randomUUID()}`;
|
|
465
|
+
spawnDaemon(port, startupAuthToken, startup);
|
|
466
|
+
await waitForDaemonReady(port, startupAuthToken);
|
|
467
|
+
})();
|
|
468
|
+
|
|
469
|
+
startupLocks.set(port, lock);
|
|
470
|
+
try {
|
|
471
|
+
await lock;
|
|
472
|
+
} finally {
|
|
473
|
+
startupLocks.delete(port);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
export async function getDaemonStatus(options: DaemonRequestOptions = {}): Promise<DaemonLifecycleStatus> {
|
|
478
|
+
const port = options.port ?? resolveDaemonPort();
|
|
479
|
+
const authToken = options.authToken ?? resolveDefaultAuthToken(port);
|
|
480
|
+
const requestTimeoutMs = resolveDaemonRequestTimeoutMs();
|
|
481
|
+
try {
|
|
482
|
+
await requestDaemon(
|
|
483
|
+
createToolRequest(
|
|
484
|
+
"browser.status",
|
|
485
|
+
withAuthToken(
|
|
486
|
+
{
|
|
487
|
+
sessionId: "cli:daemon-status"
|
|
488
|
+
},
|
|
489
|
+
authToken
|
|
490
|
+
)
|
|
491
|
+
),
|
|
492
|
+
port,
|
|
493
|
+
requestTimeoutMs
|
|
494
|
+
);
|
|
495
|
+
return {
|
|
496
|
+
running: true,
|
|
497
|
+
port,
|
|
498
|
+
pid: readDaemonPid(port)
|
|
499
|
+
};
|
|
500
|
+
} catch (error) {
|
|
501
|
+
if (isConnectionError(error)) {
|
|
502
|
+
return {
|
|
503
|
+
running: false,
|
|
504
|
+
port,
|
|
505
|
+
pid: readDaemonPid(port)
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
throw error;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
export async function ensureDaemonRunning(
|
|
514
|
+
options: DaemonRequestOptions = {}
|
|
515
|
+
): Promise<DaemonLifecycleStatus> {
|
|
516
|
+
const port = options.port ?? resolveDaemonPort();
|
|
517
|
+
const authToken = options.authToken ?? resolveDefaultAuthToken(port);
|
|
518
|
+
const status = await getDaemonStatus({
|
|
519
|
+
port,
|
|
520
|
+
authToken
|
|
521
|
+
});
|
|
522
|
+
if (status.running) {
|
|
523
|
+
return status;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
await ensureDaemonStarted(port, authToken, options.startup);
|
|
527
|
+
return {
|
|
528
|
+
running: true,
|
|
529
|
+
port,
|
|
530
|
+
pid: readDaemonPid(port)
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
export async function stopDaemon(
|
|
535
|
+
port = resolveDaemonPort()
|
|
536
|
+
): Promise<{ stopped: boolean; port: number; pid?: number }> {
|
|
537
|
+
const runtimeRecord = readDaemonRuntimeRecord(port);
|
|
538
|
+
if (runtimeRecord === undefined) {
|
|
539
|
+
return {
|
|
540
|
+
stopped: false,
|
|
541
|
+
port
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const pid = runtimeRecord.pid;
|
|
546
|
+
const authToken = runtimeRecord.authToken;
|
|
547
|
+
const requestTimeoutMs = resolveDaemonRequestTimeoutMs();
|
|
548
|
+
|
|
549
|
+
try {
|
|
550
|
+
const probe = await requestDaemon(
|
|
551
|
+
createToolRequest(
|
|
552
|
+
"browser.status",
|
|
553
|
+
withAuthToken(
|
|
554
|
+
{
|
|
555
|
+
sessionId: "cli:daemon-stop-check"
|
|
556
|
+
},
|
|
557
|
+
authToken
|
|
558
|
+
)
|
|
559
|
+
),
|
|
560
|
+
port,
|
|
561
|
+
requestTimeoutMs
|
|
562
|
+
);
|
|
563
|
+
if (!probe.ok) {
|
|
564
|
+
clearDaemonPid(port);
|
|
565
|
+
return {
|
|
566
|
+
stopped: false,
|
|
567
|
+
port,
|
|
568
|
+
pid
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
} catch {
|
|
572
|
+
clearDaemonPid(port);
|
|
573
|
+
return {
|
|
574
|
+
stopped: false,
|
|
575
|
+
port,
|
|
576
|
+
pid
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
try {
|
|
581
|
+
process.kill(pid);
|
|
582
|
+
clearDaemonPid(port);
|
|
583
|
+
return {
|
|
584
|
+
stopped: true,
|
|
585
|
+
port,
|
|
586
|
+
pid
|
|
587
|
+
};
|
|
588
|
+
} catch {
|
|
589
|
+
clearDaemonPid(port);
|
|
590
|
+
return {
|
|
591
|
+
stopped: false,
|
|
592
|
+
port,
|
|
593
|
+
pid
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
export async function callDaemonTool<TData = Record<string, unknown>>(
|
|
599
|
+
name: string,
|
|
600
|
+
args: Record<string, unknown>
|
|
601
|
+
): Promise<TData> {
|
|
602
|
+
const port = resolveDaemonPort();
|
|
603
|
+
const extracted = extractDaemonStartupConfig(args);
|
|
604
|
+
const authToken = normalizeAuthToken(extracted.requestArgs.authToken) ?? resolveDefaultAuthToken(port);
|
|
605
|
+
const requestArgs = withAuthToken(extracted.requestArgs, authToken);
|
|
606
|
+
const requestTimeoutMs = resolveDaemonRequestTimeoutMs();
|
|
607
|
+
const request = createToolRequest(name, requestArgs);
|
|
608
|
+
|
|
609
|
+
let response: ToolEnvelope;
|
|
610
|
+
try {
|
|
611
|
+
response = await requestDaemon(request, port, requestTimeoutMs);
|
|
612
|
+
} catch (error) {
|
|
613
|
+
if (!isConnectionError(error)) {
|
|
614
|
+
throw error;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
await ensureDaemonStarted(port, authToken, extracted.startup);
|
|
618
|
+
response = await requestDaemon(request, port, requestTimeoutMs);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
if (!response.ok) {
|
|
622
|
+
const code = response.error?.code ?? "E_INTERNAL";
|
|
623
|
+
const message = response.error?.message ?? "Unknown daemon error.";
|
|
624
|
+
throw new Error(`${code}: ${message}`);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
if (!isObjectRecord(response.data)) {
|
|
628
|
+
return {} as TData;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
return response.data as TData;
|
|
632
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { stopDaemon } from "./daemon-client";
|
|
4
|
+
import { EXIT_CODES, runCli } from "./main";
|
|
5
|
+
|
|
6
|
+
const TEST_DAEMON_PORT = "42491";
|
|
7
|
+
const TEST_SESSION_ID = "session:e2e";
|
|
8
|
+
|
|
9
|
+
function createIoCapture() {
|
|
10
|
+
const state = {
|
|
11
|
+
stdout: "",
|
|
12
|
+
stderr: ""
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
state,
|
|
17
|
+
io: {
|
|
18
|
+
stdout: {
|
|
19
|
+
write(content: string) {
|
|
20
|
+
state.stdout += content;
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
stderr: {
|
|
24
|
+
write(content: string) {
|
|
25
|
+
state.stderr += content;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function parseJsonLine(state: { stdout: string }): Record<string, unknown> {
|
|
33
|
+
return JSON.parse(state.stdout.trim()) as Record<string, unknown>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
beforeEach(async () => {
|
|
37
|
+
process.env.BROWSERCTL_DAEMON_PORT = TEST_DAEMON_PORT;
|
|
38
|
+
process.env.BROWSERD_MANAGED_LOCAL_ENABLED = "false";
|
|
39
|
+
process.env.BROWSERD_DEFAULT_DRIVER = "managed";
|
|
40
|
+
await stopDaemon(Number.parseInt(TEST_DAEMON_PORT, 10));
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterEach(async () => {
|
|
44
|
+
await stopDaemon(Number.parseInt(TEST_DAEMON_PORT, 10));
|
|
45
|
+
delete process.env.BROWSERCTL_DAEMON_PORT;
|
|
46
|
+
delete process.env.BROWSERD_MANAGED_LOCAL_ENABLED;
|
|
47
|
+
delete process.env.BROWSERD_DEFAULT_DRIVER;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("browserctl e2e", () => {
|
|
51
|
+
it("reuses daemon state across multiple commands in the same session", async () => {
|
|
52
|
+
const startCapture = createIoCapture();
|
|
53
|
+
const startExitCode = await runCli(["daemon-start", "--json"], startCapture.io);
|
|
54
|
+
|
|
55
|
+
expect(startExitCode).toBe(EXIT_CODES.OK);
|
|
56
|
+
const startPayload = parseJsonLine(startCapture.state);
|
|
57
|
+
expect(startPayload.ok).toBe(true);
|
|
58
|
+
const startData = startPayload.data as Record<string, unknown>;
|
|
59
|
+
expect(startData.running).toBe(true);
|
|
60
|
+
expect(startData.port).toBe(Number.parseInt(TEST_DAEMON_PORT, 10));
|
|
61
|
+
|
|
62
|
+
const openCapture = createIoCapture();
|
|
63
|
+
const openExitCode = await runCli(
|
|
64
|
+
["tab-open", "--session", TEST_SESSION_ID, "https://example.com", "--json"],
|
|
65
|
+
openCapture.io
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
expect(openExitCode).toBe(EXIT_CODES.OK);
|
|
69
|
+
const openPayload = parseJsonLine(openCapture.state);
|
|
70
|
+
expect(openPayload.ok).toBe(true);
|
|
71
|
+
const openData = openPayload.data as Record<string, unknown>;
|
|
72
|
+
expect(openData.driver).toBe("managed");
|
|
73
|
+
const targetId = openData.targetId;
|
|
74
|
+
expect(typeof targetId).toBe("string");
|
|
75
|
+
|
|
76
|
+
const listCapture = createIoCapture();
|
|
77
|
+
const listExitCode = await runCli(["tabs", "--session", TEST_SESSION_ID, "--json"], listCapture.io);
|
|
78
|
+
|
|
79
|
+
expect(listExitCode).toBe(EXIT_CODES.OK);
|
|
80
|
+
const listPayload = parseJsonLine(listCapture.state);
|
|
81
|
+
expect(listPayload.ok).toBe(true);
|
|
82
|
+
const listData = listPayload.data as { tabs: string[] };
|
|
83
|
+
expect(listData.tabs).toContain(targetId);
|
|
84
|
+
|
|
85
|
+
const consoleCapture = createIoCapture();
|
|
86
|
+
const consoleExitCode = await runCli(
|
|
87
|
+
["console-list", "--session", TEST_SESSION_ID, String(targetId), "--json"],
|
|
88
|
+
consoleCapture.io
|
|
89
|
+
);
|
|
90
|
+
expect(consoleExitCode).toBe(EXIT_CODES.OK);
|
|
91
|
+
const consolePayload = parseJsonLine(consoleCapture.state);
|
|
92
|
+
expect(consolePayload.ok).toBe(true);
|
|
93
|
+
expect(consolePayload.data).toMatchObject({
|
|
94
|
+
driver: "managed",
|
|
95
|
+
targetId,
|
|
96
|
+
entries: []
|
|
97
|
+
});
|
|
98
|
+
}, 20_000);
|
|
99
|
+
});
|