@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
|
@@ -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,
|
|
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.
|
|
61
|
+
return this.readActiveEntry(sessionId)?.state;
|
|
12
62
|
}
|
|
13
63
|
|
|
14
64
|
useProfile(sessionId: SessionId, profile: ProfileId): SessionState {
|
|
15
|
-
const current = this.
|
|
65
|
+
const current = this.readActiveEntry(sessionId, true)?.state ?? createEmptySession(sessionId);
|
|
16
66
|
const next = { ...current, profile };
|
|
17
67
|
|
|
18
|
-
this.sessions.set(sessionId,
|
|
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.
|
|
76
|
+
const current = this.readActiveEntry(sessionId, true)?.state ?? createEmptySession(sessionId);
|
|
24
77
|
const next = { ...current, targetId };
|
|
25
78
|
|
|
26
|
-
this.sessions.set(sessionId,
|
|
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(
|
|
625
|
-
|
|
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 =
|
|
998
|
-
|
|
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
|
-
|
|
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
|
|
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) => {
|