@flrande/browserctl 0.4.0-dev.15.1 → 0.5.0-dev.19.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
@@ -115,6 +115,8 @@ export type ManagedLocalPage = {
115
115
  content(): Promise<string>;
116
116
  screenshot?(options?: Record<string, unknown>): Promise<unknown>;
117
117
  locator(selector: string): ManagedLocalLocator;
118
+ route?(url: string, handler: ManagedLocalNetworkMockHandler): Promise<void>;
119
+ unroute?(url: string, handler?: ManagedLocalNetworkMockHandler): Promise<void>;
118
120
  keyboard?: ManagedLocalKeyboard;
119
121
  on?(eventName: string, listener: (payload: unknown) => unknown): void;
120
122
  };
@@ -154,8 +156,12 @@ type ManagedLocalTargetOperationState = {
154
156
  uploadFiles: string[];
155
157
  dialogArmedCount: number;
156
158
  triggerCount: number;
159
+ nextNetworkMockNumber: number;
160
+ networkMocks: Map<string, ManagedLocalNetworkMockBinding>;
161
+ requestedDownloadPath?: string;
157
162
  downloadInFlight?: Promise<ManagedLocalDownloadArtifact>;
158
163
  latestDownload?: ManagedLocalDownloadArtifact;
164
+ latestRawDownload?: unknown;
159
165
  };
160
166
 
