@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,210 @@
|
|
|
1
|
+
/** Real acquisition methods with fake CDP; shutdown never waits for uncertain image work. */
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import { afterEach, expect, it, vi } from "vitest";
|
|
4
|
+
import { DEFAULT_JPEG_QUALITY } from "../shared/capture-settings";
|
|
5
|
+
import { AgentBrowserRuntime } from "./agent-browser-runtime";
|
|
6
|
+
import { CdpConnection } from "./cdp";
|
|
7
|
+
|
|
8
|
+
class Connection extends EventEmitter {
|
|
9
|
+
isOpen = true;
|
|
10
|
+
send = vi.fn(async () => ({}));
|
|
11
|
+
close = vi.fn(() => {
|
|
12
|
+
this.isOpen = false;
|
|
13
|
+
this.emit("disconnect");
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Replace external process operations only, retaining real page/start/capture guards. */
|
|
18
|
+
function fixture() {
|
|
19
|
+
const runtime = new AgentBrowserRuntime({
|
|
20
|
+
nativeVideo: true,
|
|
21
|
+
binaryPath: "/tmp/unlaunched-browser",
|
|
22
|
+
executablePath: "/tmp/unlaunched-chromium",
|
|
23
|
+
profilePath: "/tmp/uncreated-profile",
|
|
24
|
+
ipcDirectory: "/tmp/uncreated-ipc",
|
|
25
|
+
session: "jpeg-shutdown-test",
|
|
26
|
+
});
|
|
27
|
+
const connection = new Connection();
|
|
28
|
+
const page = {
|
|
29
|
+
connection,
|
|
30
|
+
send: vi.fn(async (method: string): Promise<unknown> => {
|
|
31
|
+
if (method === "Page.getNavigationHistory") return { currentIndex: 0, entries: [] };
|
|
32
|
+
if (method === "Page.getLayoutMetrics") return { cssVisualViewport: { pageX: 0, pageY: 0 } };
|
|
33
|
+
return {};
|
|
34
|
+
}),
|
|
35
|
+
};
|
|
36
|
+
const control = runtime as unknown as {
|
|
37
|
+
invoke(args: string[]): Promise<unknown>;
|
|
38
|
+
assertVersion(): Promise<void>;
|
|
39
|
+
connectCdp(): Promise<void>;
|
|
40
|
+
captureScreenshot(): Promise<unknown>;
|
|
41
|
+
page: typeof page | null;
|
|
42
|
+
heldButtons: Set<string>;
|
|
43
|
+
heldKeys: Map<string, { key: string; code: string }>;
|
|
44
|
+
screencastFrame: unknown;
|
|
45
|
+
};
|
|
46
|
+
Object.assign(runtime, {
|
|
47
|
+
page,
|
|
48
|
+
connection,
|
|
49
|
+
emulationAppliedPage: page,
|
|
50
|
+
viewport: { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false, touch: false },
|
|
51
|
+
});
|
|
52
|
+
control.invoke = vi.fn(async () => ({ cdpUrl: "ws://127.0.0.1:1/fixture" }));
|
|
53
|
+
control.assertVersion = vi.fn(async () => {});
|
|
54
|
+
return { runtime, control, page, connection };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
afterEach(() => vi.restoreAllMocks());
|
|
58
|
+
|
|
59
|
+
it.each(["Page.enable", "Page.getNavigationHistory"])(
|
|
60
|
+
"explicit JPEG startup cannot resume from %s after shutdown",
|
|
61
|
+
async (blockedMethod) => {
|
|
62
|
+
const { runtime, page, control } = fixture();
|
|
63
|
+
const entered = Promise.withResolvers<void>();
|
|
64
|
+
const blocked = Promise.withResolvers<void>();
|
|
65
|
+
const send = page.send.getMockImplementation()!;
|
|
66
|
+
page.send.mockImplementation(async (method) => {
|
|
67
|
+
if (method === blockedMethod) {
|
|
68
|
+
entered.resolve();
|
|
69
|
+
await blocked.promise;
|
|
70
|
+
}
|
|
71
|
+
return await send(method);
|
|
72
|
+
});
|
|
73
|
+
const reconnect = vi.spyOn(runtime, "reconnect");
|
|
74
|
+
const result = runtime.startScreencast().catch((error: unknown) => error);
|
|
75
|
+
await entered.promise;
|
|
76
|
+
await runtime.shutdown();
|
|
77
|
+
blocked.resolve();
|
|
78
|
+
expect(await result).toMatchObject({ message: "Browser runtime is shutting down" });
|
|
79
|
+
expect(reconnect).not.toHaveBeenCalled();
|
|
80
|
+
expect(page.send.mock.calls.some(([method]) => method === "Page.startScreencast")).toBe(false);
|
|
81
|
+
expect(control.invoke).toHaveBeenCalledTimes(1);
|
|
82
|
+
},
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
it("optional frame startup rejects shutdown without attempting screenshot fallback", async () => {
|
|
86
|
+
const { runtime, page, control } = fixture();
|
|
87
|
+
const entered = Promise.withResolvers<void>();
|
|
88
|
+
const blocked = Promise.withResolvers<void>();
|
|
89
|
+
page.send.mockImplementation(async (method) => {
|
|
90
|
+
if (method === "Page.enable") {
|
|
91
|
+
entered.resolve();
|
|
92
|
+
await blocked.promise;
|
|
93
|
+
}
|
|
94
|
+
return {};
|
|
95
|
+
});
|
|
96
|
+
const fallback = vi.spyOn(control, "captureScreenshot");
|
|
97
|
+
const result = runtime.frame(1024, DEFAULT_JPEG_QUALITY, 0).catch((error: unknown) => error);
|
|
98
|
+
await entered.promise;
|
|
99
|
+
await runtime.shutdown();
|
|
100
|
+
blocked.resolve();
|
|
101
|
+
expect(await result).toMatchObject({ message: "Browser runtime is shutting down" });
|
|
102
|
+
expect(fallback).not.toHaveBeenCalled();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("a screenshot-only read cannot publish late pixels or capture after blocked metrics", async () => {
|
|
106
|
+
const { runtime, page, control } = fixture();
|
|
107
|
+
const entered = Promise.withResolvers<void>();
|
|
108
|
+
const blocked = Promise.withResolvers<void>();
|
|
109
|
+
const send = page.send.getMockImplementation()!;
|
|
110
|
+
page.send.mockImplementation(async (method) => {
|
|
111
|
+
if (method === "Page.getLayoutMetrics") {
|
|
112
|
+
entered.resolve();
|
|
113
|
+
await blocked.promise;
|
|
114
|
+
}
|
|
115
|
+
return await send(method);
|
|
116
|
+
});
|
|
117
|
+
const result = runtime.frame(1024, 100, 0).catch((error: unknown) => error);
|
|
118
|
+
await entered.promise;
|
|
119
|
+
await runtime.shutdown();
|
|
120
|
+
blocked.resolve();
|
|
121
|
+
expect(await result).toMatchObject({ message: "Browser runtime is shutting down" });
|
|
122
|
+
expect(page.send.mock.calls.some(([method]) => method === "Page.captureScreenshot")).toBe(false);
|
|
123
|
+
expect(control.screencastFrame).toBeNull();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("an already dispatched screenshot cannot cache or return its late bytes after shutdown", async () => {
|
|
127
|
+
const { runtime, page } = fixture();
|
|
128
|
+
const entered = Promise.withResolvers<void>();
|
|
129
|
+
const blocked = Promise.withResolvers<void>();
|
|
130
|
+
const send = page.send.getMockImplementation()!;
|
|
131
|
+
page.send.mockImplementation(async (method) => {
|
|
132
|
+
if (method === "Page.captureScreenshot") {
|
|
133
|
+
entered.resolve();
|
|
134
|
+
await blocked.promise;
|
|
135
|
+
return { data: "late-pixels" };
|
|
136
|
+
}
|
|
137
|
+
return await send(method);
|
|
138
|
+
});
|
|
139
|
+
const result = runtime.frame(1024, 100, 0).catch((error: unknown) => error);
|
|
140
|
+
await entered.promise;
|
|
141
|
+
await runtime.shutdown();
|
|
142
|
+
blocked.resolve();
|
|
143
|
+
expect(await result).toMatchObject({ message: "Browser runtime is shutting down" });
|
|
144
|
+
expect(
|
|
145
|
+
page.send.mock.calls.filter(([method]) => method === "Page.captureScreenshot"),
|
|
146
|
+
).toHaveLength(1);
|
|
147
|
+
await expect(runtime.frame(1024, 100, 0)).rejects.toThrow("closed");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("reconnect paused at version discovery cannot invoke native connection after shutdown", async () => {
|
|
151
|
+
const { runtime, control } = fixture();
|
|
152
|
+
const entered = Promise.withResolvers<void>();
|
|
153
|
+
const blocked = Promise.withResolvers<void>();
|
|
154
|
+
control.assertVersion = vi.fn(async () => {
|
|
155
|
+
entered.resolve();
|
|
156
|
+
await blocked.promise;
|
|
157
|
+
});
|
|
158
|
+
const result = runtime.reconnect().catch((error: unknown) => error);
|
|
159
|
+
await entered.promise;
|
|
160
|
+
await runtime.shutdown();
|
|
161
|
+
blocked.resolve();
|
|
162
|
+
expect(await result).toMatchObject({ message: "Browser runtime is shutting down" });
|
|
163
|
+
expect(control.invoke).toHaveBeenCalledTimes(1);
|
|
164
|
+
expect(control.invoke).toHaveBeenCalledWith([
|
|
165
|
+
"--session",
|
|
166
|
+
"jpeg-shutdown-test",
|
|
167
|
+
"--json",
|
|
168
|
+
"close",
|
|
169
|
+
]);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("late opened CDP connection is closed without publication or target attachment", async () => {
|
|
173
|
+
const { runtime, control } = fixture();
|
|
174
|
+
const entered = Promise.withResolvers<void>();
|
|
175
|
+
const blocked = Promise.withResolvers<CdpConnection>();
|
|
176
|
+
const late = new Connection();
|
|
177
|
+
vi.spyOn(CdpConnection, "connect").mockImplementation(async () => {
|
|
178
|
+
entered.resolve();
|
|
179
|
+
return await blocked.promise;
|
|
180
|
+
});
|
|
181
|
+
const result = control.connectCdp().catch((error: unknown) => error);
|
|
182
|
+
await entered.promise;
|
|
183
|
+
await runtime.shutdown();
|
|
184
|
+
blocked.resolve(late as unknown as CdpConnection);
|
|
185
|
+
expect(await result).toMatchObject({ message: "Browser runtime is shutting down" });
|
|
186
|
+
expect(late.close).toHaveBeenCalledTimes(1);
|
|
187
|
+
expect(late.send).not.toHaveBeenCalled();
|
|
188
|
+
expect(control.page).toBeNull();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("shutdown still releases original held input, then all fresh acquisition stays closed", async () => {
|
|
192
|
+
const { runtime, page, control } = fixture();
|
|
193
|
+
control.heldButtons.add("left");
|
|
194
|
+
control.heldKeys.set("ControlLeft", { key: "Control", code: "ControlLeft" });
|
|
195
|
+
await runtime.shutdown();
|
|
196
|
+
expect(page.send).toHaveBeenCalledWith(
|
|
197
|
+
"Input.dispatchMouseEvent",
|
|
198
|
+
expect.objectContaining({ type: "mouseReleased", button: "left" }),
|
|
199
|
+
{ mutation: true },
|
|
200
|
+
);
|
|
201
|
+
expect(page.send).toHaveBeenCalledWith(
|
|
202
|
+
"Input.dispatchKeyEvent",
|
|
203
|
+
expect.objectContaining({ type: "keyUp", code: "ControlLeft" }),
|
|
204
|
+
{ mutation: true },
|
|
205
|
+
);
|
|
206
|
+
await expect(runtime.reconnect()).rejects.toThrow("shutting down");
|
|
207
|
+
await expect(runtime.launch()).rejects.toThrow("shutting down");
|
|
208
|
+
await expect(runtime.frame(1024, DEFAULT_JPEG_QUALITY, 0)).rejects.toThrow("closed");
|
|
209
|
+
expect(control.invoke).toHaveBeenCalledTimes(1);
|
|
210
|
+
});
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/** Actual runtime JPEG path, fake CDP only. Swap time must survive transport delay. */
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
|
4
|
+
import { DEFAULT_JPEG_QUALITY } from "../shared/capture-settings";
|
|
5
|
+
import { AgentBrowserRuntime } from "./agent-browser-runtime";
|
|
6
|
+
|
|
7
|
+
function jpeg(tag: number): string {
|
|
8
|
+
return Buffer.from([
|
|
9
|
+
255,
|
|
10
|
+
216,
|
|
11
|
+
255,
|
|
12
|
+
224,
|
|
13
|
+
0,
|
|
14
|
+
3,
|
|
15
|
+
tag,
|
|
16
|
+
255,
|
|
17
|
+
192,
|
|
18
|
+
0,
|
|
19
|
+
11,
|
|
20
|
+
8,
|
|
21
|
+
3,
|
|
22
|
+
32,
|
|
23
|
+
5,
|
|
24
|
+
0,
|
|
25
|
+
1,
|
|
26
|
+
1,
|
|
27
|
+
17,
|
|
28
|
+
0,
|
|
29
|
+
255,
|
|
30
|
+
217,
|
|
31
|
+
]).toString("base64");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
vi.useFakeTimers();
|
|
36
|
+
vi.setSystemTime(1_800_000_000_000);
|
|
37
|
+
vi.spyOn(performance, "now").mockImplementation(() => Date.now());
|
|
38
|
+
});
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
vi.clearAllTimers();
|
|
41
|
+
vi.useRealTimers();
|
|
42
|
+
vi.restoreAllMocks();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
function fixture() {
|
|
46
|
+
const runtime = new AgentBrowserRuntime({
|
|
47
|
+
nativeVideo: true,
|
|
48
|
+
binaryPath: "/tmp/unlaunched",
|
|
49
|
+
executablePath: "/tmp/unlaunched",
|
|
50
|
+
profilePath: "/tmp/uncreated",
|
|
51
|
+
ipcDirectory: "/tmp/uncreated",
|
|
52
|
+
session: "source-order-fixture",
|
|
53
|
+
});
|
|
54
|
+
const connection = Object.assign(new EventEmitter(), { isOpen: true });
|
|
55
|
+
const page = Object.assign(new EventEmitter(), {
|
|
56
|
+
connection,
|
|
57
|
+
send: vi.fn(async (method: string): Promise<unknown> => {
|
|
58
|
+
if (method === "Page.getFrameTree") return { frameTree: { frame: { id: "root" } } };
|
|
59
|
+
if (method === "Page.getNavigationHistory")
|
|
60
|
+
return {
|
|
61
|
+
currentIndex: 0,
|
|
62
|
+
entries: [{ id: 1, url: "https://source.invalid/", title: "Fixture" }],
|
|
63
|
+
};
|
|
64
|
+
if (method === "Page.getLayoutMetrics") return { cssVisualViewport: { pageX: 0, pageY: 0 } };
|
|
65
|
+
if (method === "Page.captureScreenshot") return { data: jpeg(9) };
|
|
66
|
+
return {};
|
|
67
|
+
}),
|
|
68
|
+
});
|
|
69
|
+
Object.assign(runtime, {
|
|
70
|
+
page,
|
|
71
|
+
connection,
|
|
72
|
+
emulationAppliedPage: page,
|
|
73
|
+
screencastActive: true,
|
|
74
|
+
viewport: { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false, touch: false },
|
|
75
|
+
});
|
|
76
|
+
const internal = runtime as unknown as {
|
|
77
|
+
invalidateScreencastFrame(): void;
|
|
78
|
+
onScreencastFrame(page: unknown, event: unknown): void;
|
|
79
|
+
bindPageEvents(page: unknown): Promise<void>;
|
|
80
|
+
};
|
|
81
|
+
const emit = (sourceMs: number | undefined, tag = 1) =>
|
|
82
|
+
internal.onScreencastFrame(page, {
|
|
83
|
+
data: jpeg(tag),
|
|
84
|
+
sessionId: 17,
|
|
85
|
+
metadata: {
|
|
86
|
+
deviceWidth: 1280,
|
|
87
|
+
deviceHeight: 800,
|
|
88
|
+
...(sourceMs === undefined ? {} : { timestamp: sourceMs / 1000 }),
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
return { runtime, internal, page, emit };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Zero-wait stream polling still uses a native timer; settle that fake timer
|
|
95
|
+
* without advancing the source clock or extending the freshness assertion. */
|
|
96
|
+
async function readFrame(runtime: AgentBrowserRuntime) {
|
|
97
|
+
const pending = runtime.frame(100, DEFAULT_JPEG_QUALITY, 0);
|
|
98
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
99
|
+
return pending;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
it("preserves native swap time and its remaining one-second source lifetime", async () => {
|
|
103
|
+
const { runtime, emit } = fixture();
|
|
104
|
+
const sourceMs = Date.now() - 600;
|
|
105
|
+
emit(sourceMs);
|
|
106
|
+
const frame = await readFrame(runtime);
|
|
107
|
+
expect(frame.transport).toBe("cdp-screencast");
|
|
108
|
+
expect(frame.capturedAt).toBe(new Date(sourceMs).toISOString());
|
|
109
|
+
await vi.advanceTimersByTimeAsync(401);
|
|
110
|
+
expect((await readFrame(runtime)).transport).toBe("screenshot");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it.each([20, 10_000])(
|
|
114
|
+
"refuses a queued pre-input swap aged %sms even when it arrives after invalidation",
|
|
115
|
+
async (ageMs) => {
|
|
116
|
+
const { runtime, internal, page, emit } = fixture();
|
|
117
|
+
const sourceMs = Date.now() - ageMs;
|
|
118
|
+
internal.invalidateScreencastFrame();
|
|
119
|
+
emit(sourceMs);
|
|
120
|
+
const frame = await readFrame(runtime);
|
|
121
|
+
expect(frame.transport).toBe("screenshot");
|
|
122
|
+
expect(frame.dataBase64).toBe(jpeg(9));
|
|
123
|
+
expect(page.send).toHaveBeenCalledWith("Page.screencastFrameAck", { sessionId: 17 });
|
|
124
|
+
},
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
it.each([0, 20, 50])(
|
|
128
|
+
"post-input source offset +%sms cannot borrow clock tolerance to authorize old pixels",
|
|
129
|
+
async (offsetMs) => {
|
|
130
|
+
const { runtime, internal, page, emit } = fixture();
|
|
131
|
+
internal.invalidateScreencastFrame();
|
|
132
|
+
emit(Date.now() + offsetMs);
|
|
133
|
+
const frame = await readFrame(runtime);
|
|
134
|
+
expect(frame.transport).toBe("screenshot");
|
|
135
|
+
expect(frame.dataBase64).toBe(jpeg(9));
|
|
136
|
+
expect(page.send).toHaveBeenCalledWith("Page.screencastFrameAck", { sessionId: 17 });
|
|
137
|
+
},
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
it("a genuinely later swap beyond post-input uncertainty keeps its native timestamp", async () => {
|
|
141
|
+
const { runtime, internal, emit } = fixture();
|
|
142
|
+
internal.invalidateScreencastFrame();
|
|
143
|
+
await vi.advanceTimersByTimeAsync(51);
|
|
144
|
+
const sourceMs = Date.now();
|
|
145
|
+
emit(sourceMs);
|
|
146
|
+
const frame = await readFrame(runtime);
|
|
147
|
+
expect(frame.transport).toBe("cdp-screencast");
|
|
148
|
+
expect(frame.capturedAt).toBe(new Date(sourceMs).toISOString());
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it.each([undefined, NaN, Infinity, 1_800_000_000_051, 1_799_999_998_999])(
|
|
152
|
+
"refuses missing, invalid, future or expired source time %s",
|
|
153
|
+
async (sourceMs) => {
|
|
154
|
+
const { runtime, emit } = fixture();
|
|
155
|
+
emit(sourceMs);
|
|
156
|
+
expect((await readFrame(runtime)).transport).toBe("screenshot");
|
|
157
|
+
},
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
it("out-of-order or duplicate swaps cannot replace a newer admitted JPEG", async () => {
|
|
161
|
+
const { runtime, emit } = fixture();
|
|
162
|
+
emit(Date.now(), 2);
|
|
163
|
+
const newest = await readFrame(runtime);
|
|
164
|
+
emit(Date.now() - 10, 1);
|
|
165
|
+
emit(Date.now(), 3);
|
|
166
|
+
expect(await readFrame(runtime)).toBe(newest);
|
|
167
|
+
expect(newest.dataBase64).toBe(jpeg(2));
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it.each(["Page.frameNavigated", "Page.navigatedWithinDocument"])(
|
|
171
|
+
"%s revokes cached and queued former-document JPEG",
|
|
172
|
+
async (event) => {
|
|
173
|
+
const { runtime, internal, page, emit } = fixture();
|
|
174
|
+
await internal.bindPageEvents(page);
|
|
175
|
+
const oldSourceMs = Date.now();
|
|
176
|
+
emit(oldSourceMs);
|
|
177
|
+
const prior = await readFrame(runtime);
|
|
178
|
+
await vi.advanceTimersByTimeAsync(20);
|
|
179
|
+
page.emit(
|
|
180
|
+
event,
|
|
181
|
+
event === "Page.frameNavigated"
|
|
182
|
+
? { frame: { id: "root", loaderId: "next", url: "https://next.invalid/" } }
|
|
183
|
+
: { frameId: "root", url: "https://next.invalid/#next" },
|
|
184
|
+
);
|
|
185
|
+
emit(oldSourceMs, 2);
|
|
186
|
+
const next = await readFrame(runtime);
|
|
187
|
+
expect(next).not.toBe(prior);
|
|
188
|
+
expect(next.transport).toBe("screenshot");
|
|
189
|
+
expect(next.dataBase64).toBe(jpeg(9));
|
|
190
|
+
},
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
it("child-frame and unrelated same-document events leave current main-frame pixels intact", async () => {
|
|
194
|
+
const { runtime, internal, page, emit } = fixture();
|
|
195
|
+
await internal.bindPageEvents(page);
|
|
196
|
+
emit(Date.now());
|
|
197
|
+
const before = await readFrame(runtime);
|
|
198
|
+
page.emit("Page.frameNavigated", { frame: { id: "child", parentId: "root" } });
|
|
199
|
+
page.emit("Page.navigatedWithinDocument", { frameId: "child", url: "https://child.invalid/#x" });
|
|
200
|
+
expect(await readFrame(runtime)).toBe(before);
|
|
201
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/** Per-instance display context reaches native CLI children without changing host or session authority. */
|
|
2
|
+
|
|
3
|
+
import { mkdtemp, 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
|
+
const temporary: string[] = [];
|
|
10
|
+
afterEach(async () => {
|
|
11
|
+
await Promise.all(temporary.splice(0).map((path) => rm(path, { recursive: true, force: true })));
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const exec = vi.hoisted(() => ({
|
|
15
|
+
calls: [] as { file: string; args: string[]; env: NodeJS.ProcessEnv }[],
|
|
16
|
+
}));
|
|
17
|
+
// Records the exact child launch instead of running a platform-specific script.
|
|
18
|
+
vi.mock("node:child_process", () => ({
|
|
19
|
+
execFile: (
|
|
20
|
+
file: string,
|
|
21
|
+
args: string[],
|
|
22
|
+
options: { env: NodeJS.ProcessEnv },
|
|
23
|
+
callback: (error: null, result: { stdout: string }) => void,
|
|
24
|
+
) => {
|
|
25
|
+
exec.calls.push({ file, args, env: options.env });
|
|
26
|
+
callback(null, { stdout: args.includes("--version") ? "0.38.2\n" : "{}\n" });
|
|
27
|
+
},
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
async function fixture(launchEnvironment?: Readonly<Record<string, string>>) {
|
|
31
|
+
const directory = await mkdtemp(join(tmpdir(), "shared-browser-runtime-env-"));
|
|
32
|
+
temporary.push(directory);
|
|
33
|
+
const binary = join(directory, "owned-cli");
|
|
34
|
+
const runtime = new AgentBrowserRuntime({
|
|
35
|
+
binaryPath: binary,
|
|
36
|
+
executablePath: join(directory, "unlaunched-chromium"),
|
|
37
|
+
profilePath: join(directory, "owned-profile"),
|
|
38
|
+
ipcDirectory: join(directory, "owned-ipc"),
|
|
39
|
+
session: "owned-environment",
|
|
40
|
+
...(launchEnvironment ? { launchEnvironment } : {}),
|
|
41
|
+
});
|
|
42
|
+
const native = runtime as unknown as {
|
|
43
|
+
assertVersion(): Promise<void>;
|
|
44
|
+
invoke(args: string[]): Promise<unknown>;
|
|
45
|
+
};
|
|
46
|
+
/** Environment the owned CLI child received for the call with these arguments. */
|
|
47
|
+
const launched = (args: string[]) => {
|
|
48
|
+
const call = exec.calls.find(
|
|
49
|
+
(candidate) => candidate.file === binary && candidate.args.join() === args.join(),
|
|
50
|
+
);
|
|
51
|
+
if (!call) throw new Error(`CLI was not launched with ${args.join(" ")}`);
|
|
52
|
+
return {
|
|
53
|
+
display: call.env.DISPLAY ?? null,
|
|
54
|
+
authority: call.env.XAUTHORITY ?? null,
|
|
55
|
+
wayland: call.env.WAYLAND_DISPLAY ?? null,
|
|
56
|
+
socket: call.env.AGENT_BROWSER_SOCKET_DIR,
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
return { directory, runtime, native, launched };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe("trusted private display launch environment", () => {
|
|
63
|
+
it("passes display/auth to every CLI child, copies settings, and keeps host environment unchanged", async () => {
|
|
64
|
+
const before = {
|
|
65
|
+
display: process.env.DISPLAY,
|
|
66
|
+
authority: process.env.XAUTHORITY,
|
|
67
|
+
wayland: process.env.WAYLAND_DISPLAY,
|
|
68
|
+
};
|
|
69
|
+
const environment = { DISPLAY: ":24701", XAUTHORITY: "/tmp/owned-private-auth" };
|
|
70
|
+
const f = await fixture(environment);
|
|
71
|
+
environment.DISPLAY = ":24702";
|
|
72
|
+
await f.native.assertVersion();
|
|
73
|
+
for (const args of [["owned-request"], ["owned-reconnect"], ["--version"]]) {
|
|
74
|
+
if (args[0] !== "--version") await f.native.invoke(args);
|
|
75
|
+
expect(f.launched(args)).toEqual({
|
|
76
|
+
display: ":24701",
|
|
77
|
+
authority: "/tmp/owned-private-auth",
|
|
78
|
+
wayland: null,
|
|
79
|
+
socket: join(f.directory, "owned-ipc"),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
expect({
|
|
83
|
+
display: process.env.DISPLAY,
|
|
84
|
+
authority: process.env.XAUTHORITY,
|
|
85
|
+
wayland: process.env.WAYLAND_DISPLAY,
|
|
86
|
+
}).toEqual(before);
|
|
87
|
+
await f.runtime.shutdown();
|
|
88
|
+
});
|
|
89
|
+
it("preserves the ordinary host display path when no private environment is provided", async () => {
|
|
90
|
+
const f = await fixture();
|
|
91
|
+
await f.native.invoke(["ordinary"]);
|
|
92
|
+
expect(f.launched(["ordinary"])).toMatchObject({
|
|
93
|
+
display: process.env.DISPLAY ?? null,
|
|
94
|
+
authority: process.env.XAUTHORITY ?? null,
|
|
95
|
+
wayland: process.env.WAYLAND_DISPLAY ?? null,
|
|
96
|
+
socket: join(f.directory, "owned-ipc"),
|
|
97
|
+
});
|
|
98
|
+
await f.runtime.shutdown();
|
|
99
|
+
});
|
|
100
|
+
it("refuses missing, remote or extra values that could override managed IPC/profile/session identity", async () => {
|
|
101
|
+
for (const value of [
|
|
102
|
+
{ DISPLAY: ":24701" },
|
|
103
|
+
{ DISPLAY: "remote.example:0", XAUTHORITY: "/tmp/auth" },
|
|
104
|
+
{ DISPLAY: ":24701", XAUTHORITY: "relative-auth" },
|
|
105
|
+
{ DISPLAY: ":24701", XAUTHORITY: "/tmp/auth", AGENT_BROWSER_SOCKET_DIR: "/tmp/other-ipc" },
|
|
106
|
+
{ DISPLAY: ":24701", XAUTHORITY: "/tmp/auth", HOME: "/tmp/other-home" },
|
|
107
|
+
]) {
|
|
108
|
+
await expect(fixture(value)).rejects.toThrow("display environment is invalid");
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
});
|