@omercnet/paseo-shared-browser 1.2.2 → 2.0.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.
- 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,327 @@
|
|
|
1
|
+
/** Real navigation methods, fake CDP only; main commit is not DOM readiness. */
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import { afterEach, expect, it, vi } from "vitest";
|
|
4
|
+
import { AgentBrowserRuntime } from "./agent-browser-runtime";
|
|
5
|
+
import { CdpUnavailableError, CdpUnknownOutcomeError } from "./cdp";
|
|
6
|
+
import {
|
|
7
|
+
NAVIGATION_ACK_TIMEOUT_MS,
|
|
8
|
+
NAVIGATION_COMMIT_TIMEOUT_MS,
|
|
9
|
+
NAVIGATION_METADATA_TIMEOUT_MS,
|
|
10
|
+
} from "./navigation-budget";
|
|
11
|
+
|
|
12
|
+
function fixture() {
|
|
13
|
+
const connection = Object.assign(new EventEmitter(), { isOpen: true });
|
|
14
|
+
const page = Object.assign(new EventEmitter(), {
|
|
15
|
+
connection,
|
|
16
|
+
send: vi.fn<
|
|
17
|
+
(
|
|
18
|
+
method: string,
|
|
19
|
+
params?: Record<string, unknown>,
|
|
20
|
+
options?: { timeoutMs?: number; mutation?: boolean },
|
|
21
|
+
) => Promise<unknown>
|
|
22
|
+
>(),
|
|
23
|
+
});
|
|
24
|
+
const observe = async (method: string): Promise<unknown> => {
|
|
25
|
+
if (method === "Page.getNavigationHistory")
|
|
26
|
+
return {
|
|
27
|
+
currentIndex: 1,
|
|
28
|
+
entries: [
|
|
29
|
+
{ id: 1, url: "https://previous.invalid/" },
|
|
30
|
+
{ id: 2, url: "https://current.invalid/" },
|
|
31
|
+
{ id: 3, url: "https://next.invalid/" },
|
|
32
|
+
],
|
|
33
|
+
};
|
|
34
|
+
if (method === "Page.getFrameTree")
|
|
35
|
+
return { frameTree: { frame: { id: "root", loaderId: "old-loader" } } };
|
|
36
|
+
if (method === "Runtime.evaluate")
|
|
37
|
+
return { result: { value: { url: "https://next.invalid/", title: "Loading" } } };
|
|
38
|
+
return {};
|
|
39
|
+
};
|
|
40
|
+
const commit = (
|
|
41
|
+
url = "https://next.invalid/",
|
|
42
|
+
loaderId = "next-loader",
|
|
43
|
+
frameId = "root",
|
|
44
|
+
parentId?: string,
|
|
45
|
+
): void => {
|
|
46
|
+
page.emit("Page.frameNavigated", {
|
|
47
|
+
frame: { id: frameId, loaderId, url, ...(parentId ? { parentId } : {}) },
|
|
48
|
+
});
|
|
49
|
+
};
|
|
50
|
+
page.send.mockImplementation(async (method, params) => {
|
|
51
|
+
if (method === "Page.navigate") {
|
|
52
|
+
commit(params!.url as string);
|
|
53
|
+
return { frameId: "root", loaderId: "next-loader" };
|
|
54
|
+
}
|
|
55
|
+
if (method === "Page.navigateToHistoryEntry") {
|
|
56
|
+
commit(params!.entryId === 1 ? "https://previous.invalid/" : "https://next.invalid/");
|
|
57
|
+
return {};
|
|
58
|
+
}
|
|
59
|
+
if (method === "Page.reload") {
|
|
60
|
+
commit("https://current.invalid/");
|
|
61
|
+
return {};
|
|
62
|
+
}
|
|
63
|
+
return observe(method);
|
|
64
|
+
});
|
|
65
|
+
const runtime = new AgentBrowserRuntime({
|
|
66
|
+
binaryPath: "/tmp/unlaunched-browser",
|
|
67
|
+
executablePath: "/tmp/unlaunched-chromium",
|
|
68
|
+
profilePath: "/tmp/uncreated-profile",
|
|
69
|
+
ipcDirectory: "/tmp/uncreated-ipc",
|
|
70
|
+
session: "navigation-budget-test",
|
|
71
|
+
});
|
|
72
|
+
Object.assign(runtime, { page, connection, emulationAppliedPage: page, screencastActive: true });
|
|
73
|
+
return { runtime, page, connection, commit, observe };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function expectClean(page: EventEmitter, connection: EventEmitter): void {
|
|
77
|
+
for (const event of [
|
|
78
|
+
"Page.frameNavigated",
|
|
79
|
+
"Page.navigatedWithinDocument",
|
|
80
|
+
"Inspector.detached",
|
|
81
|
+
]) {
|
|
82
|
+
expect(page.listenerCount(event)).toBe(0);
|
|
83
|
+
}
|
|
84
|
+
expect(connection.listenerCount("disconnect")).toBe(0);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
afterEach(() => {
|
|
88
|
+
vi.restoreAllMocks();
|
|
89
|
+
vi.useRealTimers();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("buffers the exact root loader commit before ACK without DOM readiness or stream restart", async () => {
|
|
93
|
+
const { runtime, page, connection } = fixture();
|
|
94
|
+
const barrier = vi.spyOn(runtime, "invalidateQueuedVideoFrames");
|
|
95
|
+
await runtime.navigate("https://next.invalid/");
|
|
96
|
+
expect(page.send).toHaveBeenCalledExactlyOnceWith(
|
|
97
|
+
"Page.navigate",
|
|
98
|
+
{ url: "https://next.invalid/" },
|
|
99
|
+
{ mutation: true, timeoutMs: NAVIGATION_ACK_TIMEOUT_MS },
|
|
100
|
+
);
|
|
101
|
+
expect(page.listenerCount("Page.lifecycleEvent")).toBe(0);
|
|
102
|
+
expect(barrier).toHaveBeenCalledTimes(1);
|
|
103
|
+
expectClean(page, connection);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("waits for commit after ACK while child frames, other roots and old loaders cannot settle it", async () => {
|
|
107
|
+
const { runtime, page, connection, commit } = fixture();
|
|
108
|
+
page.send.mockResolvedValue({ frameId: "root", loaderId: "next-loader" });
|
|
109
|
+
let settled = false;
|
|
110
|
+
const result = runtime.navigate("https://next.invalid/").then(() => {
|
|
111
|
+
settled = true;
|
|
112
|
+
});
|
|
113
|
+
await vi.waitFor(() => expect(page.send).toHaveBeenCalledTimes(1));
|
|
114
|
+
commit("https://next.invalid/", "next-loader", "child", "root");
|
|
115
|
+
commit("https://next.invalid/", "next-loader", "other-root");
|
|
116
|
+
commit("https://next.invalid/", "old-loader");
|
|
117
|
+
await Promise.resolve();
|
|
118
|
+
expect(settled).toBe(false);
|
|
119
|
+
commit();
|
|
120
|
+
await result;
|
|
121
|
+
expectClean(page, connection);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it.each(["back", "forward", "reload"] as const)(
|
|
125
|
+
"%s awaits a fresh main commit within bounded preflight/ACK budgets",
|
|
126
|
+
async (action) => {
|
|
127
|
+
const { runtime, page, connection } = fixture();
|
|
128
|
+
await runtime[action]();
|
|
129
|
+
const mutation = page.send.mock.calls.find(
|
|
130
|
+
([method]) => method === "Page.navigateToHistoryEntry" || method === "Page.reload",
|
|
131
|
+
);
|
|
132
|
+
expect(mutation?.[2]).toEqual({ mutation: true, timeoutMs: NAVIGATION_ACK_TIMEOUT_MS });
|
|
133
|
+
for (const [method, , options] of page.send.mock.calls) {
|
|
134
|
+
if (method === "Page.getFrameTree" || method === "Page.getNavigationHistory")
|
|
135
|
+
expect(options!.timeoutMs).toBeLessThanOrEqual(NAVIGATION_METADATA_TIMEOUT_MS);
|
|
136
|
+
expect(/Screencast|Lifecycle/.test(method)).toBe(false);
|
|
137
|
+
}
|
|
138
|
+
expectClean(page, connection);
|
|
139
|
+
},
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
it("same-document navigation requires the acknowledged frame and normalized requested URL", async () => {
|
|
143
|
+
const { runtime, page, connection } = fixture();
|
|
144
|
+
page.send.mockImplementation(async () => {
|
|
145
|
+
page.emit("Page.navigatedWithinDocument", {
|
|
146
|
+
frameId: "child",
|
|
147
|
+
url: "https://next.invalid/#section",
|
|
148
|
+
});
|
|
149
|
+
page.emit("Page.navigatedWithinDocument", {
|
|
150
|
+
frameId: "root",
|
|
151
|
+
url: "https://next.invalid/#wrong",
|
|
152
|
+
});
|
|
153
|
+
page.emit("Page.navigatedWithinDocument", {
|
|
154
|
+
frameId: "root",
|
|
155
|
+
url: "https://next.invalid/#section",
|
|
156
|
+
});
|
|
157
|
+
return { frameId: "root" };
|
|
158
|
+
});
|
|
159
|
+
await runtime.navigate("https://next.invalid:443/#section");
|
|
160
|
+
expectClean(page, connection);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("history same-document commits select the exact history URL", async () => {
|
|
164
|
+
const { runtime, page, connection, observe } = fixture();
|
|
165
|
+
page.send.mockImplementation(async (method) => {
|
|
166
|
+
if (method === "Page.navigateToHistoryEntry") {
|
|
167
|
+
page.emit("Page.navigatedWithinDocument", {
|
|
168
|
+
frameId: "root",
|
|
169
|
+
url: "https://previous.invalid/",
|
|
170
|
+
});
|
|
171
|
+
return {};
|
|
172
|
+
}
|
|
173
|
+
return observe(method);
|
|
174
|
+
});
|
|
175
|
+
await runtime.back();
|
|
176
|
+
expectClean(page, connection);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it.each(["back", "forward"] as const)(
|
|
180
|
+
"%s matches a cross-document history URL whose fragment is a separate native field",
|
|
181
|
+
async (action) => {
|
|
182
|
+
const { runtime, page, connection, observe } = fixture();
|
|
183
|
+
page.send.mockImplementation(async (method) => {
|
|
184
|
+
if (method === "Page.getNavigationHistory") {
|
|
185
|
+
return {
|
|
186
|
+
currentIndex: 1,
|
|
187
|
+
entries: [
|
|
188
|
+
{ id: 1, url: "https://previous.invalid/#section" },
|
|
189
|
+
{ id: 2, url: "https://current.invalid/" },
|
|
190
|
+
{ id: 3, url: "https://next.invalid/#section" },
|
|
191
|
+
],
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
if (method === "Page.navigateToHistoryEntry") {
|
|
195
|
+
page.emit("Page.frameNavigated", {
|
|
196
|
+
frame: {
|
|
197
|
+
id: "root",
|
|
198
|
+
loaderId: "next-loader",
|
|
199
|
+
url: action === "back" ? "https://previous.invalid/" : "https://next.invalid/",
|
|
200
|
+
urlFragment: "#section",
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
return {};
|
|
204
|
+
}
|
|
205
|
+
return observe(method);
|
|
206
|
+
});
|
|
207
|
+
await runtime[action]();
|
|
208
|
+
expect(
|
|
209
|
+
page.send.mock.calls.filter(([method]) => method === "Page.navigateToHistoryEntry"),
|
|
210
|
+
).toHaveLength(1);
|
|
211
|
+
expectClean(page, connection);
|
|
212
|
+
},
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
it.each([undefined, "net::ERR_ABORTED"])(
|
|
216
|
+
"a native download ACK keeps the document even with errorText=%s",
|
|
217
|
+
async (errorText) => {
|
|
218
|
+
const { runtime, page, connection } = fixture();
|
|
219
|
+
page.send.mockResolvedValue({
|
|
220
|
+
frameId: "root",
|
|
221
|
+
isDownload: true,
|
|
222
|
+
...(errorText ? { errorText } : {}),
|
|
223
|
+
});
|
|
224
|
+
await runtime.navigate("https://next.invalid/download");
|
|
225
|
+
expect(page.send).toHaveBeenCalledTimes(1);
|
|
226
|
+
expectClean(page, connection);
|
|
227
|
+
},
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
it("a detached download ACK cannot bypass the original attachment guard", async () => {
|
|
231
|
+
const { runtime, page, connection } = fixture();
|
|
232
|
+
page.send.mockImplementation(async () => {
|
|
233
|
+
connection.isOpen = false;
|
|
234
|
+
connection.emit("disconnect");
|
|
235
|
+
return { frameId: "root", isDownload: true, errorText: "net::ERR_ABORTED" };
|
|
236
|
+
});
|
|
237
|
+
await expect(runtime.navigate("https://next.invalid/download")).rejects.toBeInstanceOf(
|
|
238
|
+
CdpUnknownOutcomeError,
|
|
239
|
+
);
|
|
240
|
+
expect(page.send).toHaveBeenCalledTimes(1);
|
|
241
|
+
expectClean(page, connection);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("missing matching commit after ACK is bounded uncertainty with no replay", async () => {
|
|
245
|
+
vi.useFakeTimers();
|
|
246
|
+
const { runtime, page, connection, commit } = fixture();
|
|
247
|
+
page.send.mockImplementation(async () => {
|
|
248
|
+
commit("https://next.invalid/", "old-loader");
|
|
249
|
+
return { frameId: "root", loaderId: "next-loader" };
|
|
250
|
+
});
|
|
251
|
+
const result = runtime.navigate("https://next.invalid/").catch((error: unknown) => error);
|
|
252
|
+
await vi.advanceTimersByTimeAsync(NAVIGATION_COMMIT_TIMEOUT_MS);
|
|
253
|
+
expect(await result).toBeInstanceOf(CdpUnknownOutcomeError);
|
|
254
|
+
expect(page.send).toHaveBeenCalledTimes(1);
|
|
255
|
+
expectClean(page, connection);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it.each(["disconnect", "replacement"] as const)(
|
|
259
|
+
"%s during ACK-to-commit cannot admit late old-page events",
|
|
260
|
+
async (kind) => {
|
|
261
|
+
const { runtime, page, connection, commit } = fixture();
|
|
262
|
+
page.send.mockResolvedValue({ frameId: "root", loaderId: "next-loader" });
|
|
263
|
+
const result = runtime.navigate("https://next.invalid/").catch((error: unknown) => error);
|
|
264
|
+
await vi.waitFor(() => expect(page.send).toHaveBeenCalledTimes(1));
|
|
265
|
+
if (kind === "disconnect") connection.emit("disconnect");
|
|
266
|
+
else Object.assign(runtime, { page: new EventEmitter(), attachmentGeneration: 1 });
|
|
267
|
+
commit();
|
|
268
|
+
expect(await result).toBeInstanceOf(CdpUnknownOutcomeError);
|
|
269
|
+
expectClean(page, connection);
|
|
270
|
+
},
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
it("unacknowledged timeout remains uncertain and cleans observers without replay", async () => {
|
|
274
|
+
vi.useFakeTimers();
|
|
275
|
+
const { runtime, page, connection } = fixture();
|
|
276
|
+
page.send.mockImplementation(
|
|
277
|
+
async (_method, _params, options) =>
|
|
278
|
+
await new Promise((_resolve, reject) =>
|
|
279
|
+
setTimeout(
|
|
280
|
+
() =>
|
|
281
|
+
reject(
|
|
282
|
+
new CdpUnknownOutcomeError("Page.navigate timed out; mutation outcome is unknown"),
|
|
283
|
+
),
|
|
284
|
+
options!.timeoutMs,
|
|
285
|
+
),
|
|
286
|
+
),
|
|
287
|
+
);
|
|
288
|
+
const result = runtime.navigate("https://next.invalid/").catch((error: unknown) => error);
|
|
289
|
+
await vi.advanceTimersByTimeAsync(NAVIGATION_ACK_TIMEOUT_MS);
|
|
290
|
+
expect(await result).toBeInstanceOf(CdpUnknownOutcomeError);
|
|
291
|
+
expect(page.send).toHaveBeenCalledTimes(1);
|
|
292
|
+
expectClean(page, connection);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("an explicit native failure is not relabelled success", async () => {
|
|
296
|
+
const { runtime, page, connection } = fixture();
|
|
297
|
+
page.send.mockResolvedValue({ errorText: "net::ERR_NAME_NOT_RESOLVED" });
|
|
298
|
+
await expect(runtime.navigate("https://next.invalid/")).rejects.toThrow("Navigation failed");
|
|
299
|
+
expectClean(page, connection);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("failed history preflight blocks mutation", async () => {
|
|
303
|
+
const { runtime, page } = fixture();
|
|
304
|
+
page.send.mockRejectedValue(new CdpUnavailableError("history unavailable"));
|
|
305
|
+
await expect(runtime.back()).rejects.toThrow("history unavailable");
|
|
306
|
+
expect(
|
|
307
|
+
page.send.mock.calls.every(
|
|
308
|
+
([method]) => method === "Page.getNavigationHistory" || method === "Page.getFrameTree",
|
|
309
|
+
),
|
|
310
|
+
).toBe(true);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("navigation metadata is bounded while ordinary state retains its default", async () => {
|
|
314
|
+
const { runtime, page } = fixture();
|
|
315
|
+
await runtime.state({ timeoutMs: NAVIGATION_METADATA_TIMEOUT_MS });
|
|
316
|
+
expect(
|
|
317
|
+
page.send.mock.calls.every(
|
|
318
|
+
([, , options]) => options!.timeoutMs! <= NAVIGATION_METADATA_TIMEOUT_MS,
|
|
319
|
+
),
|
|
320
|
+
).toBe(true);
|
|
321
|
+
page.send.mockClear();
|
|
322
|
+
await runtime.state();
|
|
323
|
+
expect(
|
|
324
|
+
page.send.mock.calls.find(([method]) => method === "Runtime.evaluate")?.[2]?.timeoutMs,
|
|
325
|
+
).toBe(15_000);
|
|
326
|
+
await expect(runtime.state({ timeoutMs: 0 })).rejects.toThrow("metadata timeout");
|
|
327
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
|
2
|
+
import { createRuntimeOwner } from "./runtime-owner";
|
|
3
|
+
import { RuntimeLostError } from "./runtime-protocol";
|
|
4
|
+
import { PrivateDisplayError } from "./virtual-display";
|
|
5
|
+
|
|
6
|
+
const fake = vi.hoisted(() => ({
|
|
7
|
+
display: vi.fn(),
|
|
8
|
+
runtime: vi.fn(),
|
|
9
|
+
launch: vi.fn(),
|
|
10
|
+
shutdown: vi.fn(),
|
|
11
|
+
assertAvailable: vi.fn(),
|
|
12
|
+
stop: vi.fn(),
|
|
13
|
+
}));
|
|
14
|
+
vi.mock("./virtual-display", async (importOriginal) => ({
|
|
15
|
+
...(await importOriginal<typeof import("./virtual-display")>()),
|
|
16
|
+
createPrivateVirtualDisplay: fake.display,
|
|
17
|
+
}));
|
|
18
|
+
vi.mock("./runtime-path", () => ({
|
|
19
|
+
resolveBrowserRuntimeRoot: () => "/tmp/owned-runtime-fixture",
|
|
20
|
+
}));
|
|
21
|
+
vi.mock("./agent-browser-runtime", () => ({
|
|
22
|
+
AgentBrowserRuntime: class {
|
|
23
|
+
launch = fake.launch;
|
|
24
|
+
shutdown = fake.shutdown;
|
|
25
|
+
identity = async () => ({ userAgent: "Fixture" });
|
|
26
|
+
constructor(options: unknown) {
|
|
27
|
+
fake.runtime(options);
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
}));
|
|
31
|
+
afterEach(() => vi.unstubAllEnvs());
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
vi.stubEnv("PASEO_SHARED_BROWSER_XVFB", undefined);
|
|
35
|
+
vi.clearAllMocks();
|
|
36
|
+
fake.assertAvailable.mockReset();
|
|
37
|
+
fake.launch.mockResolvedValue(undefined);
|
|
38
|
+
fake.shutdown.mockResolvedValue(undefined);
|
|
39
|
+
fake.stop.mockResolvedValue(undefined);
|
|
40
|
+
fake.display.mockResolvedValue({
|
|
41
|
+
launchEnvironment: { DISPLAY: ":23067", XAUTHORITY: "/tmp/private/authority" },
|
|
42
|
+
assertAvailable: fake.assertAvailable,
|
|
43
|
+
stop: fake.stop,
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("supplies private display environment only to opted-in hidden runtime creation", async () => {
|
|
48
|
+
vi.stubEnv("PASEO_SHARED_BROWSER_XVFB", "1");
|
|
49
|
+
const owner = await createRuntimeOwner();
|
|
50
|
+
const runtime = await owner.create("workspace-fixture");
|
|
51
|
+
expect(fake.runtime.mock.calls[0]![0]).toMatchObject({
|
|
52
|
+
headed: true,
|
|
53
|
+
launchEnvironment: { DISPLAY: ":23067", XAUTHORITY: "/tmp/private/authority" },
|
|
54
|
+
});
|
|
55
|
+
await owner.stop(runtime);
|
|
56
|
+
expect(fake.shutdown).toHaveBeenCalledTimes(1);
|
|
57
|
+
expect(fake.stop).toHaveBeenCalledTimes(1);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("keeps explicit headed mode and unavailable-display headless behavior unchanged", async () => {
|
|
61
|
+
vi.stubEnv("PASEO_SHARED_BROWSER_XVFB", "1");
|
|
62
|
+
await (await createRuntimeOwner({ headed: true })).create("headed-fixture");
|
|
63
|
+
expect(fake.display).not.toHaveBeenCalled();
|
|
64
|
+
expect(fake.runtime.mock.calls[0]![0]).toMatchObject({ headed: true });
|
|
65
|
+
expect(fake.runtime.mock.calls[0]![0]).not.toHaveProperty("launchEnvironment");
|
|
66
|
+
fake.display.mockResolvedValue(null);
|
|
67
|
+
const warning = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
68
|
+
try {
|
|
69
|
+
const owner = await createRuntimeOwner();
|
|
70
|
+
const absent = await owner.create("absent-fixture");
|
|
71
|
+
expect(fake.runtime.mock.calls[1]![0]).toMatchObject({ headed: false });
|
|
72
|
+
await expect(owner.request(absent, "identity", null)).resolves.toMatchObject({
|
|
73
|
+
displayNotice: "Private display unavailable: Xvfb was not found.",
|
|
74
|
+
});
|
|
75
|
+
} finally {
|
|
76
|
+
warning.mockRestore();
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("falls back only before Chromium construction and reports a bounded secret-free notice", async () => {
|
|
81
|
+
vi.stubEnv("PASEO_SHARED_BROWSER_XVFB", "1");
|
|
82
|
+
const warning = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
83
|
+
try {
|
|
84
|
+
for (const [failure, notice] of [
|
|
85
|
+
[
|
|
86
|
+
new PrivateDisplayError("Private browser display ended before readiness"),
|
|
87
|
+
"Private display unavailable: Private browser display ended before readiness.",
|
|
88
|
+
],
|
|
89
|
+
[
|
|
90
|
+
new Error("EACCES /home/user/.Xauthority-secret-cookie"),
|
|
91
|
+
"Private display unavailable: unexpected startup failure.",
|
|
92
|
+
],
|
|
93
|
+
] as const) {
|
|
94
|
+
fake.display.mockRejectedValueOnce(failure);
|
|
95
|
+
const owner = await createRuntimeOwner();
|
|
96
|
+
const runtime = await owner.create("startup-fallback-fixture");
|
|
97
|
+
expect(fake.runtime.mock.calls.at(-1)![0]).toMatchObject({ headed: false });
|
|
98
|
+
expect(fake.runtime.mock.calls.at(-1)![0]).not.toHaveProperty("launchEnvironment");
|
|
99
|
+
const identity = await owner.request(runtime, "identity", null);
|
|
100
|
+
expect(identity).toMatchObject({ displayNotice: notice });
|
|
101
|
+
expect(JSON.stringify(identity)).not.toContain("secret");
|
|
102
|
+
expect(warning).toHaveBeenLastCalledWith(`[shared-browser] ${notice} Using headless mode.`);
|
|
103
|
+
}
|
|
104
|
+
} finally {
|
|
105
|
+
warning.mockRestore();
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("cleans the private display after launch failure and even failing browser shutdown", async () => {
|
|
110
|
+
vi.stubEnv("PASEO_SHARED_BROWSER_XVFB", "1");
|
|
111
|
+
fake.launch.mockRejectedValue(new Error("fixture launch failure"));
|
|
112
|
+
fake.shutdown.mockRejectedValue(new Error("fixture shutdown failure"));
|
|
113
|
+
await expect((await createRuntimeOwner()).create("failure-fixture")).rejects.toThrow();
|
|
114
|
+
expect(fake.stop).toHaveBeenCalledTimes(1);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("refuses runtime requests after private display loss", async () => {
|
|
118
|
+
vi.stubEnv("PASEO_SHARED_BROWSER_XVFB", "1");
|
|
119
|
+
const owner = await createRuntimeOwner();
|
|
120
|
+
const runtime = await owner.create("loss-fixture");
|
|
121
|
+
fake.assertAvailable.mockImplementation(() => {
|
|
122
|
+
throw new RuntimeLostError("Private display ended");
|
|
123
|
+
});
|
|
124
|
+
await expect(owner.request(runtime, "state", null)).rejects.toBeInstanceOf(RuntimeLostError);
|
|
125
|
+
await owner.stop(runtime);
|
|
126
|
+
expect(fake.stop).toHaveBeenCalledTimes(1);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("stays headless by default and for any environment value other than 1", async () => {
|
|
130
|
+
const owner = await createRuntimeOwner();
|
|
131
|
+
for (const value of [undefined, "", "0", "true", "yes"]) {
|
|
132
|
+
vi.stubEnv("PASEO_SHARED_BROWSER_XVFB", value);
|
|
133
|
+
await owner.create("default-fixture");
|
|
134
|
+
}
|
|
135
|
+
expect(fake.display).not.toHaveBeenCalled();
|
|
136
|
+
for (const call of fake.runtime.mock.calls) {
|
|
137
|
+
expect(call[0]).toMatchObject({ headed: false });
|
|
138
|
+
expect(call[0]).not.toHaveProperty("launchEnvironment");
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("reads the environment at each runtime creation and lets an explicit option override it", async () => {
|
|
143
|
+
const owner = await createRuntimeOwner();
|
|
144
|
+
vi.stubEnv("PASEO_SHARED_BROWSER_XVFB", "1");
|
|
145
|
+
await owner.create("env-opt-in");
|
|
146
|
+
expect(fake.display).toHaveBeenCalledTimes(1);
|
|
147
|
+
await (await createRuntimeOwner({ virtualDisplay: false })).create("option-false");
|
|
148
|
+
expect(fake.display).toHaveBeenCalledTimes(1);
|
|
149
|
+
vi.stubEnv("PASEO_SHARED_BROWSER_XVFB", undefined);
|
|
150
|
+
await (await createRuntimeOwner({ virtualDisplay: true })).create("option-true");
|
|
151
|
+
expect(fake.display).toHaveBeenCalledTimes(2);
|
|
152
|
+
});
|