161
167
  type ManagedLocalDownloadArtifact = {
@@ -165,6 +171,12 @@ type ManagedLocalDownloadArtifact = {
165
171
  mimeType?: string;
166
172
  };
167
173
 
174
+ type ManagedLocalNetworkMockHandler = (...args: unknown[]) => Promise<void>;
175
+ type ManagedLocalNetworkMockBinding = {
176
+ urlPattern: string;
177
+ handler: ManagedLocalNetworkMockHandler;
178
+ };
179
+
168
180
  type ManagedLocalTargetTelemetryState = {
169
181
  consoleEntries: ManagedLocalConsoleEntry[];
170
182
  requestSummaries: ManagedLocalNetworkRequestSummary[];
@@ -192,7 +204,9 @@ function createTargetOperationState(): ManagedLocalTargetOperationState {
192
204
  return {
193
205
  uploadFiles: [],
194
206
  dialogArmedCount: 0,
195
- triggerCount: 0
207
+ triggerCount: 0,
208
+ nextNetworkMockNumber: 1,
209
+ networkMocks: new Map<string, ManagedLocalNetworkMockBinding>()
196
210
  };
197
211
  }
198
212
 
@@ -889,21 +903,26 @@ export function createManagedLocalDriver(
889
903
  profileId: ProfileId,
890
904
  targetId: TargetId,
891
905
  tab: ManagedLocalTab,
892
- operationState: ManagedLocalTargetOperationState
906
+ operationState: ManagedLocalTargetOperationState,
907
+ requestedPath?: string
893
908
  ): Promise<ManagedLocalDownloadArtifact> {
909
+ if (requestedPath !== undefined) {
910
+ operationState.requestedDownloadPath = requestedPath;
911
+ }
912
+
894
913
  if (operationState.downloadInFlight !== undefined) {
895
914
  return operationState.downloadInFlight;
896
915
  }
897
916
 
898
- const fallbackPath = createDownloadPath(
899
- profileId,
900
- targetId,
901
- Math.max(operationState.triggerCount, 1)
902
- );
917
+ const fallbackPath =
918
+ operationState.requestedDownloadPath ??
919
+ createDownloadPath(profileId, targetId, Math.max(operationState.triggerCount, 1));
903
920
  const waitForEventMethod = getObjectMethod(tab.page, "waitForEvent");
904
921
  if (typeof waitForEventMethod !== "function") {
905
922
  const artifact: ManagedLocalDownloadArtifact = { path: fallbackPath };
923
+ operationState.requestedDownloadPath = undefined;
906
924
  operationState.latestDownload = artifact;
925
+ operationState.latestRawDownload = undefined;
907
926
  const resolved = Promise.resolve(artifact);
908
927
  operationState.downloadInFlight = resolved;
909
928
  return resolved;
@@ -911,8 +930,13 @@ export function createManagedLocalDriver(
911
930
 
912
931
  const inFlight = (async () => {
913
932
  const rawDownload = await waitForEventMethod.call(tab.page, "download");
914
- const artifact = await readDownloadArtifact(rawDownload, fallbackPath);
933
+ operationState.latestRawDownload = rawDownload;
934
+ const persistedPath =
935
+ operationState.requestedDownloadPath ??
936
+ createDownloadPath(profileId, targetId, Math.max(operationState.triggerCount, 1));
937
+ const artifact = await readDownloadArtifact(rawDownload, persistedPath);
915
938
  operationState.latestDownload = artifact;
939
+ operationState.requestedDownloadPath = undefined;
916
940
  return artifact;
917
941
  })().finally(() => {
918
942
  if (operationState.downloadInFlight === inFlight) {
@@ -924,6 +948,30 @@ export function createManagedLocalDriver(
924
948
  return inFlight;
925
949
  }
926
950
 
951
+ async function resolveDownloadArtifactPath(
952
+ operationState: ManagedLocalTargetOperationState,
953
+ artifact: ManagedLocalDownloadArtifact,
954
+ requestedPath?: string
955
+ ): Promise<ManagedLocalDownloadArtifact> {
956
+ if (requestedPath === undefined || requestedPath.trim().length === 0 || artifact.path === requestedPath) {
957
+ return artifact;
958
+ }
959
+
960
+ const failurePrefix = `Failed to persist download to requested path: ${requestedPath}`;
961
+ const saveAsMethod = getObjectMethod(operationState.latestRawDownload, "saveAs");
962
+ if (typeof saveAsMethod !== "function") {
963
+ throw new Error(failurePrefix);
964
+ }
965
+
966
+ try {
967
+ await saveAsMethod.call(operationState.latestRawDownload, requestedPath);
968
+ return { ...artifact, path: requestedPath };
969
+ } catch (error) {
970
+ const message = error instanceof Error ? error.message : String(error);
971
+ throw new Error(message.length > 0 ? `${failurePrefix} (${message})` : failurePrefix);
972
+ }
973
+ }
974
+
927
975
  return {
928
976
  status: async () => ({
929
977
  kind: "managed-local",
@@ -1167,6 +1215,141 @@ export function createManagedLocalDriver(
1167
1215
  await tab.page.keyboard.press(key);
1168
1216
  return { ok: true, executed: true };
1169
1217
  }
1218
+ case "networkMockAdd": {
1219
+ const urlPattern = readActionPayloadString(payload, "urlPattern");
1220
+ if (urlPattern === undefined) {
1221
+ return {
1222
+ ok: false,
1223
+ executed: false,
1224
+ error: "action.payload.urlPattern is required for networkMockAdd action."
1225
+ };
1226
+ }
1227
+
1228
+ const routeMethod = getObjectMethod(tab.page, "route");
1229
+ if (typeof routeMethod !== "function") {
1230
+ return {
1231
+ ok: true,
1232
+ executed: false
1233
+ };
1234
+ }
1235
+
1236
+ const methodFilter = readActionPayloadString(payload, "method")?.toUpperCase();
1237
+ const status = readActionPayloadNumber(payload, "status") ?? 200;
1238
+ if (!Number.isInteger(status) || status < 100 || status > 599) {
1239
+ return {
1240
+ ok: false,
1241
+ executed: false,
1242
+ error: "action.payload.status must be an integer between 100 and 599 for networkMockAdd action."
1243
+ };
1244
+ }
1245
+ const body = readActionPayloadString(payload, "body") ?? "";
1246
+ const contentType = readActionPayloadString(payload, "contentType") ?? "text/plain";
1247
+ const mockId = `mock:${operationState.nextNetworkMockNumber}`;
1248
+ operationState.nextNetworkMockNumber += 1;
1249
+ const handler: ManagedLocalNetworkMockHandler = async (...args) => {
1250
+ const route = args[0];
1251
+ const explicitRequest = args[1];
1252
+ const request = explicitRequest ?? readObjectMethod<unknown>(route, "request");
1253
+ if (methodFilter !== undefined) {
1254
+ const requestMethod = readObjectMethod<string>(request, "method");
1255
+ if (requestMethod?.toUpperCase() !== methodFilter) {
1256
+ const continueMethod = getObjectMethod(route, "continue");
1257
+ if (typeof continueMethod === "function") {
1258
+ await continueMethod.call(route);
1259
+ return;
1260
+ }
1261
+
1262
+ const fallbackMethod = getObjectMethod(route, "fallback");
1263
+ if (typeof fallbackMethod === "function") {
1264
+ await fallbackMethod.call(route);
1265
+ return;
1266
+ }
1267
+
1268
+ return;
1269
+ }
1270
+ }
1271
+
1272
+ const fulfillMethod = getObjectMethod(route, "fulfill");
1273
+ if (typeof fulfillMethod !== "function") {
1274
+ return;
1275
+ }
1276
+
1277
+ await fulfillMethod.call(route, {
1278
+ status,
1279
+ body,
1280
+ headers: {
1281
+ "content-type": contentType
1282
+ }
1283
+ });
1284
+ };
1285
+
1286
+ await routeMethod.call(tab.page, urlPattern, handler);
1287
+ operationState.networkMocks.set(mockId, {
1288
+ urlPattern,
1289
+ handler
1290
+ });
1291
+ return {
1292
+ ok: true,
1293
+ executed: true,
1294
+ data: {
1295
+ mockId,
1296
+ urlPattern,
1297
+ ...(methodFilter !== undefined ? { method: methodFilter } : {}),
1298
+ status
1299
+ }
1300
+ };
1301
+ }
1302
+ case "networkMockClear": {
1303
+ const unrouteMethod = getObjectMethod(tab.page, "unroute");
1304
+ if (typeof unrouteMethod !== "function") {
1305
+ return {
1306
+ ok: true,
1307
+ executed: false
1308
+ };
1309
+ }
1310
+
1311
+ const mockId = readActionPayloadString(payload, "mockId");
1312
+ if (mockId !== undefined) {
1313
+ const binding = operationState.networkMocks.get(mockId);
1314
+ if (binding === undefined) {
1315
+ return {
1316
+ ok: false,
1317
+ executed: false,
1318
+ error: `Unknown network mock id: ${mockId}`
1319
+ };
1320
+ }
1321
+
1322
+ await unrouteMethod.call(tab.page, binding.urlPattern, binding.handler);
1323
+ operationState.networkMocks.delete(mockId);
1324
+ return {
1325
+ ok: true,
1326
+ executed: true,
1327
+ data: {
1328
+ cleared: 1,
1329
+ mockId
1330
+ }
1331
+ };
1332
+ }
1333
+
1334
+ let cleared = 0;
1335
+ for (const [registeredId, binding] of operationState.networkMocks.entries()) {
1336
+ try {
1337
+ await unrouteMethod.call(tab.page, binding.urlPattern, binding.handler);
1338
+ } catch {
1339
+ // Ignore per-route cleanup failures.
1340
+ }
1341
+ operationState.networkMocks.delete(registeredId);
1342
+ cleared += 1;
1343
+ }
1344
+
1345
+ return {
1346
+ ok: true,
1347
+ executed: true,
1348
+ data: {
1349
+ cleared
1350
+ }
1351
+ };
1352
+ }
1170
1353
  case "domQuery": {
1171
1354
  const selector = readActionPayloadString(payload, "selector");
1172
1355
  if (selector === undefined) {
@@ -1982,11 +2165,47 @@ export function createManagedLocalDriver(
1982
2165
  const operationState = getOrCreateTargetOperationState(profileId, targetId);
1983
2166
  operationState.dialogArmedCount += 1;
1984
2167
  },
1985
- waitDownload: async (targetId, profile) => {
2168
+ waitDownload: async (targetId, profile, path) => {
1986
2169
  const profileId = resolveProfileId(profile);
1987
2170
  const { tab } = requireTargetInProfile(profileId, targetId);
1988
2171
  const operationState = getOrCreateTargetOperationState(profileId, targetId);
1989
- const download = await ensureDownloadInFlight(profileId, targetId, tab, operationState);
2172
+ const requestedPath = path !== undefined && path.trim().length > 0 ? path : undefined;
2173
+ if (operationState.downloadInFlight === undefined && operationState.latestDownload !== undefined) {
2174
+ const download = await resolveDownloadArtifactPath(
2175
+ operationState,
2176
+ { ...operationState.latestDownload },
2177
+ requestedPath
2178
+ );
2179
+
2180
+ operationState.latestDownload = undefined;
2181
+ operationState.latestRawDownload = undefined;
2182
+ operationState.requestedDownloadPath = undefined;
2183
+ return {
2184
+ path: download.path,
2185
+ profile: profileId,
2186
+ targetId,
2187
+ uploadFiles: [...operationState.uploadFiles],
2188
+ dialogArmedCount: operationState.dialogArmedCount,
2189
+ triggerCount: operationState.triggerCount,
2190
+ ...(download.suggestedFilename !== undefined
2191
+ ? { suggestedFilename: download.suggestedFilename }
2192
+ : {}),
2193
+ ...(download.url !== undefined ? { url: download.url } : {}),
2194
+ ...(download.mimeType !== undefined ? { mimeType: download.mimeType } : {})
2195
+ };
2196
+ }
2197
+
2198
+ const inFlightDownload = await ensureDownloadInFlight(
2199
+ profileId,
2200
+ targetId,
2201
+ tab,
2202
+ operationState,
2203
+ requestedPath
2204
+ );
2205
+ const download = await resolveDownloadArtifactPath(operationState, inFlightDownload, requestedPath);
2206
+ operationState.latestDownload = undefined;
2207
+ operationState.latestRawDownload = undefined;
2208
+ operationState.requestedDownloadPath = undefined;
1990
2209
 
1991
2210
  return {
1992
2211
  path: download.path,
@@ -2007,6 +2226,9 @@ export function createManagedLocalDriver(
2007
2226
  const { tab } = requireTargetInProfile(profileId, targetId);
2008
2227
  const operationState = getOrCreateTargetOperationState(profileId, targetId);
2009
2228
  operationState.triggerCount += 1;
2229
+ operationState.latestDownload = undefined;
2230
+ operationState.latestRawDownload = undefined;
2231
+ operationState.requestedDownloadPath = undefined;
2010
2232
  void ensureDownloadInFlight(profileId, targetId, tab, operationState);
2011
2233
  },
2012
2234
  getConsoleEntries: (targetId, profile) => {
@@ -50,6 +50,8 @@ type MockPage = {
50
50
  type(value: string): Promise<void>;
51
51
  setInputFiles?(files: string[]): Promise<void>;
52
52
  };
53
+ route?(url: string, handler: (...args: unknown[]) => Promise<void>): Promise<void>;
54
+ unroute?(url: string, handler?: (...args: unknown[]) => Promise<void>): Promise<void>;
53
55
  keyboard?: {
54
56
  press(key: string): Promise<void>;
55
57
  };
@@ -71,6 +73,8 @@ type MockPageRecord = {
71
73
  locatorType: ReturnType<typeof vi.fn>;
72
74
  locatorSetInputFiles: ReturnType<typeof vi.fn>;
73
75
  keyboardPress: ReturnType<typeof vi.fn>;
76
+ route: ReturnType<typeof vi.fn>;
77
+ unroute: ReturnType<typeof vi.fn>;
74
78
  waitForEvent: ReturnType<typeof vi.fn>;
75
79
  on: ReturnType<typeof vi.fn>;
76
80
  emitConsole(entry: MockConsoleMessage): Promise<void>;
@@ -125,6 +129,8 @@ function createMockPageRecord(pageNumber: number): MockPageRecord {
125
129
  setInputFiles: locatorSetInputFiles
126
130
  }));
127
131
  const keyboardPress = vi.fn(async (_key: string) => {});
132
+ const route = vi.fn(async (_url: string, _handler: (...args: unknown[]) => Promise<void>) => {});
133
+ const unroute = vi.fn(async (_url: string, _handler?: (...args: unknown[]) => Promise<void>) => {});
128
134
  const waitForEvent = vi.fn(async (eventName: string) => {
129
135
  return await new Promise<unknown>((resolve, reject) => {
130
136
  const current = waiters.get(eventName);
@@ -171,6 +177,8 @@ function createMockPageRecord(pageNumber: number): MockPageRecord {
171
177
  content,
172
178
  screenshot,
173
179
  locator,
180
+ route,
181
+ unroute,
174
182
  on,
175
183
  waitForEvent,
176
184
  keyboard: {
@@ -192,6 +200,8 @@ function createMockPageRecord(pageNumber: number): MockPageRecord {
192
200
  locatorType,
193
201
  locatorSetInputFiles,
194
202
  keyboardPress,
203
+ route,
204
+ unroute,
195
205
  waitForEvent,
196
206
  on,
197
207
  emitConsole: async (entry) => {
@@ -408,6 +418,71 @@ describe("createRemoteCdpDriver", () => {
408
418
  expect(pageRecord.keyboardPress).toHaveBeenCalledWith("Enter");
409
419
  });
410
420
 
421
+ it("supports network mock add/clear actions when runtime exposes route controls", async () => {
422
+ const harness = createRuntimeHarness();
423
+ const driver = createRemoteCdpDriver({
424
+ cdpUrl: "ws://localhost:9222/devtools/browser/example",
425
+ runtime: harness.runtime
426
+ });
427
+ const profile = "profile:alpha";
428
+ const targetId = await driver.openTab("https://example.com/mock", profile);
429
+ const pageRecord = harness.pages[0];
430
+ if (pageRecord === undefined) {
431
+ throw new Error("Expected a mock page record.");
432
+ }
433
+
434
+ const addResult = await driver.act(
435
+ {
436
+ type: "networkMockAdd",
437
+ payload: {
438
+ urlPattern: "**/api/**",
439
+ method: "POST",
440
+ status: 201,
441
+ body: '{"ok":true}',
442
+ contentType: "application/json"
443
+ }
444
+ },
445
+ targetId,
446
+ profile
447
+ );
448
+ expect(addResult).toMatchObject({
449
+ ok: true,
450
+ executed: true,
451
+ data: {
452
+ mockId: expect.any(String),
453
+ urlPattern: "**/api/**",
454
+ method: "POST",
455
+ status: 201
456
+ }
457
+ });
458
+ expect(pageRecord.route).toHaveBeenCalledWith("**/api/**", expect.any(Function));
459
+
460
+ const mockId = (addResult as { data?: { mockId?: string } }).data?.mockId;
461
+ if (typeof mockId !== "string") {
462
+ throw new Error("Expected mockId from networkMockAdd action.");
463
+ }
464
+
465
+ const clearResult = await driver.act(
466
+ {
467
+ type: "networkMockClear",
468
+ payload: {
469
+ mockId
470
+ }
471
+ },
472
+ targetId,
473
+ profile
474
+ );
475
+ expect(clearResult).toMatchObject({
476
+ ok: true,
477
+ executed: true,
478
+ data: {
479
+ cleared: 1,
480
+ mockId
481
+ }
482
+ });
483
+ expect(pageRecord.unroute).toHaveBeenCalledWith("**/api/**", expect.any(Function));
484
+ });
485
+
411
486
  it("captures console + network telemetry and supports request body lookup", async () => {
412
487
  const harness = createRuntimeHarness();
413
488
  const driver = createRemoteCdpDriver({
@@ -597,15 +672,19 @@ describe("createRemoteCdpDriver", () => {
597
672
  });
598
673
  expect(dialogAccept).toHaveBeenCalledTimes(1);
599
674
 
675
+ const saveAs = vi.fn(async (_path: string) => {});
600
676
  await driver.triggerDownload(targetId, profile);
601
677
  await pageRecord.emitDownload({
602
678
  path: async () => "C:\\downloads\\alpha.bin",
679
+ saveAs,
603
680
  suggestedFilename: () => "alpha.bin",
604
681
  url: () => "https://example.com/alpha.bin",
605
682
  mimeType: () => "application/octet-stream"
606
683
  });
607
- await expect(driver.waitDownload(targetId, profile)).resolves.toMatchObject({
608
- path: "C:\\downloads\\alpha.bin",
684
+ await expect(
685
+ driver.waitDownload(targetId, profile, "C:\\downloads\\saved-alpha.bin")
686
+ ).resolves.toMatchObject({
687
+ path: "C:\\downloads\\saved-alpha.bin",
609
688
  profile,
610
689
  targetId,
611
690
  suggestedFilename: "alpha.bin",
@@ -613,5 +692,36 @@ describe("createRemoteCdpDriver", () => {
613
692
  mimeType: "application/octet-stream",
614
693
  triggerCount: 1
615
694
  });
695
+ expect(saveAs).toHaveBeenCalledWith("C:\\downloads\\saved-alpha.bin");
696
+ });
697
+
698
+ it("fails waitDownload when requested path cannot be persisted", async () => {
699
+ const harness = createRuntimeHarness({
700
+ supportsDownloadEvents: true
701
+ });
702
+ const driver = createRemoteCdpDriver({
703
+ cdpUrl: "ws://localhost:9222/devtools/browser/example",
704
+ runtime: harness.runtime
705
+ });
706
+ const profile = "profile:alpha";
707
+ const targetId = await driver.openTab("https://example.com/upload", profile);
708
+ const pageRecord = harness.pages[0];
709
+ if (pageRecord === undefined) {
710
+ throw new Error("Expected page record.");
711
+ }
712
+
713
+ await driver.triggerDownload(targetId, profile);
714
+ const saveAs = vi.fn(async () => {
715
+ throw new Error("permission denied");
716
+ });
717
+ await pageRecord.emitDownload({
718
+ path: async () => "C:\\downloads\\alpha.bin",
719
+ saveAs
720
+ });
721
+
722
+ await expect(
723
+ driver.waitDownload(targetId, profile, "C:\\downloads\\saved-alpha.bin")
724
+ ).rejects.toThrow("Failed to persist download to requested path");
725
+ expect(saveAs).toHaveBeenCalledWith("C:\\downloads\\saved-alpha.bin");
616
726
  });
617
727
  });