@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,19 @@
|
|
|
1
|
+
export { createRemoteCdpDriver } from "./remote-cdp-driver";
|
|
2
|
+
export type {
|
|
3
|
+
RemoteCdpBrowser,
|
|
4
|
+
RemoteCdpBrowserContext,
|
|
5
|
+
RemoteCdpConsoleEntry,
|
|
6
|
+
RemoteCdpConsoleEntryLocation,
|
|
7
|
+
RemoteCdpDriverConfig,
|
|
8
|
+
RemoteCdpDriverRuntime,
|
|
9
|
+
RemoteCdpDriverStatus,
|
|
10
|
+
RemoteCdpEndpoint,
|
|
11
|
+
RemoteCdpKeyboard,
|
|
12
|
+
RemoteCdpLocator,
|
|
13
|
+
RemoteCdpNetworkRequestSummary,
|
|
14
|
+
RemoteCdpNetworkResponseBody,
|
|
15
|
+
RemoteCdpPage,
|
|
16
|
+
RemoteCdpScreenshot,
|
|
17
|
+
RemoteCdpSnapshot,
|
|
18
|
+
RemoteCdpTelemetryDriverExtensions
|
|
19
|
+
} from "./remote-cdp-driver";
|
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { createRemoteCdpDriver } from "./index";
|
|
4
|
+
|
|
5
|
+
type MockConsoleMessage = {
|
|
6
|
+
type(): string;
|
|
7
|
+
text(): string;
|
|
8
|
+
location?(): {
|
|
9
|
+
url?: string;
|
|
10
|
+
lineNumber?: number;
|
|
11
|
+
columnNumber?: number;
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type MockNetworkResponse = {
|
|
16
|
+
url(): string;
|
|
17
|
+
status(): number;
|
|
18
|
+
request(): {
|
|
19
|
+
method(): string;
|
|
20
|
+
resourceType?(): string;
|
|
21
|
+
};
|
|
22
|
+
text?(): Promise<string>;
|
|
23
|
+
body?(): Promise<Uint8Array | ArrayBuffer | string>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type MockDialog = {
|
|
27
|
+
accept?(): Promise<void>;
|
|
28
|
+
dismiss?(): Promise<void>;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type MockDownload = {
|
|
32
|
+
path?(): Promise<string>;
|
|
33
|
+
saveAs?(path: string): Promise<void>;
|
|
34
|
+
suggestedFilename?(): string;
|
|
35
|
+
url?(): string;
|
|
36
|
+
mimeType?(): string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
type MockPage = {
|
|
40
|
+
goto(url: string): Promise<void>;
|
|
41
|
+
bringToFront(): Promise<void>;
|
|
42
|
+
close(): Promise<void>;
|
|
43
|
+
url(): string;
|
|
44
|
+
title(): Promise<string>;
|
|
45
|
+
content(): Promise<string>;
|
|
46
|
+
screenshot?(options?: Record<string, unknown>): Promise<unknown>;
|
|
47
|
+
locator(selector: string): {
|
|
48
|
+
click(): Promise<void>;
|
|
49
|
+
fill(value: string): Promise<void>;
|
|
50
|
+
type(value: string): Promise<void>;
|
|
51
|
+
setInputFiles?(files: string[]): Promise<void>;
|
|
52
|
+
};
|
|
53
|
+
keyboard?: {
|
|
54
|
+
press(key: string): Promise<void>;
|
|
55
|
+
};
|
|
56
|
+
on?(eventName: string, listener: (payload: unknown) => unknown): void;
|
|
57
|
+
waitForEvent?(eventName: string): Promise<unknown>;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
type MockPageRecord = {
|
|
61
|
+
page: MockPage;
|
|
62
|
+
goto: ReturnType<typeof vi.fn>;
|
|
63
|
+
bringToFront: ReturnType<typeof vi.fn>;
|
|
64
|
+
close: ReturnType<typeof vi.fn>;
|
|
65
|
+
title: ReturnType<typeof vi.fn>;
|
|
66
|
+
content: ReturnType<typeof vi.fn>;
|
|
67
|
+
screenshot: ReturnType<typeof vi.fn>;
|
|
68
|
+
locator: ReturnType<typeof vi.fn>;
|
|
69
|
+
locatorClick: ReturnType<typeof vi.fn>;
|
|
70
|
+
locatorFill: ReturnType<typeof vi.fn>;
|
|
71
|
+
locatorType: ReturnType<typeof vi.fn>;
|
|
72
|
+
locatorSetInputFiles: ReturnType<typeof vi.fn>;
|
|
73
|
+
keyboardPress: ReturnType<typeof vi.fn>;
|
|
74
|
+
waitForEvent: ReturnType<typeof vi.fn>;
|
|
75
|
+
on: ReturnType<typeof vi.fn>;
|
|
76
|
+
emitConsole(entry: MockConsoleMessage): Promise<void>;
|
|
77
|
+
emitResponse(response: MockNetworkResponse): Promise<void>;
|
|
78
|
+
emitDialog(dialog: MockDialog): Promise<void>;
|
|
79
|
+
emitDownload(download: MockDownload): Promise<void>;
|
|
80
|
+
getUrl(): string;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
type RuntimeHarness = {
|
|
84
|
+
runtime: {
|
|
85
|
+
connectOverCDP(endpointUrl: string): Promise<{
|
|
86
|
+
contexts(): Array<{
|
|
87
|
+
newPage(): Promise<MockPage>;
|
|
88
|
+
}>;
|
|
89
|
+
close(): Promise<void>;
|
|
90
|
+
}>;
|
|
91
|
+
};
|
|
92
|
+
connectOverCDP: ReturnType<typeof vi.fn>;
|
|
93
|
+
browserClose: ReturnType<typeof vi.fn>;
|
|
94
|
+
pages: MockPageRecord[];
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
type RuntimeHarnessOptions = {
|
|
98
|
+
supportsDownloadEvents?: boolean;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
function createMockPageRecord(pageNumber: number): MockPageRecord {
|
|
102
|
+
let currentUrl = "about:blank";
|
|
103
|
+
const listeners = new Map<string, Array<(payload: unknown) => unknown>>();
|
|
104
|
+
const waiters = new Map<
|
|
105
|
+
string,
|
|
106
|
+
Array<{ resolve: (value: unknown) => void; reject: (error: unknown) => void }>
|
|
107
|
+
>();
|
|
108
|
+
|
|
109
|
+
const goto = vi.fn(async (url: string) => {
|
|
110
|
+
currentUrl = url;
|
|
111
|
+
});
|
|
112
|
+
const bringToFront = vi.fn(async () => {});
|
|
113
|
+
const close = vi.fn(async () => {});
|
|
114
|
+
const title = vi.fn(async () => `mock-title-${pageNumber}`);
|
|
115
|
+
const content = vi.fn(async () => `<html data-page="${pageNumber}" />`);
|
|
116
|
+
const screenshot = vi.fn(async () => new Uint8Array([0, 1, 2, pageNumber]));
|
|
117
|
+
const locatorClick = vi.fn(async () => {});
|
|
118
|
+
const locatorFill = vi.fn(async (_value: string) => {});
|
|
119
|
+
const locatorType = vi.fn(async (_value: string) => {});
|
|
120
|
+
const locatorSetInputFiles = vi.fn(async (_files: string[]) => {});
|
|
121
|
+
const locator = vi.fn((_selector: string) => ({
|
|
122
|
+
click: locatorClick,
|
|
123
|
+
fill: locatorFill,
|
|
124
|
+
type: locatorType,
|
|
125
|
+
setInputFiles: locatorSetInputFiles
|
|
126
|
+
}));
|
|
127
|
+
const keyboardPress = vi.fn(async (_key: string) => {});
|
|
128
|
+
const waitForEvent = vi.fn(async (eventName: string) => {
|
|
129
|
+
return await new Promise<unknown>((resolve, reject) => {
|
|
130
|
+
const current = waiters.get(eventName);
|
|
131
|
+
if (current !== undefined) {
|
|
132
|
+
current.push({ resolve, reject });
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
waiters.set(eventName, [{ resolve, reject }]);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
const on = vi.fn((eventName: string, listener: (payload: unknown) => unknown) => {
|
|
140
|
+
const existing = listeners.get(eventName);
|
|
141
|
+
if (existing !== undefined) {
|
|
142
|
+
existing.push(listener);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
listeners.set(eventName, [listener]);
|
|
147
|
+
});
|
|
148
|
+
const emit = async (eventName: string, payload: unknown): Promise<void> => {
|
|
149
|
+
const eventListeners = listeners.get(eventName);
|
|
150
|
+
if (eventListeners !== undefined) {
|
|
151
|
+
for (const listener of eventListeners) {
|
|
152
|
+
await listener(payload);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const eventWaiters = waiters.get(eventName);
|
|
157
|
+
if (eventWaiters === undefined || eventWaiters.length === 0) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const nextWaiter = eventWaiters.shift();
|
|
162
|
+
nextWaiter?.resolve(payload);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const page: MockPage = {
|
|
166
|
+
goto,
|
|
167
|
+
bringToFront,
|
|
168
|
+
close,
|
|
169
|
+
url: () => currentUrl,
|
|
170
|
+
title,
|
|
171
|
+
content,
|
|
172
|
+
screenshot,
|
|
173
|
+
locator,
|
|
174
|
+
on,
|
|
175
|
+
waitForEvent,
|
|
176
|
+
keyboard: {
|
|
177
|
+
press: keyboardPress
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
page,
|
|
183
|
+
goto,
|
|
184
|
+
bringToFront,
|
|
185
|
+
close,
|
|
186
|
+
title,
|
|
187
|
+
content,
|
|
188
|
+
screenshot,
|
|
189
|
+
locator,
|
|
190
|
+
locatorClick,
|
|
191
|
+
locatorFill,
|
|
192
|
+
locatorType,
|
|
193
|
+
locatorSetInputFiles,
|
|
194
|
+
keyboardPress,
|
|
195
|
+
waitForEvent,
|
|
196
|
+
on,
|
|
197
|
+
emitConsole: async (entry) => {
|
|
198
|
+
await emit("console", entry);
|
|
199
|
+
},
|
|
200
|
+
emitResponse: async (response) => {
|
|
201
|
+
await emit("response", response);
|
|
202
|
+
},
|
|
203
|
+
emitDialog: async (dialog) => {
|
|
204
|
+
await emit("dialog", dialog);
|
|
205
|
+
},
|
|
206
|
+
emitDownload: async (download) => {
|
|
207
|
+
await emit("download", download);
|
|
208
|
+
},
|
|
209
|
+
getUrl: () => currentUrl
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function createRuntimeHarness(options: RuntimeHarnessOptions = {}): RuntimeHarness {
|
|
214
|
+
const supportsDownloadEvents = options.supportsDownloadEvents ?? false;
|
|
215
|
+
const pages: MockPageRecord[] = [];
|
|
216
|
+
const newPage = vi.fn(async () => {
|
|
217
|
+
const pageRecord = createMockPageRecord(pages.length + 1);
|
|
218
|
+
if (!supportsDownloadEvents) {
|
|
219
|
+
delete (pageRecord.page as Record<string, unknown>).waitForEvent;
|
|
220
|
+
}
|
|
221
|
+
pages.push(pageRecord);
|
|
222
|
+
return pageRecord.page;
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const browserClose = vi.fn(async () => {});
|
|
226
|
+
const connectOverCDP = vi.fn(async () => ({
|
|
227
|
+
contexts: () => [
|
|
228
|
+
{
|
|
229
|
+
newPage
|
|
230
|
+
}
|
|
231
|
+
],
|
|
232
|
+
close: browserClose
|
|
233
|
+
}));
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
runtime: {
|
|
237
|
+
connectOverCDP
|
|
238
|
+
},
|
|
239
|
+
connectOverCDP,
|
|
240
|
+
browserClose,
|
|
241
|
+
pages
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
describe("createRemoteCdpDriver", () => {
|
|
246
|
+
it("throws when cdpUrl is empty", () => {
|
|
247
|
+
expect(() => createRemoteCdpDriver({ cdpUrl: "" })).toThrowError(
|
|
248
|
+
"Invalid cdpUrl: value must not be empty."
|
|
249
|
+
);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("keeps status lazy and reports parsed endpoint", async () => {
|
|
253
|
+
const harness = createRuntimeHarness();
|
|
254
|
+
const driver = createRemoteCdpDriver({
|
|
255
|
+
cdpUrl: " ws://localhost:9222/devtools/browser/example ",
|
|
256
|
+
runtime: harness.runtime
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
expect(await driver.status()).toEqual({
|
|
260
|
+
kind: "remote-cdp",
|
|
261
|
+
connected: false,
|
|
262
|
+
endpoint: {
|
|
263
|
+
url: "ws://localhost:9222/devtools/browser/example",
|
|
264
|
+
protocol: "ws:",
|
|
265
|
+
host: "localhost:9222"
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
expect(await driver.listTabs()).toEqual([]);
|
|
269
|
+
expect(harness.connectOverCDP).toHaveBeenCalledTimes(0);
|
|
270
|
+
|
|
271
|
+
await driver.openTab("https://example.com/a", "profile:alpha");
|
|
272
|
+
|
|
273
|
+
expect(harness.connectOverCDP).toHaveBeenCalledWith(
|
|
274
|
+
"ws://localhost:9222/devtools/browser/example"
|
|
275
|
+
);
|
|
276
|
+
expect(await driver.status()).toMatchObject({
|
|
277
|
+
kind: "remote-cdp",
|
|
278
|
+
connected: true
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("open/list/focus/close keeps deterministic ids per profile", async () => {
|
|
283
|
+
const harness = createRuntimeHarness();
|
|
284
|
+
const driver = createRemoteCdpDriver({
|
|
285
|
+
cdpUrl: "ws://localhost:9222/devtools/browser/example",
|
|
286
|
+
runtime: harness.runtime
|
|
287
|
+
});
|
|
288
|
+
const alpha = "profile:alpha";
|
|
289
|
+
|
|
290
|
+
const firstTarget = await driver.openTab("https://example.com/1", alpha);
|
|
291
|
+
const secondTarget = await driver.openTab("https://example.com/2", alpha);
|
|
292
|
+
const betaTarget = await driver.openTab("https://example.com/3", "profile:beta");
|
|
293
|
+
|
|
294
|
+
expect(firstTarget).toBe("target:remote-cdp:profile:alpha:1");
|
|
295
|
+
expect(secondTarget).toBe("target:remote-cdp:profile:alpha:2");
|
|
296
|
+
expect(betaTarget).toBe("target:remote-cdp:profile:beta:1");
|
|
297
|
+
expect(await driver.listTabs(alpha)).toEqual([firstTarget, secondTarget]);
|
|
298
|
+
|
|
299
|
+
await driver.focusTab(secondTarget, alpha);
|
|
300
|
+
expect(harness.pages[1]?.bringToFront).toHaveBeenCalledTimes(1);
|
|
301
|
+
|
|
302
|
+
await driver.closeTab(firstTarget, alpha);
|
|
303
|
+
expect(await driver.listTabs(alpha)).toEqual([secondTarget]);
|
|
304
|
+
await driver.closeTab(secondTarget, alpha);
|
|
305
|
+
expect(await driver.listTabs(alpha)).toEqual([]);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("snapshot reports hasTarget true for active tab and false after close", async () => {
|
|
309
|
+
const harness = createRuntimeHarness();
|
|
310
|
+
const driver = createRemoteCdpDriver({
|
|
311
|
+
cdpUrl: "ws://localhost:9222/devtools/browser/example",
|
|
312
|
+
runtime: harness.runtime
|
|
313
|
+
});
|
|
314
|
+
const profile = "profile:alpha";
|
|
315
|
+
const targetId = await driver.openTab("https://example.com/snapshot", profile);
|
|
316
|
+
|
|
317
|
+
await expect(driver.snapshot(targetId, profile)).resolves.toEqual({
|
|
318
|
+
kind: "remote-cdp",
|
|
319
|
+
profile,
|
|
320
|
+
targetId,
|
|
321
|
+
endpoint: {
|
|
322
|
+
url: "ws://localhost:9222/devtools/browser/example",
|
|
323
|
+
protocol: "ws:",
|
|
324
|
+
host: "localhost:9222"
|
|
325
|
+
},
|
|
326
|
+
hasTarget: true,
|
|
327
|
+
url: "https://example.com/snapshot",
|
|
328
|
+
title: "mock-title-1",
|
|
329
|
+
html: '<html data-page="1" />',
|
|
330
|
+
requestSummaries: []
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
await expect(driver.screenshot(targetId, profile)).resolves.toEqual({
|
|
334
|
+
kind: "remote-cdp",
|
|
335
|
+
profile,
|
|
336
|
+
targetId,
|
|
337
|
+
endpoint: {
|
|
338
|
+
url: "ws://localhost:9222/devtools/browser/example",
|
|
339
|
+
protocol: "ws:",
|
|
340
|
+
host: "localhost:9222"
|
|
341
|
+
},
|
|
342
|
+
hasTarget: true,
|
|
343
|
+
mimeType: "image/png",
|
|
344
|
+
encoding: "base64",
|
|
345
|
+
imageBase64: "AAECAQ=="
|
|
346
|
+
});
|
|
347
|
+
expect(harness.pages[0]?.screenshot).toHaveBeenCalledTimes(1);
|
|
348
|
+
|
|
349
|
+
await driver.closeTab(targetId, profile);
|
|
350
|
+
|
|
351
|
+
await expect(driver.snapshot(targetId, profile)).resolves.toEqual({
|
|
352
|
+
kind: "remote-cdp",
|
|
353
|
+
profile,
|
|
354
|
+
targetId,
|
|
355
|
+
endpoint: {
|
|
356
|
+
url: "ws://localhost:9222/devtools/browser/example",
|
|
357
|
+
protocol: "ws:",
|
|
358
|
+
host: "localhost:9222"
|
|
359
|
+
},
|
|
360
|
+
hasTarget: false
|
|
361
|
+
});
|
|
362
|
+
await expect(driver.screenshot(targetId, profile)).resolves.toMatchObject({
|
|
363
|
+
hasTarget: false
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("act supports goto/click/fill/type/press and unknown action fallback", async () => {
|
|
368
|
+
const harness = createRuntimeHarness();
|
|
369
|
+
const driver = createRemoteCdpDriver({
|
|
370
|
+
cdpUrl: "ws://localhost:9222/devtools/browser/example",
|
|
371
|
+
runtime: harness.runtime
|
|
372
|
+
});
|
|
373
|
+
const profile = "profile:alpha";
|
|
374
|
+
const targetId = await driver.openTab("https://example.com/start", profile);
|
|
375
|
+
const pageRecord = harness.pages[0];
|
|
376
|
+
if (pageRecord === undefined) {
|
|
377
|
+
throw new Error("Expected a mock page record.");
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
await driver.act({ type: "goto", payload: { url: "https://example.com/next" } }, targetId, profile);
|
|
381
|
+
await driver.act({ type: "click", payload: { selector: "#go" } }, targetId, profile);
|
|
382
|
+
await driver.act(
|
|
383
|
+
{ type: "fill", payload: { selector: "#name", value: "remote" } },
|
|
384
|
+
targetId,
|
|
385
|
+
profile
|
|
386
|
+
);
|
|
387
|
+
await driver.act(
|
|
388
|
+
{ type: "type", payload: { selector: "#query", text: "remote cdp" } },
|
|
389
|
+
targetId,
|
|
390
|
+
profile
|
|
391
|
+
);
|
|
392
|
+
await driver.act({ type: "press", payload: { key: "Enter" } }, targetId, profile);
|
|
393
|
+
|
|
394
|
+
await expect(driver.act({ type: "unknown:noop" }, targetId, profile)).resolves.toMatchObject({
|
|
395
|
+
actionType: "unknown:noop",
|
|
396
|
+
profile,
|
|
397
|
+
targetId,
|
|
398
|
+
targetKnown: true,
|
|
399
|
+
ok: true,
|
|
400
|
+
executed: false
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
expect(pageRecord.getUrl()).toBe("https://example.com/next");
|
|
404
|
+
expect(pageRecord.locator).toHaveBeenCalledWith("#go");
|
|
405
|
+
expect(pageRecord.locatorClick).toHaveBeenCalledTimes(1);
|
|
406
|
+
expect(pageRecord.locatorFill).toHaveBeenCalledWith("remote");
|
|
407
|
+
expect(pageRecord.locatorType).toHaveBeenCalledWith("remote cdp");
|
|
408
|
+
expect(pageRecord.keyboardPress).toHaveBeenCalledWith("Enter");
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it("captures console + network telemetry and supports request body lookup", async () => {
|
|
412
|
+
const harness = createRuntimeHarness();
|
|
413
|
+
const driver = createRemoteCdpDriver({
|
|
414
|
+
cdpUrl: "ws://localhost:9222/devtools/browser/example",
|
|
415
|
+
runtime: harness.runtime
|
|
416
|
+
});
|
|
417
|
+
const profile = "profile:alpha";
|
|
418
|
+
const targetId = await driver.openTab("https://example.com/events", profile);
|
|
419
|
+
const pageRecord = harness.pages[0];
|
|
420
|
+
if (pageRecord === undefined) {
|
|
421
|
+
throw new Error("Expected a mock page record.");
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
await pageRecord.emitConsole({
|
|
425
|
+
type: () => "warning",
|
|
426
|
+
text: () => "remote warning",
|
|
427
|
+
location: () => ({
|
|
428
|
+
url: "https://example.com/app.js",
|
|
429
|
+
lineNumber: 9,
|
|
430
|
+
columnNumber: 4
|
|
431
|
+
})
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
await pageRecord.emitResponse({
|
|
435
|
+
url: () => "https://example.com/api/items",
|
|
436
|
+
status: () => 200,
|
|
437
|
+
request: () => ({
|
|
438
|
+
method: () => "GET",
|
|
439
|
+
resourceType: () => "xhr"
|
|
440
|
+
}),
|
|
441
|
+
text: async () => '{"ok":true}'
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
await pageRecord.emitResponse({
|
|
445
|
+
url: () => "https://example.com/api/binary",
|
|
446
|
+
status: () => 201,
|
|
447
|
+
request: () => ({
|
|
448
|
+
method: () => "POST",
|
|
449
|
+
resourceType: () => "fetch"
|
|
450
|
+
}),
|
|
451
|
+
body: async () => new Uint8Array([0, 1, 2])
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
const telemetryDriver = driver as typeof driver & {
|
|
455
|
+
getConsoleEntries?: (targetId: string, profile?: string) => Array<{
|
|
456
|
+
text: string;
|
|
457
|
+
type: string;
|
|
458
|
+
}>;
|
|
459
|
+
getNetworkResponseBody?: (
|
|
460
|
+
requestId: string,
|
|
461
|
+
targetId: string,
|
|
462
|
+
profile?: string
|
|
463
|
+
) => { body: string; encoding: "utf8" | "base64" } | undefined;
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
const consoleEntries = telemetryDriver.getConsoleEntries?.(targetId, profile) ?? [];
|
|
467
|
+
expect(consoleEntries).toEqual([
|
|
468
|
+
expect.objectContaining({
|
|
469
|
+
type: "warning",
|
|
470
|
+
text: "remote warning",
|
|
471
|
+
location: {
|
|
472
|
+
url: "https://example.com/app.js",
|
|
473
|
+
lineNumber: 9,
|
|
474
|
+
columnNumber: 4
|
|
475
|
+
}
|
|
476
|
+
})
|
|
477
|
+
]);
|
|
478
|
+
consoleEntries[0].text = "mutated";
|
|
479
|
+
expect(telemetryDriver.getConsoleEntries?.(targetId, profile)?.[0]?.text).toBe("remote warning");
|
|
480
|
+
|
|
481
|
+
const snapshot = await driver.snapshot(targetId, profile);
|
|
482
|
+
expect(snapshot).toMatchObject({
|
|
483
|
+
hasTarget: true,
|
|
484
|
+
requestSummaries: [
|
|
485
|
+
expect.objectContaining({
|
|
486
|
+
requestId: expect.any(String),
|
|
487
|
+
url: "https://example.com/api/items",
|
|
488
|
+
method: "GET",
|
|
489
|
+
resourceType: "xhr",
|
|
490
|
+
status: 200
|
|
491
|
+
}),
|
|
492
|
+
expect.objectContaining({
|
|
493
|
+
requestId: expect.any(String),
|
|
494
|
+
url: "https://example.com/api/binary",
|
|
495
|
+
method: "POST",
|
|
496
|
+
resourceType: "fetch",
|
|
497
|
+
status: 201
|
|
498
|
+
})
|
|
499
|
+
]
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
const requestSummaries = (
|
|
503
|
+
snapshot as {
|
|
504
|
+
requestSummaries?: Array<{ requestId: string; url: string }>;
|
|
505
|
+
}
|
|
506
|
+
).requestSummaries;
|
|
507
|
+
const firstRequestId = requestSummaries?.[0]?.requestId;
|
|
508
|
+
const secondRequestId = requestSummaries?.[1]?.requestId;
|
|
509
|
+
if (typeof firstRequestId !== "string" || typeof secondRequestId !== "string") {
|
|
510
|
+
throw new Error("Expected request ids from telemetry snapshot.");
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
expect(telemetryDriver.getNetworkResponseBody?.(firstRequestId, targetId, profile)).toEqual({
|
|
514
|
+
body: '{"ok":true}',
|
|
515
|
+
encoding: "utf8"
|
|
516
|
+
});
|
|
517
|
+
expect(telemetryDriver.getNetworkResponseBody?.(secondRequestId, targetId, profile)).toEqual({
|
|
518
|
+
body: "AAEC",
|
|
519
|
+
encoding: "base64"
|
|
520
|
+
});
|
|
521
|
+
expect(telemetryDriver.getNetworkResponseBody?.("request:missing", targetId, profile)).toBeUndefined();
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it("keeps upload/dialog/download state scoped to target + profile", async () => {
|
|
525
|
+
const harness = createRuntimeHarness();
|
|
526
|
+
const driver = createRemoteCdpDriver({
|
|
527
|
+
cdpUrl: "ws://localhost:9222/devtools/browser/example",
|
|
528
|
+
runtime: harness.runtime
|
|
529
|
+
});
|
|
530
|
+
const alphaProfile = "profile:alpha";
|
|
531
|
+
const betaProfile = "profile:beta";
|
|
532
|
+
const alphaTarget = await driver.openTab("https://example.com/alpha", alphaProfile);
|
|
533
|
+
const betaTarget = await driver.openTab("https://example.com/beta", betaProfile);
|
|
534
|
+
|
|
535
|
+
await expect(driver.armUpload(alphaTarget, ["wrong-profile.txt"], betaProfile)).rejects.toThrowError(
|
|
536
|
+
`Unknown targetId: ${alphaTarget} (profile: ${betaProfile})`
|
|
537
|
+
);
|
|
538
|
+
|
|
539
|
+
await driver.armUpload(alphaTarget, ["alpha.txt"], alphaProfile);
|
|
540
|
+
await driver.armUpload(betaTarget, ["beta.txt"], betaProfile);
|
|
541
|
+
await driver.armDialog(betaTarget, betaProfile);
|
|
542
|
+
await driver.triggerDownload(alphaTarget, alphaProfile);
|
|
543
|
+
await driver.triggerDownload(betaTarget, betaProfile);
|
|
544
|
+
|
|
545
|
+
await expect(driver.waitDownload(alphaTarget, alphaProfile)).resolves.toEqual({
|
|
546
|
+
path: "remote-cdp-profile%3Aalpha-target%3Aremote-cdp%3Aprofile%3Aalpha%3A1-1.bin",
|
|
547
|
+
profile: alphaProfile,
|
|
548
|
+
targetId: alphaTarget,
|
|
549
|
+
endpoint: {
|
|
550
|
+
url: "ws://localhost:9222/devtools/browser/example",
|
|
551
|
+
protocol: "ws:",
|
|
552
|
+
host: "localhost:9222"
|
|
553
|
+
},
|
|
554
|
+
uploadFiles: ["alpha.txt"],
|
|
555
|
+
dialogArmedCount: 0,
|
|
556
|
+
triggerCount: 1
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
await expect(driver.waitDownload(betaTarget, betaProfile)).resolves.toEqual({
|
|
560
|
+
path: "remote-cdp-profile%3Abeta-target%3Aremote-cdp%3Aprofile%3Abeta%3A1-1.bin",
|
|
561
|
+
profile: betaProfile,
|
|
562
|
+
targetId: betaTarget,
|
|
563
|
+
endpoint: {
|
|
564
|
+
url: "ws://localhost:9222/devtools/browser/example",
|
|
565
|
+
protocol: "ws:",
|
|
566
|
+
host: "localhost:9222"
|
|
567
|
+
},
|
|
568
|
+
uploadFiles: ["beta.txt"],
|
|
569
|
+
dialogArmedCount: 1,
|
|
570
|
+
triggerCount: 1
|
|
571
|
+
});
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it("supports real upload/dialog/download hooks when runtime exposes events", async () => {
|
|
575
|
+
const harness = createRuntimeHarness({
|
|
576
|
+
supportsDownloadEvents: true
|
|
577
|
+
});
|
|
578
|
+
const driver = createRemoteCdpDriver({
|
|
579
|
+
cdpUrl: "ws://localhost:9222/devtools/browser/example",
|
|
580
|
+
runtime: harness.runtime
|
|
581
|
+
});
|
|
582
|
+
const profile = "profile:alpha";
|
|
583
|
+
const targetId = await driver.openTab("https://example.com/upload", profile);
|
|
584
|
+
const pageRecord = harness.pages[0];
|
|
585
|
+
if (pageRecord === undefined) {
|
|
586
|
+
throw new Error("Expected page record.");
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
await driver.armUpload(targetId, ["C:\\upload\\alpha.txt"], profile);
|
|
590
|
+
await driver.act({ type: "click", payload: { selector: "#file" } }, targetId, profile);
|
|
591
|
+
expect(pageRecord.locatorSetInputFiles).toHaveBeenCalledWith(["C:\\upload\\alpha.txt"]);
|
|
592
|
+
|
|
593
|
+
const dialogAccept = vi.fn(async () => {});
|
|
594
|
+
await driver.armDialog(targetId, profile);
|
|
595
|
+
await pageRecord.emitDialog({
|
|
596
|
+
accept: dialogAccept
|
|
597
|
+
});
|
|
598
|
+
expect(dialogAccept).toHaveBeenCalledTimes(1);
|
|
599
|
+
|
|
600
|
+
await driver.triggerDownload(targetId, profile);
|
|
601
|
+
await pageRecord.emitDownload({
|
|
602
|
+
path: async () => "C:\\downloads\\alpha.bin",
|
|
603
|
+
suggestedFilename: () => "alpha.bin",
|
|
604
|
+
url: () => "https://example.com/alpha.bin",
|
|
605
|
+
mimeType: () => "application/octet-stream"
|
|
606
|
+
});
|
|
607
|
+
await expect(driver.waitDownload(targetId, profile)).resolves.toMatchObject({
|
|
608
|
+
path: "C:\\downloads\\alpha.bin",
|
|
609
|
+
profile,
|
|
610
|
+
targetId,
|
|
611
|
+
suggestedFilename: "alpha.bin",
|
|
612
|
+
url: "https://example.com/alpha.bin",
|
|
613
|
+
mimeType: "application/octet-stream",
|
|
614
|
+
triggerCount: 1
|
|
615
|
+
});
|
|
616
|
+
});
|
|
617
|
+
});
|