@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.
Files changed (52) hide show
  1. package/apps/browserctl/src/commands/act.test.ts +71 -0
  2. package/apps/browserctl/src/commands/act.ts +45 -1
  3. package/apps/browserctl/src/commands/command-wrappers.test.ts +302 -0
  4. package/apps/browserctl/src/commands/console-list.test.ts +102 -0
  5. package/apps/browserctl/src/commands/console-list.ts +89 -1
  6. package/apps/browserctl/src/commands/har-export.test.ts +112 -0
  7. package/apps/browserctl/src/commands/har-export.ts +120 -0
  8. package/apps/browserctl/src/commands/memory-delete.ts +20 -0
  9. package/apps/browserctl/src/commands/memory-inspect.ts +20 -0
  10. package/apps/browserctl/src/commands/memory-list.ts +90 -0
  11. package/apps/browserctl/src/commands/memory-mode-set.ts +29 -0
  12. package/apps/browserctl/src/commands/memory-purge.ts +16 -0
  13. package/apps/browserctl/src/commands/memory-resolve.ts +56 -0
  14. package/apps/browserctl/src/commands/memory-status.ts +16 -0
  15. package/apps/browserctl/src/commands/memory-ttl-set.ts +28 -0
  16. package/apps/browserctl/src/commands/memory-upsert.ts +142 -0
  17. package/apps/browserctl/src/commands/network-list.test.ts +110 -0
  18. package/apps/browserctl/src/commands/network-list.ts +112 -0
  19. package/apps/browserctl/src/commands/session-drop.test.ts +36 -0
  20. package/apps/browserctl/src/commands/session-drop.ts +16 -0
  21. package/apps/browserctl/src/commands/session-list.test.ts +81 -0
  22. package/apps/browserctl/src/commands/session-list.ts +70 -0
  23. package/apps/browserctl/src/commands/trace-get.test.ts +61 -0
  24. package/apps/browserctl/src/commands/trace-get.ts +62 -0
  25. package/apps/browserctl/src/commands/wait-element.test.ts +80 -0
  26. package/apps/browserctl/src/commands/wait-element.ts +76 -0
  27. package/apps/browserctl/src/commands/wait-text.test.ts +110 -0
  28. package/apps/browserctl/src/commands/wait-text.ts +93 -0
  29. package/apps/browserctl/src/commands/wait-url.test.ts +80 -0
  30. package/apps/browserctl/src/commands/wait-url.ts +76 -0
  31. package/apps/browserctl/src/main.dispatch.test.ts +206 -1
  32. package/apps/browserctl/src/main.test.ts +30 -0
  33. package/apps/browserctl/src/main.ts +246 -4
  34. package/apps/browserd/src/container.ts +1603 -48
  35. package/apps/browserd/src/main.test.ts +538 -1
  36. package/apps/browserd/src/tool-matrix.test.ts +492 -3
  37. package/package.json +5 -1
  38. package/packages/core/src/driver.ts +1 -1
  39. package/packages/core/src/index.ts +1 -0
  40. package/packages/core/src/navigation-memory.test.ts +259 -0
  41. package/packages/core/src/navigation-memory.ts +360 -0
  42. package/packages/core/src/session-store.test.ts +33 -0
  43. package/packages/core/src/session-store.ts +111 -6
  44. package/packages/driver-chrome-relay/src/chrome-relay-driver.test.ts +112 -2
  45. package/packages/driver-chrome-relay/src/chrome-relay-driver.ts +233 -10
  46. package/packages/driver-managed/src/managed-driver.test.ts +124 -0
  47. package/packages/driver-managed/src/managed-driver.ts +233 -17
  48. package/packages/driver-managed/src/managed-local-driver.test.ts +104 -2
  49. package/packages/driver-managed/src/managed-local-driver.ts +232 -10
  50. package/packages/driver-remote-cdp/src/remote-cdp-driver.test.ts +112 -2
  51. package/packages/driver-remote-cdp/src/remote-cdp-driver.ts +232 -10
  52. 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
- profileState.tabs.set(targetId, { url });
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
- 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
- }),
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 () => ({ path: "managed-download.bin" }),
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(driver.waitDownload(targetId, profile)).resolves.toMatchObject({
470
- path: "C:\\downloads\\alpha.bin",
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 () => {