@omercnet/paseo-shared-browser 1.2.2 → 2.0.0-next.244.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/OVERVIEW.md +2 -2
- package/README.md +316 -27
- package/REMOTE_BROWSER_RESEARCH.md +395 -0
- package/STREAMING_DESIGN.md +196 -0
- package/client/browser-auto-capture-density.test.ts +102 -0
- package/client/browser-auto-capture-density.ts +30 -0
- package/client/browser-canvas-input.test.ts +198 -0
- package/client/browser-canvas-input.ts +202 -0
- package/client/browser-canvas-keyboard-web.test.ts +113 -0
- package/client/browser-canvas-keyboard-web.ts +230 -0
- package/client/browser-canvas-keyboard.test.ts +123 -0
- package/client/browser-canvas-keyboard.ts +126 -0
- package/client/browser-canvas-layout.test.ts +109 -0
- package/client/browser-canvas-layout.ts +51 -0
- package/client/browser-canvas-viewport-web.test.ts +77 -0
- package/client/browser-canvas-viewport-web.ts +63 -0
- package/client/browser-canvas-viewport.tsx +152 -0
- package/client/browser-capture-cadence.test.ts +9 -0
- package/client/browser-capture-cadence.ts +6 -0
- package/client/browser-capture-density-controls.tsx +75 -0
- package/client/browser-chrome.tsx +234 -0
- package/client/browser-client-presence.ts +184 -0
- package/client/browser-compose-controls.test.tsx +275 -0
- package/client/browser-compose-controls.tsx +174 -0
- package/client/browser-control-button.tsx +109 -0
- package/client/browser-control-dialog.tsx +71 -0
- package/client/browser-control-request.test.ts +27 -0
- package/client/browser-control-request.ts +57 -0
- package/client/browser-control-tooltip-web.ts +10 -0
- package/client/browser-dialog-body.tsx +22 -0
- package/client/browser-emulation-mode.test.ts +75 -0
- package/client/browser-emulation-mode.ts +98 -0
- package/client/browser-fill-viewport.test.ts +45 -0
- package/client/browser-fill-viewport.ts +28 -0
- package/client/browser-frame-buffer.test.ts +157 -0
- package/client/browser-frame-buffer.ts +148 -0
- package/client/browser-frame-image.tsx +62 -0
- package/client/browser-image-load.test.ts +23 -0
- package/client/browser-image-load.ts +26 -0
- package/client/browser-input-queue.test.ts +1254 -0
- package/client/browser-input-queue.ts +519 -0
- package/client/browser-menu-placement.test.ts +90 -0
- package/client/browser-menu-placement.ts +78 -0
- package/client/browser-panel-styles.ts +384 -0
- package/client/browser-quality-controls.test.ts +26 -0
- package/client/browser-quality-controls.tsx +139 -0
- package/client/browser-resolution-picker.tsx +189 -0
- package/client/browser-tabs.tsx +117 -0
- package/client/browser-toolbar-menu-web.test.ts +185 -0
- package/client/browser-toolbar-menu-web.ts +159 -0
- package/client/browser-toolbar-menu.tsx +255 -0
- package/client/browser-toolbar-submenu.tsx +93 -0
- package/client/browser-video-bytes.test.ts +44 -0
- package/client/browser-video-currency.ts +45 -0
- package/client/browser-video-decoder.test.ts +473 -0
- package/client/browser-video-decoder.ts +311 -0
- package/client/browser-video-surface.tsx +21 -0
- package/client/browser-video-visibility.test.ts +45 -0
- package/client/browser-viewer-recovery.test.ts +111 -0
- package/client/browser-viewer-recovery.ts +78 -0
- package/client/browser.tsx +1650 -1579
- package/client/favorite-star.tsx +23 -0
- package/client/frame-lifecycle.ts +62 -59
- package/client/sidebar.tsx +1 -2
- package/client/use-browser-auto-capture-density.test.ts +104 -0
- package/client/use-browser-auto-capture-density.ts +43 -0
- package/client/use-browser-canvas-input.ts +375 -0
- package/client/use-browser-capture-density.ts +87 -0
- package/client/use-browser-emulation-mode.ts +56 -0
- package/client/use-browser-fill-viewport.test.ts +108 -0
- package/client/use-browser-fill-viewport.ts +62 -0
- package/client/use-browser-frame-buffer.ts +66 -0
- package/client/use-browser-image-capture.ts +139 -0
- package/client/use-browser-scoped-mutation.test.ts +118 -0
- package/client/use-browser-scoped-mutation.ts +59 -0
- package/client/use-browser-video.test.ts +1060 -0
- package/client/use-browser-video.ts +481 -0
- package/client/use-browser-viewer-recovery.ts +26 -0
- package/client/use-resolution-favorites.ts +90 -0
- package/client/web.test.ts +125 -0
- package/client/web.ts +405 -0
- package/index.client.tsx +0 -10
- package/index.server.ts +32 -4
- package/npm-shrinkwrap.json +9 -9
- package/package.json +10 -5
- package/paseo-plugin.json +1 -1
- package/scripts/prepare-runtime.mjs +1 -1
- package/server/agent-browser-runtime.ts +1601 -314
- package/server/browser-cursor.test.ts +64 -0
- package/server/browser-cursor.ts +69 -0
- package/server/browser-frame-context.ts +22 -0
- package/server/browser-frame-epoch.test.ts +95 -0
- package/server/browser-frame-rejection.ts +17 -0
- package/server/browser-gesture.ts +120 -0
- package/server/browser-operation-diagnostics.test.ts +79 -0
- package/server/browser-operation-diagnostics.ts +103 -0
- package/server/browser-policy.ts +1316 -151
- package/server/browser-video-input-authority.test.ts +211 -0
- package/server/browser-video-policy.test.ts +564 -0
- package/server/browser-video-socket.test.ts +368 -0
- package/server/browser.ts +151 -16
- package/server/capture-transport-policy.test.ts +105 -0
- package/server/capture-transport-policy.ts +154 -0
- package/server/capture-transport-runtime.test.ts +213 -0
- package/server/daemon-launch-chain.test.ts +70 -0
- package/server/daemon-shutdown.test.ts +289 -0
- package/server/discrete-input-freshness.test.ts +364 -0
- package/server/discrete-input-native-lifetime.test.ts +150 -0
- package/server/display-loss-recovery.test.ts +294 -0
- package/server/emulation-restoration.test.ts +262 -0
- package/server/input-cleanup.ts +23 -0
- package/server/input-generation.test.ts +31 -0
- package/server/input-generation.ts +36 -0
- package/server/jpeg-capture-demand.test.ts +76 -0
- package/server/jpeg-capture-demand.ts +98 -0
- package/server/jpeg-frame.ts +29 -0
- package/server/keyboard-input.test.ts +61 -0
- package/server/keyboard-input.ts +104 -0
- package/server/live-input-policy.test.ts +958 -0
- package/server/live-input-runtime.test.ts +732 -0
- package/server/live-input-supervisor.test.ts +147 -0
- package/server/mcp-entry.ts +44 -2
- package/server/native-video-backpressure.test.ts +228 -0
- package/server/native-video-buffer.test.ts +141 -0
- package/server/native-video-buffer.ts +114 -0
- package/server/native-video-capture.test.ts +599 -0
- package/server/native-video-capture.ts +497 -0
- package/server/native-video-extension-source.test.ts +201 -0
- package/server/native-video-extension-source.ts +364 -0
- package/server/native-video-extension.test.ts +41 -0
- package/server/native-video-extension.ts +83 -0
- package/server/native-video-packet.ts +9 -0
- package/server/native-video-settings.test.ts +245 -0
- package/server/navigation-budget.ts +8 -0
- package/server/navigation-commit.ts +125 -0
- package/server/navigation-policy.test.ts +153 -0
- package/server/process-identity.ts +58 -0
- package/server/reattach-held-input.test.ts +143 -0
- package/server/runtime-frame-reuse.test.ts +295 -0
- package/server/runtime-jpeg-demand.test.ts +200 -0
- package/server/runtime-jpeg-shutdown.test.ts +210 -0
- package/server/runtime-jpeg-source-order.test.ts +201 -0
- package/server/runtime-launch-environment.test.ts +111 -0
- package/server/runtime-navigation.test.ts +327 -0
- package/server/runtime-owner-display.test.ts +152 -0
- package/server/runtime-owner.ts +340 -31
- package/server/runtime-page-state.test.ts +249 -0
- package/server/runtime-protocol.ts +31 -1
- package/server/runtime-target-retention.test.ts +181 -0
- package/server/runtime-video-opt-in.test.ts +82 -0
- package/server/runtime-video.test.ts +444 -0
- package/server/screencast-source-time.ts +46 -0
- package/server/stale-wheel-admission.test.ts +136 -0
- package/server/supervisor-client.ts +28 -4
- package/server/supervisor-heartbeat.test.ts +80 -0
- package/server/supervisor.ts +553 -55
- package/server/tab-close-confirmation.test.ts +97 -0
- package/server/video-source-recovery.test.ts +20 -0
- package/server/video-source-recovery.ts +64 -0
- package/server/viewer-capture-lifetime.ts +51 -0
- package/server/virtual-display.test.ts +184 -0
- package/server/virtual-display.ts +236 -0
- package/shared/browser-display-preferences.ts +46 -0
- package/shared/browser-video.ts +83 -0
- package/shared/browser.ts +286 -65
- package/shared/capture-density.ts +16 -0
- package/shared/capture-settings.ts +17 -0
- package/shared/device-presets.ts +90 -0
- package/shared/resolution-menu.test.ts +120 -0
- package/shared/resolution-menu.ts +72 -0
- package/shared/video-settings.test.ts +38 -0
- package/shared/video-settings.ts +34 -0
- package/shared/viewport-limits.ts +5 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/** Fresh metadata reads overlap CDP latency but never accept mixed native identities. */
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { AgentBrowserRuntime } from "./agent-browser-runtime";
|
|
4
|
+
|
|
5
|
+
function fixture() {
|
|
6
|
+
const runtime = new AgentBrowserRuntime({
|
|
7
|
+
binaryPath: "/tmp/unlaunched-browser",
|
|
8
|
+
executablePath: "/tmp/unlaunched-chromium",
|
|
9
|
+
profilePath: "/tmp/uncreated-state-profile",
|
|
10
|
+
ipcDirectory: "/tmp/uncreated-state-ipc",
|
|
11
|
+
session: "state-test",
|
|
12
|
+
});
|
|
13
|
+
const calls: string[] = [];
|
|
14
|
+
const history = Promise.withResolvers<{
|
|
15
|
+
currentIndex: number;
|
|
16
|
+
entries: { url: string; title: string }[];
|
|
17
|
+
}>();
|
|
18
|
+
const metadata = Promise.withResolvers<{
|
|
19
|
+
result: { value?: { url?: string; title?: string } };
|
|
20
|
+
}>();
|
|
21
|
+
const retryHistory = Promise.withResolvers<Awaited<typeof history.promise>>();
|
|
22
|
+
const retryMetadata = Promise.withResolvers<Awaited<typeof metadata.promise>>();
|
|
23
|
+
const timeouts: number[] = [];
|
|
24
|
+
let onRead: (method: string, attempt: number) => void = () => {};
|
|
25
|
+
const page = {
|
|
26
|
+
send: (method: string, _input: unknown, options: { timeoutMs: number }) => {
|
|
27
|
+
const attempt = calls.filter((call) => call === method).length;
|
|
28
|
+
calls.push(method);
|
|
29
|
+
timeouts.push(options.timeoutMs);
|
|
30
|
+
onRead(method, attempt);
|
|
31
|
+
if (method === "Page.getNavigationHistory") {
|
|
32
|
+
return attempt === 0 ? history.promise : retryHistory.promise;
|
|
33
|
+
}
|
|
34
|
+
return attempt === 0 ? metadata.promise : retryMetadata.promise;
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
const control = runtime as unknown as {
|
|
38
|
+
page: typeof page;
|
|
39
|
+
attachmentGeneration: number;
|
|
40
|
+
documentGeneration: number;
|
|
41
|
+
stopping: boolean;
|
|
42
|
+
requirePage(): Promise<typeof page>;
|
|
43
|
+
};
|
|
44
|
+
control.page = page;
|
|
45
|
+
control.requirePage = async () => page;
|
|
46
|
+
return {
|
|
47
|
+
runtime,
|
|
48
|
+
control,
|
|
49
|
+
page,
|
|
50
|
+
calls,
|
|
51
|
+
history,
|
|
52
|
+
metadata,
|
|
53
|
+
retryHistory,
|
|
54
|
+
retryMetadata,
|
|
55
|
+
timeouts,
|
|
56
|
+
intercept: (callback: typeof onRead) => {
|
|
57
|
+
onRead = callback;
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
const entries = [
|
|
62
|
+
{ url: "https://example.test/previous", title: "Previous" },
|
|
63
|
+
{ url: "https://example.test/current", title: "History title" },
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
describe("fresh parallel native page metadata", () => {
|
|
67
|
+
it("discards both old reads and returns one fresh pair after a same-page document change", async () => {
|
|
68
|
+
const f = fixture();
|
|
69
|
+
const pending = f.runtime.state();
|
|
70
|
+
await Promise.resolve();
|
|
71
|
+
f.control.documentGeneration++;
|
|
72
|
+
f.retryHistory.resolve({
|
|
73
|
+
currentIndex: 0,
|
|
74
|
+
entries: [{ url: "https://example.test/new", title: "New history" }],
|
|
75
|
+
});
|
|
76
|
+
f.retryMetadata.resolve({
|
|
77
|
+
result: { value: { url: "https://example.test/new#ready", title: "New DOM" } },
|
|
78
|
+
});
|
|
79
|
+
f.history.resolve({ currentIndex: 1, entries });
|
|
80
|
+
f.metadata.resolve({
|
|
81
|
+
result: { value: { url: "https://example.test/old", title: "Old DOM" } },
|
|
82
|
+
});
|
|
83
|
+
expect(await pending).toEqual({
|
|
84
|
+
url: "https://example.test/new#ready",
|
|
85
|
+
title: "New DOM",
|
|
86
|
+
canGoBack: false,
|
|
87
|
+
canGoForward: false,
|
|
88
|
+
inputGeneration: "0:1",
|
|
89
|
+
});
|
|
90
|
+
expect(f.calls).toEqual([
|
|
91
|
+
"Page.getNavigationHistory",
|
|
92
|
+
"Runtime.evaluate",
|
|
93
|
+
"Page.getNavigationHistory",
|
|
94
|
+
"Runtime.evaluate",
|
|
95
|
+
]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("starts both independent reads before either resolves and retains native history facts", async () => {
|
|
99
|
+
const f = fixture();
|
|
100
|
+
const pending = f.runtime.state();
|
|
101
|
+
await Promise.resolve();
|
|
102
|
+
expect(f.calls).toEqual(["Page.getNavigationHistory", "Runtime.evaluate"]);
|
|
103
|
+
f.metadata.resolve({
|
|
104
|
+
result: { value: { url: "https://example.test/current#hash", title: "Fresh title" } },
|
|
105
|
+
});
|
|
106
|
+
f.history.resolve({ currentIndex: 1, entries });
|
|
107
|
+
expect(await pending).toMatchObject({
|
|
108
|
+
url: "https://example.test/current#hash",
|
|
109
|
+
title: "Fresh title",
|
|
110
|
+
canGoBack: true,
|
|
111
|
+
canGoForward: false,
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
it("preserves existing native-history fallback when DOM values are absent", async () => {
|
|
115
|
+
const f = fixture();
|
|
116
|
+
const pending = f.runtime.state();
|
|
117
|
+
f.metadata.resolve({ result: {} });
|
|
118
|
+
f.history.resolve({ currentIndex: 0, entries });
|
|
119
|
+
expect(await pending).toMatchObject({
|
|
120
|
+
url: entries[0]!.url,
|
|
121
|
+
title: "Previous",
|
|
122
|
+
canGoBack: false,
|
|
123
|
+
canGoForward: true,
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
it("refuses attachment, page replacement or shutdown during either pending observation", async () => {
|
|
127
|
+
for (const kind of ["attachment", "page", "shutdown"] as const) {
|
|
128
|
+
const f = fixture();
|
|
129
|
+
const pending = f.runtime.state();
|
|
130
|
+
await Promise.resolve();
|
|
131
|
+
if (kind === "shutdown") f.control.stopping = true;
|
|
132
|
+
if (kind === "attachment") f.control.attachmentGeneration++;
|
|
133
|
+
if (kind === "page")
|
|
134
|
+
f.control.page = { send: () => Promise.resolve({}) } as unknown as typeof f.page;
|
|
135
|
+
f.history.resolve({ currentIndex: 1, entries });
|
|
136
|
+
f.metadata.resolve({ result: { value: { title: "Obsolete document" } } });
|
|
137
|
+
await expect(pending).rejects.toThrow(
|
|
138
|
+
kind === "shutdown" ? "shutting down" : "metadata changed",
|
|
139
|
+
);
|
|
140
|
+
expect(f.calls).toHaveLength(2);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
it("refuses repeated document changes after exactly two read pairs", async () => {
|
|
144
|
+
const f = fixture();
|
|
145
|
+
f.intercept((method, attempt) => {
|
|
146
|
+
if (method === "Runtime.evaluate" && attempt === 1) f.control.documentGeneration++;
|
|
147
|
+
});
|
|
148
|
+
const pending = f.runtime.state();
|
|
149
|
+
await Promise.resolve();
|
|
150
|
+
f.control.documentGeneration++;
|
|
151
|
+
f.history.resolve({ currentIndex: 1, entries });
|
|
152
|
+
f.metadata.resolve({ result: {} });
|
|
153
|
+
f.retryHistory.resolve({ currentIndex: 0, entries });
|
|
154
|
+
f.retryMetadata.resolve({ result: {} });
|
|
155
|
+
await expect(pending).rejects.toThrow("metadata changed");
|
|
156
|
+
expect(f.calls).toHaveLength(4);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("shares the original timeout budget across both pairs without restarting attachment", async () => {
|
|
160
|
+
const f = fixture();
|
|
161
|
+
let clock = 1_000;
|
|
162
|
+
const monotonic = vi.spyOn(performance, "now").mockImplementation(() => clock);
|
|
163
|
+
try {
|
|
164
|
+
const pending = f.runtime.state({ timeoutMs: 1_000 });
|
|
165
|
+
await Promise.resolve();
|
|
166
|
+
clock += 700;
|
|
167
|
+
f.control.documentGeneration++;
|
|
168
|
+
f.history.resolve({ currentIndex: 1, entries });
|
|
169
|
+
f.metadata.resolve({ result: {} });
|
|
170
|
+
f.retryHistory.resolve({ currentIndex: 0, entries });
|
|
171
|
+
f.retryMetadata.resolve({ result: {} });
|
|
172
|
+
await pending;
|
|
173
|
+
expect(f.timeouts).toHaveLength(4);
|
|
174
|
+
expect(f.timeouts.slice(0, 2).every((timeout) => timeout <= 1_000)).toBe(true);
|
|
175
|
+
expect(f.timeouts.slice(2).every((timeout) => timeout <= 300)).toBe(true);
|
|
176
|
+
} finally {
|
|
177
|
+
monotonic.mockRestore();
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("does not begin a second pair after the original budget expires", async () => {
|
|
182
|
+
const f = fixture();
|
|
183
|
+
let clock = 1_000;
|
|
184
|
+
const monotonic = vi.spyOn(performance, "now").mockImplementation(() => clock);
|
|
185
|
+
try {
|
|
186
|
+
const pending = f.runtime.state({ timeoutMs: 1_000 });
|
|
187
|
+
await Promise.resolve();
|
|
188
|
+
clock += 1_000;
|
|
189
|
+
f.control.documentGeneration++;
|
|
190
|
+
f.history.resolve({ currentIndex: 1, entries });
|
|
191
|
+
f.metadata.resolve({ result: {} });
|
|
192
|
+
await expect(pending).rejects.toThrow("metadata observation timed out");
|
|
193
|
+
expect(f.calls).toHaveLength(2);
|
|
194
|
+
} finally {
|
|
195
|
+
monotonic.mockRestore();
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
for (const phase of ["first", "second"] as const) {
|
|
200
|
+
it(`refuses successful held ${phase} replies at or after the original deadline`, async () => {
|
|
201
|
+
const f = fixture();
|
|
202
|
+
let clock = 1_000;
|
|
203
|
+
const monotonic = vi.spyOn(performance, "now").mockImplementation(() => clock);
|
|
204
|
+
try {
|
|
205
|
+
const secondStarted = Promise.withResolvers<void>();
|
|
206
|
+
f.intercept((method, attempt) => {
|
|
207
|
+
if (method === "Runtime.evaluate" && attempt === 1) secondStarted.resolve();
|
|
208
|
+
});
|
|
209
|
+
const pending = f.runtime.state({ timeoutMs: 1_000 });
|
|
210
|
+
await Promise.resolve();
|
|
211
|
+
if (phase === "second") {
|
|
212
|
+
clock += 700;
|
|
213
|
+
f.control.documentGeneration++;
|
|
214
|
+
f.history.resolve({ currentIndex: 1, entries });
|
|
215
|
+
f.metadata.resolve({ result: {} });
|
|
216
|
+
await secondStarted.promise;
|
|
217
|
+
expect(f.calls).toHaveLength(4);
|
|
218
|
+
clock += 301;
|
|
219
|
+
f.retryHistory.resolve({ currentIndex: 0, entries });
|
|
220
|
+
f.retryMetadata.resolve({ result: { value: { title: "Late fresh metadata" } } });
|
|
221
|
+
} else {
|
|
222
|
+
clock += 1_000;
|
|
223
|
+
f.history.resolve({ currentIndex: 1, entries });
|
|
224
|
+
f.metadata.resolve({ result: { value: { title: "Late metadata" } } });
|
|
225
|
+
}
|
|
226
|
+
await expect(pending).rejects.toThrow("metadata observation timed out");
|
|
227
|
+
expect(f.calls).toHaveLength(phase === "second" ? 4 : 2);
|
|
228
|
+
} finally {
|
|
229
|
+
monotonic.mockRestore();
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
it("propagates failed native reads rather than reusing earlier metadata", async () => {
|
|
235
|
+
for (const failed of ["history", "metadata"]) {
|
|
236
|
+
const f = fixture();
|
|
237
|
+
const pending = f.runtime.state();
|
|
238
|
+
const refusal = expect(pending).rejects.toThrow("Read unavailable");
|
|
239
|
+
await Promise.resolve();
|
|
240
|
+
f.control.documentGeneration++;
|
|
241
|
+
if (failed === "history") f.history.reject(new Error("Read unavailable"));
|
|
242
|
+
else f.history.resolve({ currentIndex: 1, entries });
|
|
243
|
+
if (failed === "metadata") f.metadata.reject(new Error("Read unavailable"));
|
|
244
|
+
else f.metadata.resolve({ result: {} });
|
|
245
|
+
await refusal;
|
|
246
|
+
expect(f.calls).toHaveLength(2);
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
});
|
|
@@ -36,7 +36,11 @@ export type AgentBrowserOperation =
|
|
|
36
36
|
| "release-control"
|
|
37
37
|
| "navigate"
|
|
38
38
|
| "input"
|
|
39
|
-
| "viewport"
|
|
39
|
+
| "viewport"
|
|
40
|
+
| "tabs.list"
|
|
41
|
+
| "tabs.create"
|
|
42
|
+
| "tabs.select"
|
|
43
|
+
| "tabs.close";
|
|
40
44
|
|
|
41
45
|
export type RuntimeRequest =
|
|
42
46
|
| (AdminRequestBase & { method: "bridge.claim"; takeover?: boolean })
|
|
@@ -58,6 +62,12 @@ export type RuntimeRequest =
|
|
|
58
62
|
epoch: number;
|
|
59
63
|
workspaceId: string;
|
|
60
64
|
})
|
|
65
|
+
| (AdminRequestBase & {
|
|
66
|
+
method: "workspace.close";
|
|
67
|
+
epoch: number;
|
|
68
|
+
workspaceId: string;
|
|
69
|
+
runtimeId: string;
|
|
70
|
+
})
|
|
61
71
|
| (AdminRequestBase & {
|
|
62
72
|
method: "browser.request";
|
|
63
73
|
epoch: number;
|
|
@@ -107,6 +117,7 @@ export type RuntimeErrorCode =
|
|
|
107
117
|
| "WORKSPACE_NOT_FOUND"
|
|
108
118
|
| "RUNTIME_BUSY"
|
|
109
119
|
| "UNKNOWN_OUTCOME"
|
|
120
|
+
| "RUNTIME_LOST"
|
|
110
121
|
| "RUNTIME_FAILURE";
|
|
111
122
|
|
|
112
123
|
export class RuntimeProtocolError extends Error {
|
|
@@ -119,6 +130,14 @@ export class RuntimeProtocolError extends Error {
|
|
|
119
130
|
}
|
|
120
131
|
}
|
|
121
132
|
|
|
133
|
+
/** The owned runtime can never serve again; the next explicit attach must replace it. */
|
|
134
|
+
export class RuntimeLostError extends Error {
|
|
135
|
+
constructor(message: string) {
|
|
136
|
+
super(message);
|
|
137
|
+
this.name = "RuntimeLostError";
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
122
141
|
export function isRuntimeResponse(value: unknown): value is RuntimeResponse {
|
|
123
142
|
if (!isRecord(value) || typeof value.id !== "string" || typeof value.ok !== "boolean")
|
|
124
143
|
return false;
|
|
@@ -183,6 +202,17 @@ export function parseRuntimeRequest(value: unknown): RuntimeRequest {
|
|
|
183
202
|
epoch,
|
|
184
203
|
workspaceId: requireString(value, "workspaceId"),
|
|
185
204
|
};
|
|
205
|
+
if (method === "workspace.close")
|
|
206
|
+
return {
|
|
207
|
+
id,
|
|
208
|
+
version: RUNTIME_PROTOCOL_VERSION,
|
|
209
|
+
method,
|
|
210
|
+
token,
|
|
211
|
+
bridgeId,
|
|
212
|
+
epoch,
|
|
213
|
+
workspaceId: requireString(value, "workspaceId"),
|
|
214
|
+
runtimeId: requireString(value, "runtimeId"),
|
|
215
|
+
};
|
|
186
216
|
if (method === "workspace.request")
|
|
187
217
|
return {
|
|
188
218
|
id,
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/** Exercise real target attachment/reconnect methods without native sockets or profiles. */
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { AgentBrowserRuntime } from "./agent-browser-runtime";
|
|
5
|
+
import { CdpConnection, type CdpSession } from "./cdp";
|
|
6
|
+
|
|
7
|
+
class ConnectionFixture extends EventEmitter {
|
|
8
|
+
isOpen = true;
|
|
9
|
+
nextSession = 0;
|
|
10
|
+
readonly calls: { method: string; params: Record<string, unknown> }[] = [];
|
|
11
|
+
constructor(readonly targetIds: string[]) {
|
|
12
|
+
super();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async send<T>(method: string, params: Record<string, unknown> = {}): Promise<T> {
|
|
16
|
+
this.calls.push({ method, params });
|
|
17
|
+
if (method === "Target.getTargets")
|
|
18
|
+
return {
|
|
19
|
+
targetInfos: this.targetIds.map((targetId) => ({
|
|
20
|
+
targetId,
|
|
21
|
+
type: "page",
|
|
22
|
+
url: "about:blank",
|
|
23
|
+
title: targetId,
|
|
24
|
+
})),
|
|
25
|
+
} as T;
|
|
26
|
+
if (method === "Target.attachToTarget")
|
|
27
|
+
return { sessionId: `fixture-${++this.nextSession}` } as T;
|
|
28
|
+
if (method === "Page.getNavigationHistory") return { currentIndex: 0, entries: [] } as T;
|
|
29
|
+
if (method === "Page.getFrameTree") return { frameTree: { frame: { id: "root" } } } as T;
|
|
30
|
+
return {} as T;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
close(): void {
|
|
34
|
+
this.isOpen = false;
|
|
35
|
+
this.emit("disconnect");
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface RuntimeControl {
|
|
40
|
+
invoke(args: string[]): Promise<unknown>;
|
|
41
|
+
assertVersion(): Promise<void>;
|
|
42
|
+
connectCdp(targetId?: string | null): Promise<void>;
|
|
43
|
+
reattachPageForScreencast(previous: CdpSession): Promise<CdpSession>;
|
|
44
|
+
connection: CdpConnection;
|
|
45
|
+
targetId: string | null;
|
|
46
|
+
page: CdpSession | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Stub only binary discovery and CDP transport, preserving runtime selection policy. */
|
|
50
|
+
function fixture(targetLists: string[][]) {
|
|
51
|
+
const connections = targetLists.map((ids) => new ConnectionFixture(ids));
|
|
52
|
+
let next = 0;
|
|
53
|
+
vi.spyOn(CdpConnection, "connect").mockImplementation(async () => {
|
|
54
|
+
const connection = connections[next++];
|
|
55
|
+
if (!connection) throw new Error("Unexpected reconnect");
|
|
56
|
+
return connection as unknown as CdpConnection;
|
|
57
|
+
});
|
|
58
|
+
const runtime = new AgentBrowserRuntime({
|
|
59
|
+
binaryPath: "/tmp/unlaunched-browser",
|
|
60
|
+
executablePath: "/tmp/unlaunched-chromium",
|
|
61
|
+
profilePath: "/tmp/uncreated-profile",
|
|
62
|
+
ipcDirectory: "/tmp/uncreated-ipc",
|
|
63
|
+
session: "target-retention-test",
|
|
64
|
+
});
|
|
65
|
+
const control = runtime as unknown as RuntimeControl;
|
|
66
|
+
control.invoke = vi.fn(async () => ({ cdpUrl: "ws://127.0.0.1:1/fixture" }));
|
|
67
|
+
control.assertVersion = async () => {};
|
|
68
|
+
return { runtime, control, connections };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
afterEach(() => vi.restoreAllMocks());
|
|
72
|
+
describe("exact selected target retention", () => {
|
|
73
|
+
it("permits initial selection when no page has ever been selected", async () => {
|
|
74
|
+
const { control } = fixture([["first", "second"]]);
|
|
75
|
+
await control.connectCdp();
|
|
76
|
+
expect(control.targetId).toBe("first");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("retains the selected surviving page across disconnect instead of adopting the first tab", async () => {
|
|
80
|
+
const { runtime, control, connections } = fixture([
|
|
81
|
+
["other", "original"],
|
|
82
|
+
["other", "original"],
|
|
83
|
+
]);
|
|
84
|
+
await control.connectCdp("original");
|
|
85
|
+
connections[0]!.close();
|
|
86
|
+
expect(control.page).toBeNull();
|
|
87
|
+
expect(control.targetId).toBe("original");
|
|
88
|
+
await runtime.reconnect();
|
|
89
|
+
expect(control.page?.targetId).toBe("original");
|
|
90
|
+
expect(
|
|
91
|
+
connections[1]!.calls
|
|
92
|
+
.filter((call) => call.method === "Target.activateTarget")
|
|
93
|
+
.map((call) => call.params.targetId),
|
|
94
|
+
).toEqual(["original"]);
|
|
95
|
+
expect(control.invoke).toHaveBeenCalledWith([
|
|
96
|
+
"--session",
|
|
97
|
+
"target-retention-test",
|
|
98
|
+
"--json",
|
|
99
|
+
"get",
|
|
100
|
+
"cdp-url",
|
|
101
|
+
]);
|
|
102
|
+
expect(
|
|
103
|
+
connections
|
|
104
|
+
.flatMap((connection) => connection.calls)
|
|
105
|
+
.some((call) => call.method === "Page.navigate"),
|
|
106
|
+
).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("refuses a missing selected page on every reconnect without adopting another tab", async () => {
|
|
110
|
+
const { runtime, control, connections } = fixture([["original"], ["other"], ["other"]]);
|
|
111
|
+
await control.connectCdp("original");
|
|
112
|
+
connections[0]!.close();
|
|
113
|
+
await expect(runtime.reconnect()).rejects.toThrow(
|
|
114
|
+
"selected browser page is no longer available",
|
|
115
|
+
);
|
|
116
|
+
await expect(runtime.reconnect()).rejects.toThrow(
|
|
117
|
+
"selected browser page is no longer available",
|
|
118
|
+
);
|
|
119
|
+
expect(control.targetId).toBe("original");
|
|
120
|
+
expect(control.page).toBeNull();
|
|
121
|
+
expect(
|
|
122
|
+
connections
|
|
123
|
+
.slice(1)
|
|
124
|
+
.flatMap((connection) => connection.calls)
|
|
125
|
+
.some(
|
|
126
|
+
(call) =>
|
|
127
|
+
call.method === "Target.attachToTarget" || call.method === "Target.activateTarget",
|
|
128
|
+
),
|
|
129
|
+
).toBe(false);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("requires an explicit initial target rather than replacing a missing launch target", async () => {
|
|
133
|
+
const { runtime, control, connections } = fixture([["other"], ["other"]]);
|
|
134
|
+
await expect(control.connectCdp("missing")).rejects.toThrow(
|
|
135
|
+
"selected browser page is no longer available",
|
|
136
|
+
);
|
|
137
|
+
expect(control.targetId).toBe("missing");
|
|
138
|
+
await expect(runtime.reconnect()).rejects.toThrow(
|
|
139
|
+
"selected browser page is no longer available",
|
|
140
|
+
);
|
|
141
|
+
expect(control.page).toBeNull();
|
|
142
|
+
expect(
|
|
143
|
+
connections
|
|
144
|
+
.flatMap((connection) => connection.calls)
|
|
145
|
+
.some((call) => call.method === "Target.activateTarget"),
|
|
146
|
+
).toBe(false);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("retains a newly explicitly selected page across later disconnects", async () => {
|
|
150
|
+
const { runtime, control, connections } = fixture([
|
|
151
|
+
["first", "chosen"],
|
|
152
|
+
["first", "chosen"],
|
|
153
|
+
]);
|
|
154
|
+
await control.connectCdp();
|
|
155
|
+
await runtime.selectTarget("chosen");
|
|
156
|
+
connections[0]!.close();
|
|
157
|
+
await runtime.reconnect();
|
|
158
|
+
expect(control.page?.targetId).toBe("chosen");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("refuses screencast reattachment when its exact former page disappeared", async () => {
|
|
162
|
+
const { control, connections } = fixture([["original", "other"]]);
|
|
163
|
+
await control.connectCdp("original");
|
|
164
|
+
const previous = control.page!;
|
|
165
|
+
connections[0]!.targetIds.splice(0, 1);
|
|
166
|
+
const before = connections[0]!.calls.length;
|
|
167
|
+
await expect(control.reattachPageForScreencast(previous)).rejects.toThrow(
|
|
168
|
+
"selected browser page is no longer available",
|
|
169
|
+
);
|
|
170
|
+
expect(control.page).toBe(previous);
|
|
171
|
+
expect(control.targetId).toBe("original");
|
|
172
|
+
expect(
|
|
173
|
+
connections[0]!.calls
|
|
174
|
+
.slice(before)
|
|
175
|
+
.some(
|
|
176
|
+
(call) =>
|
|
177
|
+
call.method === "Target.activateTarget" || call.method === "Target.attachToTarget",
|
|
178
|
+
),
|
|
179
|
+
).toBe(false);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/** Encoded video is host-opt-in: image-only sessions never load the capture extension. */
|
|
2
|
+
|
|
3
|
+
import { mkdtemp, readdir, rm } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
7
|
+
import { AgentBrowserRuntime } from "./agent-browser-runtime";
|
|
8
|
+
|
|
9
|
+
// The owned CLI is simulated at the process boundary, as in daemon-shutdown.test.ts: a
|
|
10
|
+
// POSIX-shebang script is not executable on Windows, so no executable file is required.
|
|
11
|
+
vi.mock("node:child_process", async (importOriginal) => ({
|
|
12
|
+
...(await importOriginal<typeof import("node:child_process")>()),
|
|
13
|
+
execFile: (
|
|
14
|
+
_file: string,
|
|
15
|
+
_args: string[],
|
|
16
|
+
_options: unknown,
|
|
17
|
+
callback: (error: null, result: { stdout: string }) => void,
|
|
18
|
+
) => callback(null, { stdout: "0.38.2\n" }),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
const temporary: string[] = [];
|
|
22
|
+
afterEach(async () => {
|
|
23
|
+
await Promise.all(temporary.splice(0).map((path) => rm(path, { recursive: true, force: true })));
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
async function fixture(nativeVideo?: boolean) {
|
|
27
|
+
const directory = await mkdtemp(join(tmpdir(), "shared-browser-video-opt-in-"));
|
|
28
|
+
temporary.push(directory);
|
|
29
|
+
const binary = join(directory, "owned-cli");
|
|
30
|
+
const ipcDirectory = join(directory, "ipc");
|
|
31
|
+
const runtime = new AgentBrowserRuntime({
|
|
32
|
+
binaryPath: binary,
|
|
33
|
+
executablePath: join(directory, "unlaunched-chromium"),
|
|
34
|
+
profilePath: join(directory, "profile"),
|
|
35
|
+
ipcDirectory,
|
|
36
|
+
session: "video-opt-in",
|
|
37
|
+
...(nativeVideo === undefined ? {} : { nativeVideo }),
|
|
38
|
+
});
|
|
39
|
+
const internal = runtime as unknown as {
|
|
40
|
+
invoke(args: string[]): Promise<unknown>;
|
|
41
|
+
video: unknown;
|
|
42
|
+
};
|
|
43
|
+
const launches: string[][] = [];
|
|
44
|
+
internal.invoke = async (args) => {
|
|
45
|
+
launches.push(args);
|
|
46
|
+
throw new Error("Owned fixture stops before a browser exists");
|
|
47
|
+
};
|
|
48
|
+
return { runtime, internal, launches, ipcDirectory };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("encoded video opt-in", () => {
|
|
52
|
+
it("launches image-only sessions without the extension, its allowlist or any materialized files", async () => {
|
|
53
|
+
for (const option of [undefined, false]) {
|
|
54
|
+
const f = await fixture(option);
|
|
55
|
+
await expect(f.runtime.launch()).rejects.toThrow("stops before a browser");
|
|
56
|
+
const [args] = f.launches;
|
|
57
|
+
expect(args).not.toContain("--extension");
|
|
58
|
+
expect(args?.join(" ")).not.toContain("allowlisted-extension-id");
|
|
59
|
+
expect(await readdir(f.ipcDirectory)).toEqual([]);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("loads and allowlists the capture extension only when the host opted in", async () => {
|
|
64
|
+
const f = await fixture(true);
|
|
65
|
+
await expect(f.runtime.launch()).rejects.toThrow("stops before a browser");
|
|
66
|
+
const [args] = f.launches;
|
|
67
|
+
expect(args).toContain("--extension");
|
|
68
|
+
expect(args?.join(" ")).toContain("--allowlisted-extension-id=");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("reports a typed disabled reply without creating any capture source", async () => {
|
|
72
|
+
const f = await fixture();
|
|
73
|
+
const reply = await f.runtime.readVideo({ quality: "high" });
|
|
74
|
+
expect(reply).toMatchObject({
|
|
75
|
+
status: "unsupported",
|
|
76
|
+
reasonCode: "video-disabled",
|
|
77
|
+
streamId: null,
|
|
78
|
+
packets: [],
|
|
79
|
+
});
|
|
80
|
+
expect(f.internal.video).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
});
|