@flrande/browserctl 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
@@ -4,30 +4,135 @@ function createEmptySession(sessionId: SessionId): SessionState {
4
4
  return { sessionId };
5
5
  }
6
6
 
7
+ type SessionStoreOptions = {
8
+ ttlMs?: number;
9
+ now?: () => number;
10
+ };
11
+
12
+ type SessionEntry = {
13
+ state: SessionState;
14
+ touchedAt: number;
15
+ };
16
+
17
+ export type SessionSnapshot = {
18
+ state: SessionState;
19
+ touchedAt: number;
20
+ };
21
+
7
22
  export class SessionStore {
8
- private readonly sessions = new Map<SessionId, SessionState>();
23
+ private readonly sessions = new Map<SessionId, SessionEntry>();
24
+ private readonly ttlMs: number;
25
+ private readonly now: () => number;
26
+
27
+ constructor(options: SessionStoreOptions = {}) {
28
+ this.ttlMs = options.ttlMs === undefined ? 0 : Math.max(0, Math.trunc(options.ttlMs));
29
+ this.now = options.now ?? (() => Date.now());
30
+ }
31
+
32
+ private isExpired(entry: SessionEntry, now: number): boolean {
33
+ return this.ttlMs > 0 && now - entry.touchedAt > this.ttlMs;
34
+ }
35
+
36
+ private readActiveEntry(sessionId: SessionId, touch = false): SessionEntry | undefined {
37
+ const existing = this.sessions.get(sessionId);
38
+ if (existing === undefined) {
39
+ return undefined;
40
+ }
41
+
42
+ const currentTime = this.now();
43
+ if (this.isExpired(existing, currentTime)) {
44
+ this.sessions.delete(sessionId);
45
+ return undefined;
46
+ }
47
+
48
+ if (touch) {
49
+ const touchedEntry: SessionEntry = {
50
+ state: existing.state,
51
+ touchedAt: currentTime
52
+ };
53
+ this.sessions.set(sessionId, touchedEntry);
54
+ return touchedEntry;
55
+ }
56
+
57
+ return existing;
58
+ }
9
59
 
10
60
  get(sessionId: SessionId): SessionState | undefined {
11
- return this.sessions.get(sessionId);
61
+ return this.readActiveEntry(sessionId)?.state;
12
62
  }
13
63
 
14
64
  useProfile(sessionId: SessionId, profile: ProfileId): SessionState {
15
- const current = this.sessions.get(sessionId) ?? createEmptySession(sessionId);
65
+ const current = this.readActiveEntry(sessionId, true)?.state ?? createEmptySession(sessionId);
16
66
  const next = { ...current, profile };
17
67
 
18
- this.sessions.set(sessionId, next);
68
+ this.sessions.set(sessionId, {
69
+ state: next,
70
+ touchedAt: this.now()
71
+ });
19
72
  return next;
20
73
  }
21
74
 
22
75
  useTarget(sessionId: SessionId, targetId: TargetId): SessionState {
23
- const current = this.sessions.get(sessionId) ?? createEmptySession(sessionId);
76
+ const current = this.readActiveEntry(sessionId, true)?.state ?? createEmptySession(sessionId);
24
77
  const next = { ...current, targetId };
25
78
 
26
- this.sessions.set(sessionId, next);
79
+ this.sessions.set(sessionId, {
80
+ state: next,
81
+ touchedAt: this.now()
82
+ });
27
83
  return next;
28
84
  }
29
85
 
30
86
  delete(sessionId: SessionId): boolean {
31
87
  return this.sessions.delete(sessionId);
32
88
  }
89
+
90
+ cleanupExpired(): number {
91
+ if (this.ttlMs <= 0 || this.sessions.size === 0) {
92
+ return 0;
93
+ }
94
+
95
+ const currentTime = this.now();
96
+ let removedCount = 0;
97
+ for (const [sessionId, entry] of this.sessions.entries()) {
98
+ if (!this.isExpired(entry, currentTime)) {
99
+ continue;
100
+ }
101
+
102
+ this.sessions.delete(sessionId);
103
+ removedCount += 1;
104
+ }
105
+
106
+ return removedCount;
107
+ }
108
+
109
+ has(sessionId: SessionId): boolean {
110
+ return this.readActiveEntry(sessionId) !== undefined;
111
+ }
112
+
113
+ listSnapshots(): SessionSnapshot[] {
114
+ const snapshots: SessionSnapshot[] = [];
115
+ const currentTime = this.now();
116
+ for (const [sessionId, entry] of this.sessions.entries()) {
117
+ if (this.isExpired(entry, currentTime)) {
118
+ this.sessions.delete(sessionId);
119
+ continue;
120
+ }
121
+
122
+ snapshots.push({
123
+ state: entry.state,
124
+ touchedAt: entry.touchedAt
125
+ });
126
+ }
127
+
128
+ return snapshots;
129
+ }
130
+
131
+ list(): SessionState[] {
132
+ return this.listSnapshots().map((snapshot) => snapshot.state);
133
+ }
134
+
135
+ size(): number {
136
+ return this.listSnapshots().length;
137
+ }
33
138
  }
