@flrande/browserctl 0.1.0-dev.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README-CN.md +66 -0
- package/README.md +66 -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-CN.md +38 -0
- package/extensions/chrome-relay/README.md +38 -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,125 @@
|
|
|
1
|
+
import type { BrowserDriver } from "../../core/src/driver";
|
|
2
|
+
import type { ProfileId, TargetId } from "../../core/src/types";
|
|
3
|
+
|
|
4
|
+
export type ManagedDriverStatus = {
|
|
5
|
+
kind: "managed";
|
|
6
|
+
connected: true;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const DEFAULT_PROFILE_ID: ProfileId = "profile:managed:default";
|
|
10
|
+
|
|
11
|
+
type ManagedTab = {
|
|
12
|
+
url: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type ManagedProfileState = {
|
|
16
|
+
nextTargetNumber: number;
|
|
17
|
+
tabs: Map<TargetId, ManagedTab>;
|
|
18
|
+
tabOrder: TargetId[];
|
|
19
|
+
focusedTargetId?: TargetId;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function resolveProfileId(profile?: ProfileId): ProfileId {
|
|
23
|
+
return profile ?? DEFAULT_PROFILE_ID;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function createTargetId(profileId: ProfileId, targetNumber: number): TargetId {
|
|
27
|
+
return `target:managed:${profileId}:${targetNumber}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function createProfileState(): ManagedProfileState {
|
|
31
|
+
return {
|
|
32
|
+
nextTargetNumber: 1,
|
|
33
|
+
tabs: new Map<TargetId, ManagedTab>(),
|
|
34
|
+
tabOrder: []
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function createManagedDriver(): BrowserDriver<ManagedDriverStatus> {
|
|
39
|
+
const profileStates = new Map<ProfileId, ManagedProfileState>();
|
|
40
|
+
|
|
41
|
+
function getOrCreateProfileState(profileId: ProfileId): ManagedProfileState {
|
|
42
|
+
const existingState = profileStates.get(profileId);
|
|
43
|
+
if (existingState !== undefined) {
|
|
44
|
+
return existingState;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const createdState = createProfileState();
|
|
48
|
+
profileStates.set(profileId, createdState);
|
|
49
|
+
return createdState;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function requireTargetInProfile(profileId: ProfileId, targetId: TargetId): ManagedProfileState {
|
|
53
|
+
const profileState = profileStates.get(profileId);
|
|
54
|
+
if (profileState === undefined || !profileState.tabs.has(targetId)) {
|
|
55
|
+
throw new Error(`Unknown targetId: ${targetId} (profile: ${profileId})`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return profileState;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
status: async () => ({ kind: "managed", connected: true }),
|
|
63
|
+
listProfiles: async () => {
|
|
64
|
+
const knownProfiles = new Set<ProfileId>([DEFAULT_PROFILE_ID]);
|
|
65
|
+
for (const profileId of profileStates.keys()) {
|
|
66
|
+
knownProfiles.add(profileId);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return Array.from(knownProfiles).sort();
|
|
70
|
+
},
|
|
71
|
+
listTabs: async (profile) => {
|
|
72
|
+
const profileId = resolveProfileId(profile);
|
|
73
|
+
const profileState = profileStates.get(profileId);
|
|
74
|
+
return profileState === undefined ? [] : [...profileState.tabOrder];
|
|
75
|
+
},
|
|
76
|
+
openTab: async (url, profile) => {
|
|
77
|
+
const profileId = resolveProfileId(profile);
|
|
78
|
+
const profileState = getOrCreateProfileState(profileId);
|
|
79
|
+
const targetId = createTargetId(profileId, profileState.nextTargetNumber);
|
|
80
|
+
|
|
81
|
+
profileState.nextTargetNumber += 1;
|
|
82
|
+
profileState.tabs.set(targetId, { url });
|
|
83
|
+
profileState.tabOrder.push(targetId);
|
|
84
|
+
if (profileState.focusedTargetId === undefined) {
|
|
85
|
+
profileState.focusedTargetId = targetId;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return targetId;
|
|
89
|
+
},
|
|
90
|
+
focusTab: async (targetId, profile) => {
|
|
91
|
+
const profileId = resolveProfileId(profile);
|
|
92
|
+
const profileState = requireTargetInProfile(profileId, targetId);
|
|
93
|
+
profileState.focusedTargetId = targetId;
|
|
94
|
+
},
|
|
95
|
+
closeTab: async (targetId, profile) => {
|
|
96
|
+
const profileId = resolveProfileId(profile);
|
|
97
|
+
const profileState = requireTargetInProfile(profileId, targetId);
|
|
98
|
+
|
|
99
|
+
profileState.tabs.delete(targetId);
|
|
100
|
+
profileState.tabOrder = profileState.tabOrder.filter((existingTargetId) => existingTargetId !== targetId);
|
|
101
|
+
if (profileState.focusedTargetId === targetId) {
|
|
102
|
+
profileState.focusedTargetId = profileState.tabOrder[0];
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
snapshot: async (targetId, profile) => ({
|
|
106
|
+
kind: "managed",
|
|
107
|
+
profile: resolveProfileId(profile),
|
|
108
|
+
targetId,
|
|
109
|
+
hasTarget:
|
|
110
|
+
profileStates.get(resolveProfileId(profile))?.tabs.has(targetId) ?? false
|
|
111
|
+
}),
|
|
112
|
+
act: async (action, targetId, profile) => ({
|
|
113
|
+
actionType: action.type,
|
|
114
|
+
profile: resolveProfileId(profile),
|
|
115
|
+
targetId,
|
|
116
|
+
targetKnown:
|
|
117
|
+
profileStates.get(resolveProfileId(profile))?.tabs.has(targetId) ?? false,
|
|
118
|
+
ok: true
|
|
119
|
+
}),
|
|
120
|
+
armUpload: async () => {},
|
|
121
|
+
armDialog: async () => {},
|
|
122
|
+
waitDownload: async () => ({ path: "managed-download.bin" }),
|
|
123
|
+
triggerDownload: async () => {}
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createManagedLocalDriver,
|
|
5
|
+
type ManagedLocalBrowser,
|
|
6
|
+
type ManagedLocalBrowserContext,
|
|
7
|
+
type ManagedLocalDriverRuntime,
|
|
8
|
+
type ManagedLocalPage
|
|
9
|
+
} from "./index";
|
|
10
|
+
|
|
11
|
+
type MockPageRecord = {
|
|
12
|
+
page: ManagedLocalPage;
|
|
13
|
+
goto: ReturnType<typeof vi.fn>;
|
|
14
|
+
bringToFront: ReturnType<typeof vi.fn>;
|
|
15
|
+
close: ReturnType<typeof vi.fn>;
|
|
16
|
+
title: ReturnType<typeof vi.fn>;
|
|
17
|
+
content: ReturnType<typeof vi.fn>;
|
|
18
|
+
screenshot: ReturnType<typeof vi.fn>;
|
|
19
|
+
locator: ReturnType<typeof vi.fn>;
|
|
20
|
+
locatorClick: ReturnType<typeof vi.fn>;
|
|
21
|
+
locatorFill: ReturnType<typeof vi.fn>;
|
|
22
|
+
locatorType: ReturnType<typeof vi.fn>;
|
|
23
|
+
locatorSetInputFiles: ReturnType<typeof vi.fn>;
|
|
24
|
+
keyboardPress: ReturnType<typeof vi.fn>;
|
|
25
|
+
waitForEvent: ReturnType<typeof vi.fn>;
|
|
26
|
+
on: ReturnType<typeof vi.fn>;
|
|
27
|
+
emitConsole(entry: MockConsoleMessage): Promise<void>;
|
|
28
|
+
emitResponse(response: MockNetworkResponse): Promise<void>;
|
|
29
|
+
emitDialog(dialog: MockDialog): Promise<void>;
|
|
30
|
+
emitDownload(download: MockDownload): Promise<void>;
|
|
31
|
+
getUrl(): string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
type RuntimeHarness = {
|
|
35
|
+
runtime: ManagedLocalDriverRuntime;
|
|
36
|
+
launch: ReturnType<typeof vi.fn>;
|
|
37
|
+
newContext: ReturnType<typeof vi.fn>;
|
|
38
|
+
browserClose: ReturnType<typeof vi.fn>;
|
|
39
|
+
contextClose: ReturnType<typeof vi.fn>;
|
|
40
|
+
pages: MockPageRecord[];
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
type RuntimeHarnessOptions = {
|
|
44
|
+
newContextDelayMs?: number;
|
|
45
|
+
supportsDownloadEvents?: boolean;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
type MockConsoleMessage = {
|
|
49
|
+
type(): string;
|
|
50
|
+
text(): string;
|
|
51
|
+
location?(): {
|
|
52
|
+
url?: string;
|
|
53
|
+
lineNumber?: number;
|
|
54
|
+
columnNumber?: number;
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
type MockNetworkResponse = {
|
|
59
|
+
url(): string;
|
|
60
|
+
status(): number;
|
|
61
|
+
request(): {
|
|
62
|
+
method(): string;
|
|
63
|
+
resourceType?(): string;
|
|
64
|
+
};
|
|
65
|
+
text?(): Promise<string>;
|
|
66
|
+
body?(): Promise<Uint8Array | string>;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
type MockDialog = {
|
|
70
|
+
accept?(): Promise<void>;
|
|
71
|
+
dismiss?(): Promise<void>;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
type MockDownload = {
|
|
75
|
+
path?(): Promise<string>;
|
|
76
|
+
saveAs?(path: string): Promise<void>;
|
|
77
|
+
suggestedFilename?(): string;
|
|
78
|
+
url?(): string;
|
|
79
|
+
mimeType?(): string;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
function createMockPageRecord(pageNumber: number): MockPageRecord {
|
|
83
|
+
let currentUrl = "about:blank";
|
|
84
|
+
const listeners = new Map<string, Array<(payload: unknown) => unknown>>();
|
|
85
|
+
const waiters = new Map<
|
|
86
|
+
string,
|
|
87
|
+
Array<{ resolve: (value: unknown) => void; reject: (error: unknown) => void }>
|
|
88
|
+
>();
|
|
89
|
+
|
|
90
|
+
const goto = vi.fn(async (url: string) => {
|
|
91
|
+
currentUrl = url;
|
|
92
|
+
});
|
|
93
|
+
const bringToFront = vi.fn(async () => {});
|
|
94
|
+
const close = vi.fn(async () => {});
|
|
95
|
+
const title = vi.fn(async () => `mock-title-${pageNumber}`);
|
|
96
|
+
const content = vi.fn(async () => `<html data-page="${pageNumber}" />`);
|
|
97
|
+
const screenshot = vi.fn(async () => new Uint8Array([0, 1, 2, pageNumber]));
|
|
98
|
+
const locatorClick = vi.fn(async () => {});
|
|
99
|
+
const locatorFill = vi.fn(async (_value: string) => {});
|
|
100
|
+
const locatorType = vi.fn(async (_value: string) => {});
|
|
101
|
+
const locatorSetInputFiles = vi.fn(async (_files: string[]) => {});
|
|
102
|
+
const locator = vi.fn((_selector: string) => ({
|
|
103
|
+
click: locatorClick,
|
|
104
|
+
fill: locatorFill,
|
|
105
|
+
type: locatorType,
|
|
106
|
+
setInputFiles: locatorSetInputFiles
|
|
107
|
+
}));
|
|
108
|
+
const keyboardPress = vi.fn(async (_key: string) => {});
|
|
109
|
+
const waitForEvent = vi.fn(async (eventName: string) => {
|
|
110
|
+
return await new Promise<unknown>((resolve, reject) => {
|
|
111
|
+
const current = waiters.get(eventName);
|
|
112
|
+
if (current !== undefined) {
|
|
113
|
+
current.push({ resolve, reject });
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
waiters.set(eventName, [{ resolve, reject }]);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
const on = vi.fn((eventName: string, listener: (payload: unknown) => unknown) => {
|
|
121
|
+
const eventListeners = listeners.get(eventName);
|
|
122
|
+
if (eventListeners !== undefined) {
|
|
123
|
+
eventListeners.push(listener);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
listeners.set(eventName, [listener]);
|
|
128
|
+
});
|
|
129
|
+
const emit = async (eventName: string, payload: unknown): Promise<void> => {
|
|
130
|
+
const eventListeners = listeners.get(eventName);
|
|
131
|
+
if (eventListeners !== undefined) {
|
|
132
|
+
for (const listener of eventListeners) {
|
|
133
|
+
await listener(payload);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const eventWaiters = waiters.get(eventName);
|
|
138
|
+
if (eventWaiters === undefined || eventWaiters.length === 0) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const nextWaiter = eventWaiters.shift();
|
|
143
|
+
nextWaiter?.resolve(payload);
|
|
144
|
+
};
|
|
145
|
+
const page: ManagedLocalPage & { on: typeof on; waitForEvent: typeof waitForEvent } = {
|
|
146
|
+
goto,
|
|
147
|
+
bringToFront,
|
|
148
|
+
close,
|
|
149
|
+
url: () => currentUrl,
|
|
150
|
+
title,
|
|
151
|
+
content,
|
|
152
|
+
screenshot,
|
|
153
|
+
locator,
|
|
154
|
+
on,
|
|
155
|
+
waitForEvent,
|
|
156
|
+
keyboard: {
|
|
157
|
+
press: keyboardPress
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
page,
|
|
163
|
+
goto,
|
|
164
|
+
bringToFront,
|
|
165
|
+
close,
|
|
166
|
+
title,
|
|
167
|
+
content,
|
|
168
|
+
screenshot,
|
|
169
|
+
locator,
|
|
170
|
+
locatorClick,
|
|
171
|
+
locatorFill,
|
|
172
|
+
locatorType,
|
|
173
|
+
locatorSetInputFiles,
|
|
174
|
+
keyboardPress,
|
|
175
|
+
waitForEvent,
|
|
176
|
+
on,
|
|
177
|
+
emitConsole: async (entry) => {
|
|
178
|
+
await emit("console", entry);
|
|
179
|
+
},
|
|
180
|
+
emitResponse: async (response) => {
|
|
181
|
+
await emit("response", response);
|
|
182
|
+
},
|
|
183
|
+
emitDialog: async (dialog) => {
|
|
184
|
+
await emit("dialog", dialog);
|
|
185
|
+
},
|
|
186
|
+
emitDownload: async (download) => {
|
|
187
|
+
await emit("download", download);
|
|
188
|
+
},
|
|
189
|
+
getUrl: () => currentUrl
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function createRuntimeHarness(options: RuntimeHarnessOptions = {}): RuntimeHarness {
|
|
194
|
+
const newContextDelayMs = options.newContextDelayMs ?? 0;
|
|
195
|
+
const supportsDownloadEvents = options.supportsDownloadEvents ?? false;
|
|
196
|
+
const pages: MockPageRecord[] = [];
|
|
197
|
+
const contextClose = vi.fn(async () => {});
|
|
198
|
+
const newPage = vi.fn(async () => {
|
|
199
|
+
const pageRecord = createMockPageRecord(pages.length + 1);
|
|
200
|
+
if (!supportsDownloadEvents) {
|
|
201
|
+
delete (pageRecord.page as Record<string, unknown>).waitForEvent;
|
|
202
|
+
}
|
|
203
|
+
pages.push(pageRecord);
|
|
204
|
+
return pageRecord.page;
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const newContext = vi.fn(async (): Promise<ManagedLocalBrowserContext> => {
|
|
208
|
+
if (newContextDelayMs > 0) {
|
|
209
|
+
await new Promise<void>((resolve) => {
|
|
210
|
+
setTimeout(resolve, newContextDelayMs);
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
newPage,
|
|
216
|
+
close: contextClose
|
|
217
|
+
};
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
const browserClose = vi.fn(async () => {});
|
|
221
|
+
const launch = vi.fn(async (): Promise<ManagedLocalBrowser> => ({
|
|
222
|
+
newContext,
|
|
223
|
+
close: browserClose
|
|
224
|
+
}));
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
runtime: { launch },
|
|
228
|
+
launch,
|
|
229
|
+
newContext,
|
|
230
|
+
browserClose,
|
|
231
|
+
contextClose,
|
|
232
|
+
pages
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
describe("createManagedLocalDriver", () => {
|
|
237
|
+
it("does not launch a browser during status/list-only calls", async () => {
|
|
238
|
+
const harness = createRuntimeHarness();
|
|
239
|
+
const driver = createManagedLocalDriver({
|
|
240
|
+
runtime: harness.runtime,
|
|
241
|
+
browserName: "firefox",
|
|
242
|
+
headless: false,
|
|
243
|
+
channel: "firefox",
|
|
244
|
+
launchTimeoutMs: 4_000,
|
|
245
|
+
args: ["--private"]
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
expect(await driver.status()).toMatchObject({
|
|
249
|
+
kind: "managed-local",
|
|
250
|
+
launched: false,
|
|
251
|
+
browserName: "firefox",
|
|
252
|
+
headless: false
|
|
253
|
+
});
|
|
254
|
+
expect(await driver.listTabs()).toEqual([]);
|
|
255
|
+
expect(harness.launch).toHaveBeenCalledTimes(0);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("lazily launches and supports tab lifecycle, snapshot, and actions", async () => {
|
|
259
|
+
const harness = createRuntimeHarness();
|
|
260
|
+
const driver = createManagedLocalDriver({
|
|
261
|
+
runtime: harness.runtime,
|
|
262
|
+
browserName: "webkit",
|
|
263
|
+
headless: true
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
const targetId = await driver.openTab("https://example.com/a");
|
|
267
|
+
|
|
268
|
+
expect(harness.launch).toHaveBeenCalledTimes(1);
|
|
269
|
+
expect(harness.launch).toHaveBeenCalledWith({
|
|
270
|
+
browserName: "webkit",
|
|
271
|
+
headless: true,
|
|
272
|
+
channel: undefined,
|
|
273
|
+
executablePath: undefined,
|
|
274
|
+
launchTimeoutMs: undefined,
|
|
275
|
+
args: []
|
|
276
|
+
});
|
|
277
|
+
expect(await driver.listTabs()).toEqual([targetId]);
|
|
278
|
+
|
|
279
|
+
await driver.focusTab(targetId);
|
|
280
|
+
expect(harness.pages[0]?.bringToFront).toHaveBeenCalledTimes(1);
|
|
281
|
+
|
|
282
|
+
const clickResult = await driver.act(
|
|
283
|
+
{ type: "click", payload: { selector: "#go" } },
|
|
284
|
+
targetId
|
|
285
|
+
);
|
|
286
|
+
expect(clickResult).toMatchObject({
|
|
287
|
+
actionType: "click",
|
|
288
|
+
targetId,
|
|
289
|
+
targetKnown: true,
|
|
290
|
+
ok: true
|
|
291
|
+
});
|
|
292
|
+
expect(harness.pages[0]?.locator).toHaveBeenCalledWith("#go");
|
|
293
|
+
expect(harness.pages[0]?.locatorClick).toHaveBeenCalledTimes(1);
|
|
294
|
+
|
|
295
|
+
const snapshot = await driver.snapshot(targetId);
|
|
296
|
+
expect(snapshot).toEqual({
|
|
297
|
+
kind: "managed-local",
|
|
298
|
+
profile: "profile:managed-local:default",
|
|
299
|
+
targetId,
|
|
300
|
+
hasTarget: true,
|
|
301
|
+
url: "https://example.com/a",
|
|
302
|
+
title: "mock-title-1",
|
|
303
|
+
html: '<html data-page="1" />',
|
|
304
|
+
requestSummaries: []
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
await expect(driver.screenshot(targetId)).resolves.toEqual({
|
|
308
|
+
kind: "managed-local",
|
|
309
|
+
profile: "profile:managed-local:default",
|
|
310
|
+
targetId,
|
|
311
|
+
hasTarget: true,
|
|
312
|
+
mimeType: "image/png",
|
|
313
|
+
encoding: "base64",
|
|
314
|
+
imageBase64: "AAECAQ=="
|
|
315
|
+
});
|
|
316
|
+
expect(harness.pages[0]?.screenshot).toHaveBeenCalledTimes(1);
|
|
317
|
+
|
|
318
|
+
await driver.closeTab(targetId);
|
|
319
|
+
expect(harness.pages[0]?.close).toHaveBeenCalledTimes(1);
|
|
320
|
+
expect(await driver.listTabs()).toEqual([]);
|
|
321
|
+
await expect(driver.snapshot(targetId)).resolves.toMatchObject({
|
|
322
|
+
hasTarget: false
|
|
323
|
+
});
|
|
324
|
+
await expect(driver.screenshot(targetId)).resolves.toMatchObject({
|
|
325
|
+
hasTarget: false
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("collects console and network events and exposes response-body lookup", async () => {
|
|
330
|
+
const harness = createRuntimeHarness();
|
|
331
|
+
const driver = createManagedLocalDriver({ runtime: harness.runtime });
|
|
332
|
+
const profile = "profile:alpha";
|
|
333
|
+
const targetId = await driver.openTab("https://example.com/events", profile);
|
|
334
|
+
const pageRecord = harness.pages[0];
|
|
335
|
+
if (pageRecord === undefined) {
|
|
336
|
+
throw new Error("Expected a mock page to be created.");
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
await pageRecord.emitConsole({
|
|
340
|
+
type: () => "warning",
|
|
341
|
+
text: () => "managed-local warning",
|
|
342
|
+
location: () => ({
|
|
343
|
+
url: "https://example.com/app.js",
|
|
344
|
+
lineNumber: 7,
|
|
345
|
+
columnNumber: 2
|
|
346
|
+
})
|
|
347
|
+
});
|
|
348
|
+
await pageRecord.emitResponse({
|
|
349
|
+
url: () => "https://example.com/api/items",
|
|
350
|
+
status: () => 200,
|
|
351
|
+
request: () => ({
|
|
352
|
+
method: () => "GET",
|
|
353
|
+
resourceType: () => "xhr"
|
|
354
|
+
}),
|
|
355
|
+
text: async () => '{"ok":true}'
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
expect(pageRecord.on).toHaveBeenCalledWith("console", expect.any(Function));
|
|
359
|
+
expect(pageRecord.on).toHaveBeenCalledWith("response", expect.any(Function));
|
|
360
|
+
|
|
361
|
+
const telemetryDriver = driver as typeof driver & {
|
|
362
|
+
getConsoleEntries?: (targetId: string, profile?: string) => unknown[];
|
|
363
|
+
getNetworkResponseBody?: (
|
|
364
|
+
requestId: string,
|
|
365
|
+
targetId: string,
|
|
366
|
+
profile?: string
|
|
367
|
+
) => { body: string; encoding: string } | undefined;
|
|
368
|
+
};
|
|
369
|
+
expect(telemetryDriver.getConsoleEntries).toBeTypeOf("function");
|
|
370
|
+
expect(telemetryDriver.getNetworkResponseBody).toBeTypeOf("function");
|
|
371
|
+
|
|
372
|
+
const consoleEntries = telemetryDriver.getConsoleEntries?.(targetId) ?? [];
|
|
373
|
+
expect(consoleEntries).toEqual([
|
|
374
|
+
expect.objectContaining({
|
|
375
|
+
type: "warning",
|
|
376
|
+
text: "managed-local warning",
|
|
377
|
+
location: {
|
|
378
|
+
url: "https://example.com/app.js",
|
|
379
|
+
lineNumber: 7,
|
|
380
|
+
columnNumber: 2
|
|
381
|
+
}
|
|
382
|
+
})
|
|
383
|
+
]);
|
|
384
|
+
|
|
385
|
+
const snapshot = await driver.snapshot(targetId, profile);
|
|
386
|
+
expect(snapshot).toMatchObject({
|
|
387
|
+
hasTarget: true,
|
|
388
|
+
requestSummaries: [
|
|
389
|
+
expect.objectContaining({
|
|
390
|
+
requestId: expect.any(String),
|
|
391
|
+
url: "https://example.com/api/items",
|
|
392
|
+
method: "GET",
|
|
393
|
+
resourceType: "xhr",
|
|
394
|
+
status: 200
|
|
395
|
+
})
|
|
396
|
+
]
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
const requestId = (
|
|
400
|
+
snapshot as {
|
|
401
|
+
requestSummaries?: Array<{ requestId?: string }>;
|
|
402
|
+
}
|
|
403
|
+
).requestSummaries?.[0]?.requestId;
|
|
404
|
+
if (typeof requestId !== "string") {
|
|
405
|
+
throw new Error("Expected requestId in snapshot metadata.");
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
expect(telemetryDriver.getNetworkResponseBody?.(requestId, targetId)).toEqual({
|
|
409
|
+
body: '{"ok":true}',
|
|
410
|
+
encoding: "utf8"
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it("keeps upload/dialog/download state scoped to profile + target", async () => {
|
|
415
|
+
const harness = createRuntimeHarness();
|
|
416
|
+
const driver = createManagedLocalDriver({ runtime: harness.runtime });
|
|
417
|
+
const profile = "profile:alpha";
|
|
418
|
+
const targetId = await driver.openTab("https://example.com/download", profile);
|
|
419
|
+
|
|
420
|
+
await expect(driver.armUpload(targetId, ["alpha.txt"], "profile:beta")).rejects.toThrowError(
|
|
421
|
+
`Unknown targetId: ${targetId} (profile: profile:beta)`
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
await driver.armUpload(targetId, ["alpha.txt"], profile);
|
|
425
|
+
await driver.armDialog(targetId, profile);
|
|
426
|
+
await driver.triggerDownload(targetId, profile);
|
|
427
|
+
|
|
428
|
+
await expect(driver.waitDownload(targetId, profile)).resolves.toMatchObject({
|
|
429
|
+
profile,
|
|
430
|
+
targetId,
|
|
431
|
+
uploadFiles: ["alpha.txt"],
|
|
432
|
+
dialogArmedCount: 1,
|
|
433
|
+
triggerCount: 1
|
|
434
|
+
});
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it("supports real upload/dialog/download hooks when runtime exposes events", async () => {
|
|
438
|
+
const harness = createRuntimeHarness({
|
|
439
|
+
supportsDownloadEvents: true
|
|
440
|
+
});
|
|
441
|
+
const driver = createManagedLocalDriver({
|
|
442
|
+
runtime: harness.runtime
|
|
443
|
+
});
|
|
444
|
+
const profile = "profile:alpha";
|
|
445
|
+
const targetId = await driver.openTab("https://example.com/upload", profile);
|
|
446
|
+
const pageRecord = harness.pages[0];
|
|
447
|
+
if (pageRecord === undefined) {
|
|
448
|
+
throw new Error("Expected page record.");
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
await driver.armUpload(targetId, ["C:\\upload\\alpha.txt"], profile);
|
|
452
|
+
await driver.act({ type: "click", payload: { selector: "#file" } }, targetId, profile);
|
|
453
|
+
expect(pageRecord.locatorSetInputFiles).toHaveBeenCalledWith(["C:\\upload\\alpha.txt"]);
|
|
454
|
+
|
|
455
|
+
const dialogAccept = vi.fn(async () => {});
|
|
456
|
+
await driver.armDialog(targetId, profile);
|
|
457
|
+
await pageRecord.emitDialog({
|
|
458
|
+
accept: dialogAccept
|
|
459
|
+
});
|
|
460
|
+
expect(dialogAccept).toHaveBeenCalledTimes(1);
|
|
461
|
+
|
|
462
|
+
await driver.triggerDownload(targetId, profile);
|
|
463
|
+
await pageRecord.emitDownload({
|
|
464
|
+
path: async () => "C:\\downloads\\alpha.bin",
|
|
465
|
+
suggestedFilename: () => "alpha.bin",
|
|
466
|
+
url: () => "https://example.com/alpha.bin",
|
|
467
|
+
mimeType: () => "application/octet-stream"
|
|
468
|
+
});
|
|
469
|
+
await expect(driver.waitDownload(targetId, profile)).resolves.toMatchObject({
|
|
470
|
+
path: "C:\\downloads\\alpha.bin",
|
|
471
|
+
profile,
|
|
472
|
+
targetId,
|
|
473
|
+
suggestedFilename: "alpha.bin",
|
|
474
|
+
url: "https://example.com/alpha.bin",
|
|
475
|
+
mimeType: "application/octet-stream",
|
|
476
|
+
triggerCount: 1
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it("reuses one browser launch while creating separate profile contexts", async () => {
|
|
481
|
+
const harness = createRuntimeHarness();
|
|
482
|
+
const driver = createManagedLocalDriver({ runtime: harness.runtime });
|
|
483
|
+
|
|
484
|
+
await driver.openTab("https://example.com/alpha", "profile:alpha");
|
|
485
|
+
await driver.openTab("https://example.com/beta", "profile:beta");
|
|
486
|
+
|
|
487
|
+
expect(harness.launch).toHaveBeenCalledTimes(1);
|
|
488
|
+
expect(harness.newContext).toHaveBeenCalledTimes(2);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it("serializes same-profile context init during concurrent openTab calls", async () => {
|
|
492
|
+
const harness = createRuntimeHarness({ newContextDelayMs: 15 });
|
|
493
|
+
const driver = createManagedLocalDriver({ runtime: harness.runtime });
|
|
494
|
+
|
|
495
|
+
const [firstTarget, secondTarget] = await Promise.all([
|
|
496
|
+
driver.openTab("https://example.com/concurrent/a", "profile:alpha"),
|
|
497
|
+
driver.openTab("https://example.com/concurrent/b", "profile:alpha")
|
|
498
|
+
]);
|
|
499
|
+
|
|
500
|
+
const tabs = await driver.listTabs("profile:alpha");
|
|
501
|
+
expect(tabs).toHaveLength(2);
|
|
502
|
+
expect(new Set(tabs)).toEqual(new Set([firstTarget, secondTarget]));
|
|
503
|
+
expect(harness.launch).toHaveBeenCalledTimes(1);
|
|
504
|
+
expect(harness.newContext).toHaveBeenCalledTimes(1);
|
|
505
|
+
});
|
|
506
|
+
});
|