@flrande/browserctl 0.4.0 → 0.5.0-dev.21.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/apps/browserctl/src/commands/act.test.ts +71 -0
- package/apps/browserctl/src/commands/act.ts +45 -1
- package/apps/browserctl/src/commands/command-wrappers.test.ts +302 -0
- package/apps/browserctl/src/commands/console-list.test.ts +102 -0
- package/apps/browserctl/src/commands/console-list.ts +89 -1
- package/apps/browserctl/src/commands/har-export.test.ts +112 -0
- package/apps/browserctl/src/commands/har-export.ts +120 -0
- package/apps/browserctl/src/commands/memory-delete.ts +20 -0
- package/apps/browserctl/src/commands/memory-inspect.ts +20 -0
- package/apps/browserctl/src/commands/memory-list.ts +90 -0
- package/apps/browserctl/src/commands/memory-mode-set.ts +29 -0
- package/apps/browserctl/src/commands/memory-purge.ts +16 -0
- package/apps/browserctl/src/commands/memory-resolve.ts +56 -0
- package/apps/browserctl/src/commands/memory-status.ts +16 -0
- package/apps/browserctl/src/commands/memory-ttl-set.ts +28 -0
- package/apps/browserctl/src/commands/memory-upsert.ts +142 -0
- package/apps/browserctl/src/commands/network-list.test.ts +110 -0
- package/apps/browserctl/src/commands/network-list.ts +112 -0
- package/apps/browserctl/src/commands/session-drop.test.ts +36 -0
- package/apps/browserctl/src/commands/session-drop.ts +16 -0
- package/apps/browserctl/src/commands/session-list.test.ts +81 -0
- package/apps/browserctl/src/commands/session-list.ts +70 -0
- package/apps/browserctl/src/commands/trace-get.test.ts +61 -0
- package/apps/browserctl/src/commands/trace-get.ts +62 -0
- package/apps/browserctl/src/commands/wait-element.test.ts +80 -0
- package/apps/browserctl/src/commands/wait-element.ts +76 -0
- package/apps/browserctl/src/commands/wait-text.test.ts +110 -0
- package/apps/browserctl/src/commands/wait-text.ts +93 -0
- package/apps/browserctl/src/commands/wait-url.test.ts +80 -0
- package/apps/browserctl/src/commands/wait-url.ts +76 -0
- package/apps/browserctl/src/main.dispatch.test.ts +206 -1
- package/apps/browserctl/src/main.test.ts +30 -0
- package/apps/browserctl/src/main.ts +246 -4
- package/apps/browserd/src/container.ts +1603 -48
- package/apps/browserd/src/main.test.ts +538 -1
- package/apps/browserd/src/tool-matrix.test.ts +492 -3
- package/package.json +5 -1
- package/packages/core/src/driver.ts +1 -1
- package/packages/core/src/index.ts +1 -0
- package/packages/core/src/navigation-memory.test.ts +259 -0
- package/packages/core/src/navigation-memory.ts +360 -0
- package/packages/core/src/session-store.test.ts +33 -0
- package/packages/core/src/session-store.ts +111 -6
- package/packages/driver-chrome-relay/src/chrome-relay-driver.test.ts +112 -2
- package/packages/driver-chrome-relay/src/chrome-relay-driver.ts +233 -10
- package/packages/driver-managed/src/managed-driver.test.ts +124 -0
- package/packages/driver-managed/src/managed-driver.ts +233 -17
- package/packages/driver-managed/src/managed-local-driver.test.ts +104 -2
- package/packages/driver-managed/src/managed-local-driver.ts +232 -10
- package/packages/driver-remote-cdp/src/remote-cdp-driver.test.ts +112 -2
- package/packages/driver-remote-cdp/src/remote-cdp-driver.ts +232 -10
- package/packages/transport-mcp-stdio/src/tool-map.ts +18 -1
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { once } from "node:events";
|
|
2
|
+
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
|
3
|
+
import type { AddressInfo } from "node:net";
|
|
4
|
+
|
|
1
5
|
import { describe, expect, it } from "vitest";
|
|
2
6
|
|
|
3
7
|
import { createManagedDriver } from "./index";
|
|
@@ -56,4 +60,124 @@ describe("createManagedDriver", () => {
|
|
|
56
60
|
`Unknown targetId: ${target} (profile: profile:beta)`
|
|
57
61
|
);
|
|
58
62
|
});
|
|
63
|
+
|
|
64
|
+
it("tracks local URL/html state for navigate/login/download flows", async () => {
|
|
65
|
+
function sendHtml(response: ServerResponse, route: string): void {
|
|
66
|
+
response.statusCode = 200;
|
|
67
|
+
response.setHeader("content-type", "text/html; charset=utf-8");
|
|
68
|
+
response.end(`<!doctype html><h1>Mock</h1><p>Route: ${route}</p>`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const server = createServer((request: IncomingMessage, response: ServerResponse) => {
|
|
72
|
+
if (request.url === "/app" && request.method === "GET") {
|
|
73
|
+
sendHtml(response, "/app");
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (request.url === "/app/cart" && request.method === "GET") {
|
|
78
|
+
sendHtml(response, "/app/cart");
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (request.url === "/api/login" && request.method === "POST") {
|
|
83
|
+
response.statusCode = 200;
|
|
84
|
+
response.setHeader("set-cookie", "mock_session=test; Path=/; HttpOnly");
|
|
85
|
+
response.setHeader("content-type", "application/json; charset=utf-8");
|
|
86
|
+
response.end(JSON.stringify({ ok: true }));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (request.url === "/download/orders.csv" && request.method === "GET") {
|
|
91
|
+
response.statusCode = 200;
|
|
92
|
+
response.setHeader("content-type", "text/csv; charset=utf-8");
|
|
93
|
+
response.end("order_id,total\n1,42\n");
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
response.statusCode = 404;
|
|
98
|
+
response.end("not-found");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
server.listen(0, "127.0.0.1");
|
|
102
|
+
await once(server, "listening");
|
|
103
|
+
|
|
104
|
+
const address = server.address();
|
|
105
|
+
if (address === null || typeof address === "string") {
|
|
106
|
+
throw new Error("Expected server address info to be available.");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const baseUrl = `http://127.0.0.1:${(address as AddressInfo).port}`;
|
|
110
|
+
const driver = createManagedDriver();
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const targetId = await driver.openTab(`${baseUrl}/app`);
|
|
114
|
+
const initialSnapshot = await driver.snapshot(targetId);
|
|
115
|
+
expect(initialSnapshot).toMatchObject({
|
|
116
|
+
hasTarget: true,
|
|
117
|
+
url: `${baseUrl}/app`
|
|
118
|
+
});
|
|
119
|
+
expect(initialSnapshot).toMatchObject({
|
|
120
|
+
html: expect.stringContaining("Route: /app")
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const loginResult = await driver.act(
|
|
124
|
+
{
|
|
125
|
+
type: "login",
|
|
126
|
+
payload: { path: "/api/login" }
|
|
127
|
+
},
|
|
128
|
+
targetId
|
|
129
|
+
);
|
|
130
|
+
expect(loginResult).toMatchObject({
|
|
131
|
+
actionType: "login",
|
|
132
|
+
targetKnown: true,
|
|
133
|
+
ok: true,
|
|
134
|
+
executed: true
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const navigateResult = await driver.act(
|
|
138
|
+
{
|
|
139
|
+
type: "navigate",
|
|
140
|
+
payload: { path: "/app/cart" }
|
|
141
|
+
},
|
|
142
|
+
targetId
|
|
143
|
+
);
|
|
144
|
+
expect(navigateResult).toMatchObject({
|
|
145
|
+
actionType: "navigate",
|
|
146
|
+
targetKnown: true,
|
|
147
|
+
ok: true,
|
|
148
|
+
executed: true
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const postNavigateSnapshot = await driver.snapshot(targetId);
|
|
152
|
+
expect(postNavigateSnapshot).toMatchObject({
|
|
153
|
+
hasTarget: true,
|
|
154
|
+
url: `${baseUrl}/app/cart`
|
|
155
|
+
});
|
|
156
|
+
expect(postNavigateSnapshot).toMatchObject({
|
|
157
|
+
html: expect.stringContaining("Route: /app/cart")
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const prepareDownloadResult = await driver.act(
|
|
161
|
+
{
|
|
162
|
+
type: "prepare-download",
|
|
163
|
+
payload: { path: "/download/orders.csv" }
|
|
164
|
+
},
|
|
165
|
+
targetId
|
|
166
|
+
);
|
|
167
|
+
expect(prepareDownloadResult).toMatchObject({
|
|
168
|
+
actionType: "prepare-download",
|
|
169
|
+
targetKnown: true,
|
|
170
|
+
ok: true,
|
|
171
|
+
executed: true
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
await expect(
|
|
175
|
+
driver.waitDownload(targetId, undefined, "C:\\downloads\\orders.csv")
|
|
176
|
+
).resolves.toEqual({
|
|
177
|
+
path: "C:\\downloads\\orders.csv"
|
|
178
|
+
});
|
|
179
|
+
} finally {
|
|
180
|
+
server.close();
|
|
181
|
+
}
|
|
182
|
+
});
|
|
59
183
|
});
|
|
@@ -10,6 +10,9 @@ const DEFAULT_PROFILE_ID: ProfileId = "profile:managed:default";
|
|
|
10
10
|
|
|
11
11
|
type ManagedTab = {
|
|
12
12
|
url: string;
|
|
13
|
+
html: string;
|
|
14
|
+
cookieHeader?: string;
|
|
15
|
+
pendingDownloadUrl?: string;
|
|
13
16
|
};
|
|
14
17
|
|
|
15
18
|
type ManagedProfileState = {
|
|
@@ -35,6 +38,58 @@ function createProfileState(): ManagedProfileState {
|
|
|
35
38
|
};
|
|
36
39
|
}
|
|
37
40
|
|
|
41
|
+
function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
|
42
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function parseCookieHeader(response: Response): string | undefined {
|
|
46
|
+
const rawSetCookie = response.headers.get("set-cookie");
|
|
47
|
+
if (rawSetCookie === null) {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const [cookieSegment] = rawSetCookie.split(";");
|
|
52
|
+
const cookieHeader = cookieSegment.trim();
|
|
53
|
+
return cookieHeader.length > 0 ? cookieHeader : undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function readPayloadString(
|
|
57
|
+
payload: Record<string, unknown>,
|
|
58
|
+
fieldName: string
|
|
59
|
+
): string | undefined {
|
|
60
|
+
const value = payload[fieldName];
|
|
61
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function requestTab(
|
|
65
|
+
tab: ManagedTab,
|
|
66
|
+
url: string,
|
|
67
|
+
init: RequestInit
|
|
68
|
+
): Promise<void> {
|
|
69
|
+
const headers = new Headers(init.headers ?? {});
|
|
70
|
+
if (tab.cookieHeader !== undefined && !headers.has("cookie")) {
|
|
71
|
+
headers.set("cookie", tab.cookieHeader);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const response = await fetch(url, {
|
|
75
|
+
...init,
|
|
76
|
+
headers
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const cookieHeader = parseCookieHeader(response);
|
|
80
|
+
if (cookieHeader !== undefined) {
|
|
81
|
+
tab.cookieHeader = cookieHeader;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const body = await response.text();
|
|
85
|
+
if (!response.ok) {
|
|
86
|
+
throw new Error(`${init.method ?? "GET"} ${url} failed with ${response.status}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
tab.url = response.url;
|
|
90
|
+
tab.html = body;
|
|
91
|
+
}
|
|
92
|
+
|
|
38
93
|
export function createManagedDriver(): BrowserDriver<ManagedDriverStatus> {
|
|
39
94
|
const profileStates = new Map<ProfileId, ManagedProfileState>();
|
|
40
95
|
|
|
@@ -79,12 +134,22 @@ export function createManagedDriver(): BrowserDriver<ManagedDriverStatus> {
|
|
|
79
134
|
const targetId = createTargetId(profileId, profileState.nextTargetNumber);
|
|
80
135
|
|
|
81
136
|
profileState.nextTargetNumber += 1;
|
|
82
|
-
|
|
137
|
+
const tab: ManagedTab = {
|
|
138
|
+
url,
|
|
139
|
+
html: ""
|
|
140
|
+
};
|
|
141
|
+
profileState.tabs.set(targetId, tab);
|
|
83
142
|
profileState.tabOrder.push(targetId);
|
|
84
143
|
if (profileState.focusedTargetId === undefined) {
|
|
85
144
|
profileState.focusedTargetId = targetId;
|
|
86
145
|
}
|
|
87
146
|
|
|
147
|
+
try {
|
|
148
|
+
await requestTab(tab, url, { method: "GET" });
|
|
149
|
+
} catch {
|
|
150
|
+
// Keep managed driver deterministic even when URL is unreachable.
|
|
151
|
+
}
|
|
152
|
+
|
|
88
153
|
return targetId;
|
|
89
154
|
},
|
|
90
155
|
focusTab: async (targetId, profile) => {
|
|
@@ -102,24 +167,175 @@ export function createManagedDriver(): BrowserDriver<ManagedDriverStatus> {
|
|
|
102
167
|
profileState.focusedTargetId = profileState.tabOrder[0];
|
|
103
168
|
}
|
|
104
169
|
},
|
|
105
|
-
snapshot: async (targetId, profile) =>
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
170
|
+
snapshot: async (targetId, profile) => {
|
|
171
|
+
const profileId = resolveProfileId(profile);
|
|
172
|
+
const tab = profileStates.get(profileId)?.tabs.get(targetId);
|
|
173
|
+
if (tab === undefined) {
|
|
174
|
+
return {
|
|
175
|
+
kind: "managed",
|
|
176
|
+
profile: profileId,
|
|
177
|
+
targetId,
|
|
178
|
+
hasTarget: false
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
kind: "managed",
|
|
184
|
+
profile: profileId,
|
|
185
|
+
targetId,
|
|
186
|
+
hasTarget: true,
|
|
187
|
+
url: tab.url,
|
|
188
|
+
html: tab.html
|
|
189
|
+
};
|
|
190
|
+
},
|
|
191
|
+
act: async (action, targetId, profile) => {
|
|
192
|
+
const profileId = resolveProfileId(profile);
|
|
193
|
+
const tab = profileStates.get(profileId)?.tabs.get(targetId);
|
|
194
|
+
if (tab === undefined) {
|
|
195
|
+
return {
|
|
196
|
+
actionType: action.type,
|
|
197
|
+
profile: profileId,
|
|
198
|
+
targetId,
|
|
199
|
+
targetKnown: false,
|
|
200
|
+
ok: false,
|
|
201
|
+
executed: false
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const payload = isObjectRecord(action.payload) ? action.payload : {};
|
|
206
|
+
if (action.type === "navigate" || action.type === "goto") {
|
|
207
|
+
const nextUrlValue = readPayloadString(payload, "url") ?? readPayloadString(payload, "path");
|
|
208
|
+
if (nextUrlValue === undefined) {
|
|
209
|
+
return {
|
|
210
|
+
actionType: action.type,
|
|
211
|
+
profile: profileId,
|
|
212
|
+
targetId,
|
|
213
|
+
targetKnown: true,
|
|
214
|
+
ok: false,
|
|
215
|
+
executed: true,
|
|
216
|
+
error: "action.payload.url or action.payload.path is required for navigate action."
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const nextUrl = new URL(nextUrlValue, tab.url).toString();
|
|
221
|
+
try {
|
|
222
|
+
await requestTab(tab, nextUrl, { method: "GET" });
|
|
223
|
+
} catch (error) {
|
|
224
|
+
return {
|
|
225
|
+
actionType: action.type,
|
|
226
|
+
profile: profileId,
|
|
227
|
+
targetId,
|
|
228
|
+
targetKnown: true,
|
|
229
|
+
ok: false,
|
|
230
|
+
executed: true,
|
|
231
|
+
error: error instanceof Error ? error.message : String(error)
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
actionType: action.type,
|
|
237
|
+
profile: profileId,
|
|
238
|
+
targetId,
|
|
239
|
+
targetKnown: true,
|
|
240
|
+
ok: true,
|
|
241
|
+
executed: true,
|
|
242
|
+
data: {
|
|
243
|
+
url: tab.url
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (action.type === "login") {
|
|
249
|
+
const loginPath = readPayloadString(payload, "path") ?? "/api/login";
|
|
250
|
+
const loginUrl = new URL(loginPath, tab.url).toString();
|
|
251
|
+
try {
|
|
252
|
+
await requestTab(tab, loginUrl, { method: "POST" });
|
|
253
|
+
} catch (error) {
|
|
254
|
+
return {
|
|
255
|
+
actionType: action.type,
|
|
256
|
+
profile: profileId,
|
|
257
|
+
targetId,
|
|
258
|
+
targetKnown: true,
|
|
259
|
+
ok: false,
|
|
260
|
+
executed: true,
|
|
261
|
+
error: error instanceof Error ? error.message : String(error)
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
actionType: action.type,
|
|
267
|
+
profile: profileId,
|
|
268
|
+
targetId,
|
|
269
|
+
targetKnown: true,
|
|
270
|
+
ok: true,
|
|
271
|
+
executed: true
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (action.type === "prepare-download") {
|
|
276
|
+
const downloadPath = readPayloadString(payload, "path");
|
|
277
|
+
if (downloadPath === undefined) {
|
|
278
|
+
return {
|
|
279
|
+
actionType: action.type,
|
|
280
|
+
profile: profileId,
|
|
281
|
+
targetId,
|
|
282
|
+
targetKnown: true,
|
|
283
|
+
ok: false,
|
|
284
|
+
executed: true,
|
|
285
|
+
error: "action.payload.path is required for prepare-download action."
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
tab.pendingDownloadUrl = new URL(downloadPath, tab.url).toString();
|
|
290
|
+
return {
|
|
291
|
+
actionType: action.type,
|
|
292
|
+
profile: profileId,
|
|
293
|
+
targetId,
|
|
294
|
+
targetKnown: true,
|
|
295
|
+
ok: true,
|
|
296
|
+
executed: true,
|
|
297
|
+
data: {
|
|
298
|
+
url: tab.pendingDownloadUrl
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
actionType: action.type,
|
|
305
|
+
profile: profileId,
|
|
306
|
+
targetId,
|
|
307
|
+
targetKnown: true,
|
|
308
|
+
ok: true,
|
|
309
|
+
executed: false
|
|
310
|
+
};
|
|
311
|
+
},
|
|
120
312
|
armUpload: async () => {},
|
|
121
313
|
armDialog: async () => {},
|
|
122
|
-
waitDownload: async () =>
|
|
314
|
+
waitDownload: async (targetId, profile, path) => {
|
|
315
|
+
const profileId = resolveProfileId(profile);
|
|
316
|
+
const tab = profileStates.get(profileId)?.tabs.get(targetId);
|
|
317
|
+
if (tab === undefined) {
|
|
318
|
+
throw new Error(`Unknown targetId: ${targetId} (profile: ${profileId})`);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (tab.pendingDownloadUrl !== undefined) {
|
|
322
|
+
const headers = new Headers();
|
|
323
|
+
if (tab.cookieHeader !== undefined) {
|
|
324
|
+
headers.set("cookie", tab.cookieHeader);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
try {
|
|
328
|
+
await fetch(tab.pendingDownloadUrl, {
|
|
329
|
+
method: "GET",
|
|
330
|
+
headers
|
|
331
|
+
});
|
|
332
|
+
} finally {
|
|
333
|
+
tab.pendingDownloadUrl = undefined;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return { path: path ?? "managed-download.bin" };
|
|
338
|
+
},
|
|
123
339
|
triggerDownload: async () => {}
|
|
124
340
|
};
|
|
125
341
|
}
|
|
@@ -22,6 +22,8 @@ type MockPageRecord = {
|
|
|
22
22
|
locatorType: ReturnType<typeof vi.fn>;
|
|
23
23
|
locatorSetInputFiles: ReturnType<typeof vi.fn>;
|
|
24
24
|
keyboardPress: ReturnType<typeof vi.fn>;
|
|
25
|
+
route: ReturnType<typeof vi.fn>;
|
|
26
|
+
unroute: ReturnType<typeof vi.fn>;
|
|
25
27
|
waitForEvent: ReturnType<typeof vi.fn>;
|
|
26
28
|
on: ReturnType<typeof vi.fn>;
|
|
27
29
|
emitConsole(entry: MockConsoleMessage): Promise<void>;
|
|
@@ -106,6 +108,8 @@ function createMockPageRecord(pageNumber: number): MockPageRecord {
|
|
|
106
108
|
setInputFiles: locatorSetInputFiles
|
|
107
109
|
}));
|
|
108
110
|
const keyboardPress = vi.fn(async (_key: string) => {});
|
|
111
|
+
const route = vi.fn(async (_url: string, _handler: (...args: unknown[]) => Promise<void>) => {});
|
|
112
|
+
const unroute = vi.fn(async (_url: string, _handler?: (...args: unknown[]) => Promise<void>) => {});
|
|
109
113
|
const waitForEvent = vi.fn(async (eventName: string) => {
|
|
110
114
|
return await new Promise<unknown>((resolve, reject) => {
|
|
111
115
|
const current = waiters.get(eventName);
|
|
@@ -151,6 +155,8 @@ function createMockPageRecord(pageNumber: number): MockPageRecord {
|
|
|
151
155
|
content,
|
|
152
156
|
screenshot,
|
|
153
157
|
locator,
|
|
158
|
+
route,
|
|
159
|
+
unroute,
|
|
154
160
|
on,
|
|
155
161
|
waitForEvent,
|
|
156
162
|
keyboard: {
|
|
@@ -172,6 +178,8 @@ function createMockPageRecord(pageNumber: number): MockPageRecord {
|
|
|
172
178
|
locatorType,
|
|
173
179
|
locatorSetInputFiles,
|
|
174
180
|
keyboardPress,
|
|
181
|
+
route,
|
|
182
|
+
unroute,
|
|
175
183
|
waitForEvent,
|
|
176
184
|
on,
|
|
177
185
|
emitConsole: async (entry) => {
|
|
@@ -411,6 +419,66 @@ describe("createManagedLocalDriver", () => {
|
|
|
411
419
|
});
|
|
412
420
|
});
|
|
413
421
|
|
|
422
|
+
it("supports network mock add/clear actions when runtime exposes route controls", async () => {
|
|
423
|
+
const harness = createRuntimeHarness();
|
|
424
|
+
const driver = createManagedLocalDriver({ runtime: harness.runtime });
|
|
425
|
+
const targetId = await driver.openTab("https://example.com/mock");
|
|
426
|
+
const pageRecord = harness.pages[0];
|
|
427
|
+
if (pageRecord === undefined) {
|
|
428
|
+
throw new Error("Expected a mock page to be created.");
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const addResult = await driver.act(
|
|
432
|
+
{
|
|
433
|
+
type: "networkMockAdd",
|
|
434
|
+
payload: {
|
|
435
|
+
urlPattern: "**/api/**",
|
|
436
|
+
method: "POST",
|
|
437
|
+
status: 201,
|
|
438
|
+
body: '{"ok":true}',
|
|
439
|
+
contentType: "application/json"
|
|
440
|
+
}
|
|
441
|
+
},
|
|
442
|
+
targetId
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
expect(addResult).toMatchObject({
|
|
446
|
+
ok: true,
|
|
447
|
+
executed: true,
|
|
448
|
+
data: {
|
|
449
|
+
mockId: expect.any(String),
|
|
450
|
+
urlPattern: "**/api/**",
|
|
451
|
+
method: "POST",
|
|
452
|
+
status: 201
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
expect(pageRecord.route).toHaveBeenCalledWith("**/api/**", expect.any(Function));
|
|
456
|
+
|
|
457
|
+
const mockId = (addResult as { data?: { mockId?: string } }).data?.mockId;
|
|
458
|
+
if (typeof mockId !== "string") {
|
|
459
|
+
throw new Error("Expected mockId from networkMockAdd action.");
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const clearResult = await driver.act(
|
|
463
|
+
{
|
|
464
|
+
type: "networkMockClear",
|
|
465
|
+
payload: {
|
|
466
|
+
mockId
|
|
467
|
+
}
|
|
468
|
+
},
|
|
469
|
+
targetId
|
|
470
|
+
);
|
|
471
|
+
expect(clearResult).toMatchObject({
|
|
472
|
+
ok: true,
|
|
473
|
+
executed: true,
|
|
474
|
+
data: {
|
|
475
|
+
cleared: 1,
|
|
476
|
+
mockId
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
expect(pageRecord.unroute).toHaveBeenCalledWith("**/api/**", expect.any(Function));
|
|
480
|
+
});
|
|
481
|
+
|
|
414
482
|
it("keeps upload/dialog/download state scoped to profile + target", async () => {
|
|
415
483
|
const harness = createRuntimeHarness();
|
|
416
484
|
const driver = createManagedLocalDriver({ runtime: harness.runtime });
|
|
@@ -459,15 +527,19 @@ describe("createManagedLocalDriver", () => {
|
|
|
459
527
|
});
|
|
460
528
|
expect(dialogAccept).toHaveBeenCalledTimes(1);
|
|
461
529
|
|
|
530
|
+
const saveAs = vi.fn(async (_path: string) => {});
|
|
462
531
|
await driver.triggerDownload(targetId, profile);
|
|
463
532
|
await pageRecord.emitDownload({
|
|
464
533
|
path: async () => "C:\\downloads\\alpha.bin",
|
|
534
|
+
saveAs,
|
|
465
535
|
suggestedFilename: () => "alpha.bin",
|
|
466
536
|
url: () => "https://example.com/alpha.bin",
|
|
467
537
|
mimeType: () => "application/octet-stream"
|
|
468
538
|
});
|
|
469
|
-
await expect(
|
|
470
|
-
|
|
539
|
+
await expect(
|
|
540
|
+
driver.waitDownload(targetId, profile, "C:\\downloads\\saved-alpha.bin")
|
|
541
|
+
).resolves.toMatchObject({
|
|
542
|
+
path: "C:\\downloads\\saved-alpha.bin",
|
|
471
543
|
profile,
|
|
472
544
|
targetId,
|
|
473
545
|
suggestedFilename: "alpha.bin",
|
|
@@ -475,6 +547,36 @@ describe("createManagedLocalDriver", () => {
|
|
|
475
547
|
mimeType: "application/octet-stream",
|
|
476
548
|
triggerCount: 1
|
|
477
549
|
});
|
|
550
|
+
expect(saveAs).toHaveBeenCalledWith("C:\\downloads\\saved-alpha.bin");
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
it("fails waitDownload when requested path cannot be persisted", async () => {
|
|
554
|
+
const harness = createRuntimeHarness({
|
|
555
|
+
supportsDownloadEvents: true
|
|
556
|
+
});
|
|
557
|
+
const driver = createManagedLocalDriver({
|
|
558
|
+
runtime: harness.runtime
|
|
559
|
+
});
|
|
560
|
+
const profile = "profile:alpha";
|
|
561
|
+
const targetId = await driver.openTab("https://example.com/upload", profile);
|
|
562
|
+
const pageRecord = harness.pages[0];
|
|
563
|
+
if (pageRecord === undefined) {
|
|
564
|
+
throw new Error("Expected page record.");
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
await driver.triggerDownload(targetId, profile);
|
|
568
|
+
const saveAs = vi.fn(async () => {
|
|
569
|
+
throw new Error("permission denied");
|
|
570
|
+
});
|
|
571
|
+
await pageRecord.emitDownload({
|
|
572
|
+
path: async () => "C:\\downloads\\alpha.bin",
|
|
573
|
+
saveAs
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
await expect(
|
|
577
|
+
driver.waitDownload(targetId, profile, "C:\\downloads\\saved-alpha.bin")
|
|
578
|
+
).rejects.toThrow("Failed to persist download to requested path");
|
|
579
|
+
expect(saveAs).toHaveBeenCalledWith("C:\\downloads\\saved-alpha.bin");
|
|
478
580
|
});
|
|
479
581
|
|
|
480
582
|
it("reuses one browser launch while creating separate profile contexts", async () => {
|