@@ -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>;
@@ -132,6 +136,8 @@ function createMockPageRecord(pageNumber: number): MockPageRecord {
132
136
  setInputFiles: locatorSetInputFiles
133
137
  }));
134
138
  const keyboardPress = vi.fn(async (_key: string) => {});
139
+ const route = vi.fn(async (_url: string, _handler: (...args: unknown[]) => Promise<void>) => {});
140
+ const unroute = vi.fn(async (_url: string, _handler?: (...args: unknown[]) => Promise<void>) => {});
135
141
  const waitForEvent = vi.fn(async (eventName: string) => {
136
142
  return await new Promise<unknown>((resolve, reject) => {
137
143
  const current = waiters.get(eventName);
@@ -178,6 +184,8 @@ function createMockPageRecord(pageNumber: number): MockPageRecord {
178
184
  content,
179
185
  screenshot,
180
186
  locator,
187
+ route,
188
+ unroute,
181
189
  on,
182
190
  waitForEvent,
183
191
  keyboard: {
@@ -199,6 +207,8 @@ function createMockPageRecord(pageNumber: number): MockPageRecord {
199
207
  locatorType,
200
208
  locatorSetInputFiles,
201
209
  keyboardPress,
210
+ route,
211
+ unroute,
202
212
  waitForEvent,
203
213
  on,
204
214
  emitConsole: async (entry) => {
@@ -433,6 +443,71 @@ describe("createChromeRelayDriver", () => {
433
443
  expect(pageRecord.keyboardPress).toHaveBeenCalledWith("Enter");
434
444
  });
435
445
 
446
+ it("supports network mock add/clear actions when runtime exposes route controls", async () => {
447
+ const harness = createRuntimeHarness();
448
+ const driver = createChromeRelayDriver({
449
+ relayUrl: "ws://127.0.0.1:9333/devtools/browser/relay",
450
+ runtime: harness.runtime
451
+ });
452
+ const profile = "profile:alpha";
453
+ const targetId = await driver.openTab("https://example.com/mock", profile);
454
+ const pageRecord = harness.pages[0];
455
+ if (pageRecord === undefined) {
456
+ throw new Error("Expected a mock page record.");
457
+ }
458
+
459
+ const addResult = await driver.act(
460
+ {
461
+ type: "networkMockAdd",
462
+ payload: {
463
+ urlPattern: "**/api/**",
464
+ method: "POST",
465
+ status: 201,
466
+ body: '{"ok":true}',
467
+ contentType: "application/json"
468
+ }
469
+ },
470
+ targetId,
471
+ profile
472
+ );
473
+ expect(addResult).toMatchObject({
474
+ ok: true,
475
+ executed: true,
476
+ data: {
477
+ mockId: expect.any(String),
478
+ urlPattern: "**/api/**",
479
+ method: "POST",
480
+ status: 201
481
+ }
482
+ });
483
+ expect(pageRecord.route).toHaveBeenCalledWith("**/api/**", expect.any(Function));
484
+
485
+ const mockId = (addResult as { data?: { mockId?: string } }).data?.mockId;
486
+ if (typeof mockId !== "string") {
487
+ throw new Error("Expected mockId from networkMockAdd action.");
488
+ }
489
+
490
+ const clearResult = await driver.act(
491
+ {
492
+ type: "networkMockClear",
493
+ payload: {
494
+ mockId
495
+ }
496
+ },
497
+ targetId,
498
+ profile
499
+ );
500
+ expect(clearResult).toMatchObject({
501
+ ok: true,
502
+ executed: true,
503
+ data: {
504
+ cleared: 1,
505
+ mockId
506
+ }
507
+ });
508
+ expect(pageRecord.unroute).toHaveBeenCalledWith("**/api/**", expect.any(Function));
509
+ });
510
+
436
511
  it("captures console + network telemetry and supports request body lookup", async () => {
437
512
  const harness = createRuntimeHarness();
438
513
  const driver = createChromeRelayDriver({
@@ -614,15 +689,19 @@ describe("createChromeRelayDriver", () => {
614
689
  });
615
690
  expect(dialogAccept).toHaveBeenCalledTimes(1);
616
691
 
692
+ const saveAs = vi.fn(async (_path: string) => {});
617
693
  await driver.triggerDownload(targetId, profile);
618
694
  await pageRecord.emitDownload({
619
695
  path: async () => "C:\\downloads\\alpha.bin",
696
+ saveAs,
620
697
  suggestedFilename: () => "alpha.bin",
621
698
  url: () => "https://example.com/alpha.bin",
622
699
  mimeType: () => "application/octet-stream"
623
700
  });
624
- await expect(driver.waitDownload(targetId, profile)).resolves.toMatchObject({
625
- path: "C:\\downloads\\alpha.bin",
701
+ await expect(
702
+ driver.waitDownload(targetId, profile, "C:\\downloads\\saved-alpha.bin")
703
+ ).resolves.toMatchObject({
704
+ path: "C:\\downloads\\saved-alpha.bin",
626
705
  profile,
627
706
  targetId,
628
707
  suggestedFilename: "alpha.bin",
@@ -630,5 +709,36 @@ describe("createChromeRelayDriver", () => {
630
709
  mimeType: "application/octet-stream",
631
710
  triggerCount: 1
632
711
  });
712
+ expect(saveAs).toHaveBeenCalledWith("C:\\downloads\\saved-alpha.bin");
713
+ });
714
+
715
+ it("fails waitDownload when requested path cannot be persisted", async () => {
716
+ const harness = createRuntimeHarness({
717
+ supportsDownloadEvents: true
718
+ });
719
+ const driver = createChromeRelayDriver({
720
+ relayUrl: "ws://127.0.0.1:9333/devtools/browser/relay",
721
+ runtime: harness.runtime
722
+ });
723
+ const profile = "profile:alpha";
724
+ const targetId = await driver.openTab("https://example.com/upload", profile);
725
+ const pageRecord = harness.pages[0];
726
+ if (pageRecord === undefined) {
727
+ throw new Error("Expected page record.");
728
+ }
729
+
730
+ await driver.triggerDownload(targetId, profile);
731
+ const saveAs = vi.fn(async () => {
732
+ throw new Error("permission denied");
733
+ });
734
+ await pageRecord.emitDownload({
735
+ path: async () => "C:\\downloads\\alpha.bin",
736
+ saveAs
737
+ });
738
+
739
+ await expect(
740
+ driver.waitDownload(targetId, profile, "C:\\downloads\\saved-alpha.bin")
741
+ ).rejects.toThrow("Failed to persist download to requested path");
742
+ expect(saveAs).toHaveBeenCalledWith("C:\\downloads\\saved-alpha.bin");
633
743
  });
634
744
  });
@@ -97,6 +97,8 @@ export type ChromeRelayPage = {
97
97
  frameSnapshot?(frameId: string): Promise<unknown>;
98
98
  scroll?(deltaX?: number, deltaY?: number): Promise<unknown>;
99
99
  locator(selector: string): ChromeRelayLocator;
100
+ route?(url: string, handler: ChromeRelayNetworkMockHandler): Promise<void>;
101
+ unroute?(url: string, handler?: ChromeRelayNetworkMockHandler): Promise<void>;
100
102
  keyboard?: ChromeRelayKeyboard;
101
103
  on?(eventName: string, listener: (payload: unknown) => unknown): void;
102
104
  };
@@ -149,8 +151,12 @@ type ChromeRelayTargetOperationState = {
149
151
  uploadFiles: string[];
150
152
  dialogArmedCount: number;
151
153
  triggerCount: number;
154
+ nextNetworkMockNumber: number;
155
+ networkMocks: Map<string, ChromeRelayNetworkMockBinding>;
156
+ requestedDownloadPath?: string;
152
157
  downloadInFlight?: Promise<ChromeRelayDownloadArtifact>;
153
158
  latestDownload?: ChromeRelayDownloadArtifact;
159
+ latestRawDownload?: unknown;
154
160
  };
155
161
 
156
162
  type ChromeRelayDownloadArtifact = {
@@ -160,6 +166,12 @@ type ChromeRelayDownloadArtifact = {
160
166
  mimeType?: string;
161
167
  };
162
168
 
169
+ type ChromeRelayNetworkMockHandler = (...args: unknown[]) => Promise<void>;
170
+ type ChromeRelayNetworkMockBinding = {
171
+ urlPattern: string;
172
+ handler: ChromeRelayNetworkMockHandler;
173
+ };
174
+
163
175
  type ChromeRelayTargetTelemetryState = {
164
176
  consoleEntries: ChromeRelayConsoleEntry[];
165
177
  requestSummaries: ChromeRelayNetworkRequestSummary[];
@@ -187,7 +199,9 @@ function createTargetOperationState(): ChromeRelayTargetOperationState {
187
199
  return {
188
200
  uploadFiles: [],
189
201
  dialogArmedCount: 0,
190
- triggerCount: 0
202
+ triggerCount: 0,
203
+ nextNetworkMockNumber: 1,
204
+ networkMocks: new Map<string, ChromeRelayNetworkMockBinding>()
191
205
  };
192
206
  }
193
207
 
@@ -988,21 +1002,26 @@ export function createChromeRelayDriver(
988
1002
  profileId: ProfileId,
989
1003
  targetId: TargetId,
990
1004
  tab: ChromeRelayTab,
991
- operationState: ChromeRelayTargetOperationState
1005
+ operationState: ChromeRelayTargetOperationState,
1006
+ requestedPath?: string
992
1007
  ): Promise<ChromeRelayDownloadArtifact> {
1008
+ if (requestedPath !== undefined) {
1009
+ operationState.requestedDownloadPath = requestedPath;
1010
+ }
1011
+
993
1012
  if (operationState.downloadInFlight !== undefined) {
994
1013
  return operationState.downloadInFlight;
995
1014
  }
996
1015
 
997
- const fallbackPath = createDownloadPath(
998
- profileId,
999
- targetId,
1000
- Math.max(operationState.triggerCount, 1)
1001
- );
1016
+ const fallbackPath =
1017
+ operationState.requestedDownloadPath ??
1018
+ createDownloadPath(profileId, targetId, Math.max(operationState.triggerCount, 1));
1002
1019
  const waitForEventMethod = getObjectMethod(tab.page, "waitForEvent");
1003
1020
  if (typeof waitForEventMethod !== "function") {
1004
1021
  const artifact: ChromeRelayDownloadArtifact = { path: fallbackPath };
1022
+ operationState.requestedDownloadPath = undefined;
1005
1023
  operationState.latestDownload = artifact;
1024
+ operationState.latestRawDownload = undefined;
1006
1025
  const resolved = Promise.resolve(artifact);
1007
1026
  operationState.downloadInFlight = resolved;
1008
1027
  return resolved;
@@ -1010,8 +1029,13 @@ export function createChromeRelayDriver(
1010
1029
 
1011
1030
  const inFlight = (async () => {
1012
1031
  const rawDownload = await waitForEventMethod.call(tab.page, "download");
1013
- const artifact = await readDownloadArtifact(rawDownload, fallbackPath);
1032
+ operationState.latestRawDownload = rawDownload;
1033
+ const persistedPath =
1034
+ operationState.requestedDownloadPath ??
1035
+ createDownloadPath(profileId, targetId, Math.max(operationState.triggerCount, 1));
1036
+ const artifact = await readDownloadArtifact(rawDownload, persistedPath);
1014
1037
  operationState.latestDownload = artifact;
1038
+ operationState.requestedDownloadPath = undefined;
1015
1039
  return artifact;
1016
1040
  })().finally(() => {
1017
1041
  if (operationState.downloadInFlight === inFlight) {
@@ -1023,6 +1047,30 @@ export function createChromeRelayDriver(
1023
1047
  return inFlight;
1024
1048
  }
1025
1049
 
1050
+ async function resolveDownloadArtifactPath(
1051
+ operationState: ChromeRelayTargetOperationState,
1052
+ artifact: ChromeRelayDownloadArtifact,
1053
+ requestedPath?: string
1054
+ ): Promise<ChromeRelayDownloadArtifact> {
1055
+ if (requestedPath === undefined || requestedPath.trim().length === 0 || artifact.path === requestedPath) {
1056
+ return artifact;
1057
+ }
1058
+
1059
+ const failurePrefix = `Failed to persist download to requested path: ${requestedPath}`;
1060
+ const saveAsMethod = getObjectMethod(operationState.latestRawDownload, "saveAs");
1061
+ if (typeof saveAsMethod !== "function") {
1062
+ throw new Error(failurePrefix);
1063
+ }
1064
+
1065
+ try {
1066
+ await saveAsMethod.call(operationState.latestRawDownload, requestedPath);
1067
+ return { ...artifact, path: requestedPath };
1068
+ } catch (error) {
1069
+ const message = error instanceof Error ? error.message : String(error);
1070
+ throw new Error(message.length > 0 ? `${failurePrefix} (${message})` : failurePrefix);
1071
+ }
1072
+ }
1073
+
1026
1074
  return {
1027
1075
  status: async () => {
1028
1076
  const runtimeConnected = await readRuntimeConnectionStatus(runtime);
@@ -1324,7 +1372,142 @@ export function createChromeRelayDriver(
1324
1372
  : {
1325
1373
  deltaX,
1326
1374
  deltaY
1375
+ }
1376
+ };
1377
+ }
1378
+ case "networkMockAdd": {
1379
+ const urlPattern = readActionPayloadString(payload, "urlPattern");
1380
+ if (urlPattern === undefined) {
1381
+ return {
1382
+ ok: false,
1383
+ executed: false,
1384
+ error: "action.payload.urlPattern is required for networkMockAdd action."
1385
+ };
1386
+ }
1387
+
1388
+ const routeMethod = getObjectMethod(tab.page, "route");
1389
+ if (typeof routeMethod !== "function") {
1390
+ return {
1391
+ ok: true,
1392
+ executed: false
1393
+ };
1394
+ }
1395
+
1396
+ const methodFilter = readActionPayloadString(payload, "method")?.toUpperCase();
1397
+ const status = readActionPayloadNumber(payload, "status") ?? 200;
1398
+ if (!Number.isInteger(status) || status < 100 || status > 599) {
1399
+ return {
1400
+ ok: false,
1401
+ executed: false,
1402
+ error: "action.payload.status must be an integer between 100 and 599 for networkMockAdd action."
1403
+ };
1404
+ }
1405
+ const body = readActionPayloadString(payload, "body") ?? "";
1406
+ const contentType = readActionPayloadString(payload, "contentType") ?? "text/plain";
1407
+ const mockId = `mock:${operationState.nextNetworkMockNumber}`;
1408
+ operationState.nextNetworkMockNumber += 1;
1409
+ const handler: ChromeRelayNetworkMockHandler = async (...args) => {
1410
+ const route = args[0];
1411
+ const explicitRequest = args[1];
1412
+ const request = explicitRequest ?? readObjectMethod<unknown>(route, "request");
1413
+ if (methodFilter !== undefined) {
1414
+ const requestMethod = readObjectMethod<string>(request, "method");
1415
+ if (requestMethod?.toUpperCase() !== methodFilter) {
1416
+ const continueMethod = getObjectMethod(route, "continue");
1417
+ if (typeof continueMethod === "function") {
1418
+ await continueMethod.call(route);
1419
+ return;
1420
+ }
1421
+
1422
+ const fallbackMethod = getObjectMethod(route, "fallback");
1423
+ if (typeof fallbackMethod === "function") {
1424
+ await fallbackMethod.call(route);
1425
+ return;
1327
1426
  }
1427
+
1428
+ return;
1429
+ }
1430
+ }
1431
+
1432
+ const fulfillMethod = getObjectMethod(route, "fulfill");
1433
+ if (typeof fulfillMethod !== "function") {
1434
+ return;
1435
+ }
1436
+
1437
+ await fulfillMethod.call(route, {
1438
+ status,
1439
+ body,
1440
+ headers: {
1441
+ "content-type": contentType
1442
+ }
1443
+ });
1444
+ };
1445
+
1446
+ await routeMethod.call(tab.page, urlPattern, handler);
1447
+ operationState.networkMocks.set(mockId, {
1448
+ urlPattern,
1449
+ handler
1450
+ });
1451
+ return {
1452
+ ok: true,
1453
+ executed: true,
1454
+ data: {
1455
+ mockId,
1456
+ urlPattern,
1457
+ ...(methodFilter !== undefined ? { method: methodFilter } : {}),
1458
+ status
1459
+ }
1460
+ };
1461
+ }
1462
+ case "networkMockClear": {
1463
+ const unrouteMethod = getObjectMethod(tab.page, "unroute");
1464
+ if (typeof unrouteMethod !== "function") {
1465
+ return {
1466
+ ok: true,
1467
+ executed: false
1468
+ };
1469
+ }
1470
+
1471
+ const mockId = readActionPayloadString(payload, "mockId");
1472
+ if (mockId !== undefined) {
1473
+ const binding = operationState.networkMocks.get(mockId);
1474
+ if (binding === undefined) {
1475
+ return {
1476
+ ok: false,
1477
+ executed: false,
1478
+ error: `Unknown network mock id: ${mockId}`
1479
+ };
1480
+ }
1481
+
1482
+ await unrouteMethod.call(tab.page, binding.urlPattern, binding.handler);
1483
+ operationState.networkMocks.delete(mockId);
1484
+ return {
1485
+ ok: true,
1486
+ executed: true,
1487
+ data: {
1488
+ cleared: 1,
1489
+ mockId
1490
+ }
1491
+ };
1492
+ }
1493
+
1494
+ let cleared = 0;
1495
+ for (const [registeredId, binding] of operationState.networkMocks.entries()) {
1496
+ try {
1497
+ await unrouteMethod.call(tab.page, binding.urlPattern, binding.handler);
1498
+ } catch {
1499
+ // Ignore per-route cleanup failures.
1500
+ }
1501
+ operationState.networkMocks.delete(registeredId);
1502
+ cleared += 1;
1503
+ }
1504
+
1505
+ return {
1506
+ ok: true,
1507
+ executed: true,
1508
+ data: {
1509
+ cleared
1510
+ }
1328
1511
  };
1329
1512
  }
1330
1513
  case "domQuery": {
@@ -2166,11 +2349,48 @@ export function createChromeRelayDriver(
2166
2349
  const operationState = getOrCreateTargetOperationState(profileId, targetId);
2167
2350
  operationState.dialogArmedCount += 1;
2168
2351
  },
2169
- waitDownload: async (targetId, profile) => {
2352
+ waitDownload: async (targetId, profile, path) => {
2170
2353
  const profileId = resolveProfileId(profile);
2171
2354
  const { tab } = requireTargetInProfile(profileId, targetId);
2172
2355
  const operationState = getOrCreateTargetOperationState(profileId, targetId);
2173
- const download = await ensureDownloadInFlight(profileId, targetId, tab, operationState);
2356
+ const requestedPath = path !== undefined && path.trim().length > 0 ? path : undefined;
2357
+ if (operationState.downloadInFlight === undefined && operationState.latestDownload !== undefined) {
2358
+ const download = await resolveDownloadArtifactPath(
2359
+ operationState,
2360
+ { ...operationState.latestDownload },
2361
+ requestedPath
2362
+ );
2363
+
2364
+ operationState.latestDownload = undefined;
2365
+ operationState.latestRawDownload = undefined;
2366
+ operationState.requestedDownloadPath = undefined;
2367
+ return {
2368
+ path: download.path,
2369
+ profile: profileId,
2370
+ targetId,
2371
+ relayUrl,
2372
+ uploadFiles: [...operationState.uploadFiles],
2373
+ dialogArmedCount: operationState.dialogArmedCount,
2374
+ triggerCount: operationState.triggerCount,
2375
+ ...(download.suggestedFilename !== undefined
2376
+ ? { suggestedFilename: download.suggestedFilename }
2377
+ : {}),
2378
+ ...(download.url !== undefined ? { url: download.url } : {}),
2379
+ ...(download.mimeType !== undefined ? { mimeType: download.mimeType } : {})
2380
+ };
2381
+ }
2382
+
2383
+ const inFlightDownload = await ensureDownloadInFlight(
2384
+ profileId,
2385
+ targetId,
2386
+ tab,
2387
+ operationState,
2388
+ requestedPath
2389
+ );
2390
+ const download = await resolveDownloadArtifactPath(operationState, inFlightDownload, requestedPath);
2391
+ operationState.latestDownload = undefined;
2392
+ operationState.latestRawDownload = undefined;
2393
+ operationState.requestedDownloadPath = undefined;
2174
2394
 
2175
2395
  return {
2176
2396
  path: download.path,
@@ -2192,6 +2412,9 @@ export function createChromeRelayDriver(
2192
2412
  const { tab } = requireTargetInProfile(profileId, targetId);
2193
2413
  const operationState = getOrCreateTargetOperationState(profileId, targetId);
2194
2414
  operationState.triggerCount += 1;
2415
+ operationState.latestDownload = undefined;
2416
+ operationState.latestRawDownload = undefined;
2417
+ operationState.requestedDownloadPath = undefined;
2195
2418
  void ensureDownloadInFlight(profileId, targetId, tab, operationState);
2196
2419
  },
2197
2420
  getConsoleEntries: (targetId, profile) => {