@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.
Files changed (173) hide show
  1. package/OVERVIEW.md +2 -2
  2. package/README.md +316 -27
  3. package/REMOTE_BROWSER_RESEARCH.md +395 -0
  4. package/STREAMING_DESIGN.md +196 -0
  5. package/client/browser-auto-capture-density.test.ts +102 -0
  6. package/client/browser-auto-capture-density.ts +30 -0
  7. package/client/browser-canvas-input.test.ts +198 -0
  8. package/client/browser-canvas-input.ts +202 -0
  9. package/client/browser-canvas-keyboard-web.test.ts +113 -0
  10. package/client/browser-canvas-keyboard-web.ts +230 -0
  11. package/client/browser-canvas-keyboard.test.ts +123 -0
  12. package/client/browser-canvas-keyboard.ts +126 -0
  13. package/client/browser-canvas-layout.test.ts +109 -0
  14. package/client/browser-canvas-layout.ts +51 -0
  15. package/client/browser-canvas-viewport-web.test.ts +77 -0
  16. package/client/browser-canvas-viewport-web.ts +63 -0
  17. package/client/browser-canvas-viewport.tsx +152 -0
  18. package/client/browser-capture-cadence.test.ts +9 -0
  19. package/client/browser-capture-cadence.ts +6 -0
  20. package/client/browser-capture-density-controls.tsx +75 -0
  21. package/client/browser-chrome.tsx +234 -0
  22. package/client/browser-client-presence.ts +184 -0
  23. package/client/browser-compose-controls.test.tsx +275 -0
  24. package/client/browser-compose-controls.tsx +174 -0
  25. package/client/browser-control-button.tsx +109 -0
  26. package/client/browser-control-dialog.tsx +71 -0
  27. package/client/browser-control-request.test.ts +27 -0
  28. package/client/browser-control-request.ts +57 -0
  29. package/client/browser-control-tooltip-web.ts +10 -0
  30. package/client/browser-dialog-body.tsx +22 -0
  31. package/client/browser-emulation-mode.test.ts +75 -0
  32. package/client/browser-emulation-mode.ts +98 -0
  33. package/client/browser-fill-viewport.test.ts +45 -0
  34. package/client/browser-fill-viewport.ts +28 -0
  35. package/client/browser-frame-buffer.test.ts +157 -0
  36. package/client/browser-frame-buffer.ts +148 -0
  37. package/client/browser-frame-image.tsx +62 -0
  38. package/client/browser-image-load.test.ts +23 -0
  39. package/client/browser-image-load.ts +26 -0
  40. package/client/browser-input-queue.test.ts +1254 -0
  41. package/client/browser-input-queue.ts +519 -0
  42. package/client/browser-menu-placement.test.ts +90 -0
  43. package/client/browser-menu-placement.ts +78 -0
  44. package/client/browser-panel-styles.ts +384 -0
  45. package/client/browser-quality-controls.test.ts +26 -0
  46. package/client/browser-quality-controls.tsx +139 -0
  47. package/client/browser-resolution-picker.tsx +189 -0
  48. package/client/browser-tabs.tsx +117 -0
  49. package/client/browser-toolbar-menu-web.test.ts +185 -0
  50. package/client/browser-toolbar-menu-web.ts +159 -0
  51. package/client/browser-toolbar-menu.tsx +255 -0
  52. package/client/browser-toolbar-submenu.tsx +93 -0
  53. package/client/browser-video-bytes.test.ts +44 -0
  54. package/client/browser-video-currency.ts +45 -0
  55. package/client/browser-video-decoder.test.ts +473 -0
  56. package/client/browser-video-decoder.ts +311 -0
  57. package/client/browser-video-surface.tsx +21 -0
  58. package/client/browser-video-visibility.test.ts +45 -0
  59. package/client/browser-viewer-recovery.test.ts +111 -0
  60. package/client/browser-viewer-recovery.ts +78 -0
  61. package/client/browser.tsx +1650 -1579
  62. package/client/favorite-star.tsx +23 -0
  63. package/client/frame-lifecycle.ts +62 -59
  64. package/client/sidebar.tsx +1 -2
  65. package/client/use-browser-auto-capture-density.test.ts +104 -0
  66. package/client/use-browser-auto-capture-density.ts +43 -0
  67. package/client/use-browser-canvas-input.ts +375 -0
  68. package/client/use-browser-capture-density.ts +87 -0
  69. package/client/use-browser-emulation-mode.ts +56 -0
  70. package/client/use-browser-fill-viewport.test.ts +108 -0
  71. package/client/use-browser-fill-viewport.ts +62 -0
  72. package/client/use-browser-frame-buffer.ts +66 -0
  73. package/client/use-browser-image-capture.ts +139 -0
  74. package/client/use-browser-scoped-mutation.test.ts +118 -0
  75. package/client/use-browser-scoped-mutation.ts +59 -0
  76. package/client/use-browser-video.test.ts +1060 -0
  77. package/client/use-browser-video.ts +481 -0
  78. package/client/use-browser-viewer-recovery.ts +26 -0
  79. package/client/use-resolution-favorites.ts +90 -0
  80. package/client/web.test.ts +125 -0
  81. package/client/web.ts +405 -0
  82. package/index.client.tsx +0 -10
  83. package/index.server.ts +32 -4
  84. package/npm-shrinkwrap.json +9 -9
  85. package/package.json +10 -5
  86. package/paseo-plugin.json +1 -1
  87. package/scripts/prepare-runtime.mjs +1 -1
  88. package/server/agent-browser-runtime.ts +1601 -314
  89. package/server/browser-cursor.test.ts +64 -0
  90. package/server/browser-cursor.ts +69 -0
  91. package/server/browser-frame-context.ts +22 -0
  92. package/server/browser-frame-epoch.test.ts +95 -0
  93. package/server/browser-frame-rejection.ts +17 -0
  94. package/server/browser-gesture.ts +120 -0
  95. package/server/browser-operation-diagnostics.test.ts +79 -0
  96. package/server/browser-operation-diagnostics.ts +103 -0
  97. package/server/browser-policy.ts +1316 -151
  98. package/server/browser-video-input-authority.test.ts +211 -0
  99. package/server/browser-video-policy.test.ts +564 -0
  100. package/server/browser-video-socket.test.ts +368 -0
  101. package/server/browser.ts +151 -16
  102. package/server/capture-transport-policy.test.ts +105 -0
  103. package/server/capture-transport-policy.ts +154 -0
  104. package/server/capture-transport-runtime.test.ts +213 -0
  105. package/server/daemon-launch-chain.test.ts +70 -0
  106. package/server/daemon-shutdown.test.ts +289 -0
  107. package/server/discrete-input-freshness.test.ts +364 -0
  108. package/server/discrete-input-native-lifetime.test.ts +150 -0
  109. package/server/display-loss-recovery.test.ts +294 -0
  110. package/server/emulation-restoration.test.ts +262 -0
  111. package/server/input-cleanup.ts +23 -0
  112. package/server/input-generation.test.ts +31 -0
  113. package/server/input-generation.ts +36 -0
  114. package/server/jpeg-capture-demand.test.ts +76 -0
  115. package/server/jpeg-capture-demand.ts +98 -0
  116. package/server/jpeg-frame.ts +29 -0
  117. package/server/keyboard-input.test.ts +61 -0
  118. package/server/keyboard-input.ts +104 -0
  119. package/server/live-input-policy.test.ts +958 -0
  120. package/server/live-input-runtime.test.ts +732 -0
  121. package/server/live-input-supervisor.test.ts +147 -0
  122. package/server/mcp-entry.ts +44 -2
  123. package/server/native-video-backpressure.test.ts +228 -0
  124. package/server/native-video-buffer.test.ts +141 -0
  125. package/server/native-video-buffer.ts +114 -0
  126. package/server/native-video-capture.test.ts +599 -0
  127. package/server/native-video-capture.ts +497 -0
  128. package/server/native-video-extension-source.test.ts +201 -0
  129. package/server/native-video-extension-source.ts +364 -0
  130. package/server/native-video-extension.test.ts +41 -0
  131. package/server/native-video-extension.ts +83 -0
  132. package/server/native-video-packet.ts +9 -0
  133. package/server/native-video-settings.test.ts +245 -0
  134. package/server/navigation-budget.ts +8 -0
  135. package/server/navigation-commit.ts +125 -0
  136. package/server/navigation-policy.test.ts +153 -0
  137. package/server/process-identity.ts +58 -0
  138. package/server/reattach-held-input.test.ts +143 -0
  139. package/server/runtime-frame-reuse.test.ts +295 -0
  140. package/server/runtime-jpeg-demand.test.ts +200 -0
  141. package/server/runtime-jpeg-shutdown.test.ts +210 -0
  142. package/server/runtime-jpeg-source-order.test.ts +201 -0
  143. package/server/runtime-launch-environment.test.ts +111 -0
  144. package/server/runtime-navigation.test.ts +327 -0
  145. package/server/runtime-owner-display.test.ts +152 -0
  146. package/server/runtime-owner.ts +340 -31
  147. package/server/runtime-page-state.test.ts +249 -0
  148. package/server/runtime-protocol.ts +31 -1
  149. package/server/runtime-target-retention.test.ts +181 -0
  150. package/server/runtime-video-opt-in.test.ts +82 -0
  151. package/server/runtime-video.test.ts +444 -0
  152. package/server/screencast-source-time.ts +46 -0
  153. package/server/stale-wheel-admission.test.ts +136 -0
  154. package/server/supervisor-client.ts +28 -4
  155. package/server/supervisor-heartbeat.test.ts +80 -0
  156. package/server/supervisor.ts +553 -55
  157. package/server/tab-close-confirmation.test.ts +97 -0
  158. package/server/video-source-recovery.test.ts +20 -0
  159. package/server/video-source-recovery.ts +64 -0
  160. package/server/viewer-capture-lifetime.ts +51 -0
  161. package/server/virtual-display.test.ts +184 -0
  162. package/server/virtual-display.ts +236 -0
  163. package/shared/browser-display-preferences.ts +46 -0
  164. package/shared/browser-video.ts +83 -0
  165. package/shared/browser.ts +286 -65
  166. package/shared/capture-density.ts +16 -0
  167. package/shared/capture-settings.ts +17 -0
  168. package/shared/device-presets.ts +90 -0
  169. package/shared/resolution-menu.test.ts +120 -0
  170. package/shared/resolution-menu.ts +72 -0
  171. package/shared/video-settings.test.ts +38 -0
  172. package/shared/video-settings.ts +34 -0
  173. package/shared/viewport-limits.ts +5 -0
@@ -0,0 +1,444 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { performance } from "node:perf_hooks";
3
+ import { describe, expect, it, vi } from "vitest";
4
+
5
+ const fixture = vi.hoisted(() => ({
6
+ captures: [] as {
7
+ options: unknown;
8
+ idleExpired: boolean;
9
+ sourceFailure: "startup" | "source-stopped" | "source-reset" | "source-dimensions" | null;
10
+ read: ReturnType<typeof vi.fn>;
11
+ stop: ReturnType<typeof vi.fn>;
12
+ invalidateQueuedFrames: ReturnType<typeof vi.fn>;
13
+ }[],
14
+ }));
15
+ vi.mock("./native-video-capture", () => ({
16
+ NativeVideoCapture: class {
17
+ options: unknown;
18
+ idleExpired = false;
19
+ sourceFailure: "startup" | "source-stopped" | "source-reset" | "source-dimensions" | null =
20
+ null;
21
+ read = vi.fn(async () => ({ status: "ready", streamId: "stream", packets: [] }));
22
+ stop = vi.fn(async () => {});
23
+ invalidateQueuedFrames = vi.fn();
24
+ constructor(options: unknown) {
25
+ this.options = options;
26
+ fixture.captures.push(this);
27
+ }
28
+ },
29
+ }));
30
+
31
+ import { AgentBrowserRuntime } from "./agent-browser-runtime";
32
+
33
+ function setup() {
34
+ fixture.captures = [];
35
+ const runtime = new AgentBrowserRuntime({
36
+ nativeVideo: true,
37
+ binaryPath: "/tmp/owned-bin",
38
+ executablePath: "/tmp/owned-chrome",
39
+ profilePath: "/tmp/owned-profile",
40
+ ipcDirectory: "/tmp/owned-ipc",
41
+ session: "fixture",
42
+ });
43
+ const internal = runtime as unknown as {
44
+ page: unknown;
45
+ connection: unknown;
46
+ viewport: unknown;
47
+ emulationAppliedPage: unknown;
48
+ attachmentGeneration: number;
49
+ documentGeneration: number;
50
+ restoreConfiguredEmulation(page: unknown): Promise<void>;
51
+ assertVersion(): Promise<void>;
52
+ connectCdp(): Promise<void>;
53
+ invoke(args: string[]): Promise<unknown>;
54
+ };
55
+ const connection = Object.assign(new EventEmitter(), { isOpen: true, close: vi.fn() });
56
+ const page = Object.assign(new EventEmitter(), {
57
+ connection,
58
+ targetId: "exact",
59
+ send: vi.fn(async (method: string): Promise<unknown> => {
60
+ if (method === "Page.getFrameTree") {
61
+ return { frameTree: { frame: { id: "root", loaderId: "prior-loader" } } };
62
+ }
63
+ if (method === "Page.reload") {
64
+ // Real reload admission observes a fresh main-frame commit, which may
65
+ // arrive before the ACK. No DOM readiness is needed for this barrier.
66
+ page.emit("Page.frameNavigated", {
67
+ frame: { id: "root", loaderId: "reload-loader", url: "https://fixture.invalid/" },
68
+ });
69
+ }
70
+ return {};
71
+ }),
72
+ });
73
+ internal.connection = connection;
74
+ internal.page = page;
75
+ internal.emulationAppliedPage = page;
76
+ internal.viewport = {
77
+ width: 412,
78
+ height: 839,
79
+ captureScale: 2,
80
+ deviceScaleFactor: 3,
81
+ mobile: true,
82
+ touch: true,
83
+ };
84
+ return { runtime, internal, page, connection };
85
+ }
86
+ describe("native video runtime fence", () => {
87
+ it.each(["startup", "source-stopped", "source-reset"] as const)(
88
+ "retires %s failure under continued demand and retries only after cooldown",
89
+ async (failure) => {
90
+ vi.useFakeTimers();
91
+ const clock = vi.spyOn(performance, "now").mockImplementation(() => Date.now());
92
+ const { runtime } = setup();
93
+ try {
94
+ await runtime.readVideo({ quality: "high", waitMs: 0 });
95
+ const former = fixture.captures[0]!;
96
+ former.sourceFailure = failure;
97
+ expect((await runtime.readVideo({ quality: "high", waitMs: 0 })).status).toBe("reset");
98
+ expect(former.stop).toHaveBeenCalledOnce();
99
+ for (let i = 0; i < 20; i++) await runtime.readVideo({ quality: "high", waitMs: 0 });
100
+ expect(fixture.captures).toHaveLength(1);
101
+ await vi.advanceTimersByTimeAsync(2999);
102
+ await runtime.readVideo({ quality: "high", waitMs: 0 });
103
+ expect(fixture.captures).toHaveLength(1);
104
+ await vi.advanceTimersByTimeAsync(1);
105
+ expect((await runtime.readVideo({ quality: "high", waitMs: 0 })).status).toBe("ready");
106
+ expect(fixture.captures).toHaveLength(2);
107
+ } finally {
108
+ await runtime.stopVideo();
109
+ clock.mockRestore();
110
+ vi.useRealTimers();
111
+ }
112
+ },
113
+ );
114
+
115
+ it("never retries a permanent size failure for the same viewport, including after navigation", async () => {
116
+ vi.useFakeTimers();
117
+ const clock = vi.spyOn(performance, "now").mockImplementation(() => Date.now());
118
+ const { runtime, internal } = setup();
119
+ try {
120
+ internal.viewport = {
121
+ width: 412,
122
+ height: 839,
123
+ captureScale: 1,
124
+ deviceScaleFactor: 2.625,
125
+ mobile: true,
126
+ touch: true,
127
+ };
128
+ await runtime.readVideo({ quality: "high", waitMs: 0 });
129
+ expect(fixture.captures[0]!.options).toMatchObject({ width: 412, height: 839 });
130
+ fixture.captures[0]!.sourceFailure = "source-dimensions";
131
+ expect((await runtime.readVideo({ quality: "high", waitMs: 0 })).status).toBe("unsupported");
132
+ expect(fixture.captures[0]!.stop).toHaveBeenCalledOnce();
133
+ internal.documentGeneration++;
134
+ await vi.advanceTimersByTimeAsync(60_000);
135
+ for (let i = 0; i < 20; i++) {
136
+ expect((await runtime.readVideo({ quality: "high", waitMs: 0 })).status).toBe(
137
+ "unsupported",
138
+ );
139
+ }
140
+ expect(fixture.captures).toHaveLength(1);
141
+ await runtime.setCaptureDensity(2, 2.625);
142
+ expect((await runtime.readVideo({ quality: "high", waitMs: 0 })).status).toBe("ready");
143
+ expect(fixture.captures).toHaveLength(2);
144
+ expect(fixture.captures[1]!.options).toMatchObject({ width: 824, height: 1678 });
145
+ expect(internal.viewport).toMatchObject({ width: 412, height: 839, captureScale: 2 });
146
+ } finally {
147
+ await runtime.stopVideo();
148
+ clock.mockRestore();
149
+ vi.useRealTimers();
150
+ }
151
+ });
152
+
153
+ it("keeps concurrent failed-source retirement fenced even after its cooldown", async () => {
154
+ vi.useFakeTimers();
155
+ const clock = vi.spyOn(performance, "now").mockImplementation(() => Date.now());
156
+ const { runtime } = setup();
157
+ const release = Promise.withResolvers<void>();
158
+ try {
159
+ await runtime.readVideo({ quality: "high", waitMs: 0 });
160
+ const former = fixture.captures[0]!;
161
+ former.sourceFailure = "source-stopped";
162
+ former.stop.mockImplementationOnce(() => release.promise);
163
+ let settled = false;
164
+ const retirement = runtime.readVideo({ quality: "high", waitMs: 0 }).then((reply) => {
165
+ settled = true;
166
+ return reply;
167
+ });
168
+ for (let i = 0; i < 10; i++) await Promise.resolve();
169
+ // Backend teardown can outlive the outer plugin RPC deadline. The reader
170
+ // must release its admission slot while replacement capture stays fenced.
171
+ await vi.advanceTimersByTimeAsync(35000);
172
+ expect(settled).toBe(true);
173
+ const readers = await Promise.all(
174
+ Array.from({ length: 8 }, () => runtime.readVideo({ quality: "high", waitMs: 0 })),
175
+ );
176
+ expect(readers.every((value) => value.status === "reset")).toBe(true);
177
+ expect(fixture.captures).toHaveLength(1);
178
+ let cleanupSettled = false;
179
+ const cleanup = runtime.stopVideo().then(() => {
180
+ cleanupSettled = true;
181
+ });
182
+ await vi.advanceTimersByTimeAsync(1);
183
+ expect(cleanupSettled).toBe(false);
184
+ release.resolve();
185
+ await cleanup;
186
+ await retirement;
187
+ await Promise.all(
188
+ Array.from({ length: 8 }, () => runtime.readVideo({ quality: "high", waitMs: 0 })),
189
+ );
190
+ expect(fixture.captures).toHaveLength(2);
191
+ expect(former.stop).toHaveBeenCalledOnce();
192
+ } finally {
193
+ release.resolve();
194
+ await runtime.stopVideo();
195
+ clock.mockRestore();
196
+ vi.useRealTimers();
197
+ }
198
+ });
199
+
200
+ it("a single cohort failure never retires a source used by a healthy peer", async () => {
201
+ const { runtime } = setup();
202
+ await runtime.readVideo({ quality: "high", waitMs: 0 });
203
+ const shared = fixture.captures[0]!;
204
+ shared.read.mockResolvedValueOnce({
205
+ status: "unsupported",
206
+ streamId: "high",
207
+ packets: [],
208
+ reason: "Native video quality is unavailable",
209
+ });
210
+ expect((await runtime.readVideo({ quality: "high", waitMs: 0 })).status).toBe("unsupported");
211
+ expect((await runtime.readVideo({ quality: "low", waitMs: 0 })).status).toBe("ready");
212
+ expect(shared.stop).not.toHaveBeenCalled();
213
+ expect(fixture.captures).toHaveLength(1);
214
+ await runtime.stopVideo();
215
+ });
216
+ it("forwards exact encoder settings and refuses invalid values before creating a source", async () => {
217
+ const { runtime } = setup();
218
+ await expect(runtime.readVideo({ quality: "high", bitrate: 1, fps: 30 })).rejects.toThrow();
219
+ expect(fixture.captures).toHaveLength(0);
220
+ await runtime.readVideo({ quality: "low", bitrate: 24_000_000, fps: 60, waitMs: 0 });
221
+ expect(fixture.captures[0]!.read).toHaveBeenLastCalledWith({
222
+ quality: "low",
223
+ bitrate: 24_000_000,
224
+ fps: 60,
225
+ waitMs: 0,
226
+ });
227
+ await runtime.stopVideo();
228
+ });
229
+
230
+ it("retires an idle source under the stop barrier and resumes on the next read", async () => {
231
+ const { runtime } = setup();
232
+ await runtime.readVideo({ quality: "high", waitMs: 0 });
233
+ const expired = fixture.captures[0]!;
234
+ expired.idleExpired = true;
235
+ const stopping = Promise.withResolvers<void>();
236
+ expired.stop.mockImplementationOnce(() => stopping.promise);
237
+ let settled = false;
238
+ const retirement = runtime.readVideo({ quality: "high", waitMs: 0 }).then((reply) => {
239
+ settled = true;
240
+ return reply;
241
+ });
242
+ for (let i = 0; i < 10; i++) await Promise.resolve();
243
+ expect(settled).toBe(true);
244
+ expect(await runtime.readVideo({ quality: "high", waitMs: 0 })).toMatchObject({
245
+ status: "reset",
246
+ packets: [],
247
+ });
248
+ expect(fixture.captures).toHaveLength(1);
249
+ stopping.resolve();
250
+ await runtime.stopVideo();
251
+ expect(await retirement).toMatchObject({ status: "reset", packets: [] });
252
+ await runtime.readVideo({ quality: "high", waitMs: 0 });
253
+ expect(fixture.captures).toHaveLength(2);
254
+ expect(expired.stop).toHaveBeenCalledOnce();
255
+ await runtime.stopVideo();
256
+ });
257
+
258
+ it("turns expiry during a read into a recoverable reset, not permanent unsupported", async () => {
259
+ const { runtime } = setup();
260
+ await runtime.readVideo({ quality: "high", waitMs: 0 });
261
+ const expired = fixture.captures[0]!;
262
+ expired.read.mockImplementationOnce(async () => {
263
+ expired.idleExpired = true;
264
+ return { status: "unsupported", packets: [] };
265
+ });
266
+ expect(await runtime.readVideo({ quality: "high", waitMs: 0 })).toMatchObject({
267
+ status: "reset",
268
+ packets: [],
269
+ });
270
+ expect(await runtime.readVideo({ quality: "high", waitMs: 0 })).toMatchObject({
271
+ status: "reset",
272
+ packets: [],
273
+ });
274
+ await runtime.stopVideo();
275
+ await runtime.readVideo({ quality: "high", waitMs: 0 });
276
+ expect(fixture.captures).toHaveLength(2);
277
+ await runtime.stopVideo();
278
+ });
279
+
280
+ it("creates the exact physical capture pixels and retains live pointer continuity", async () => {
281
+ const { runtime } = setup();
282
+ await runtime.readVideo({ quality: "high", waitMs: 0 });
283
+ expect(fixture.captures[0]?.options).toMatchObject({
284
+ targetId: "exact",
285
+ width: 824,
286
+ height: 1678,
287
+ });
288
+ await runtime.mouseMove(10, 10);
289
+ expect(fixture.captures[0]?.invalidateQueuedFrames).not.toHaveBeenCalled();
290
+ await runtime.stopVideo();
291
+ });
292
+ it("document change while a read is pending returns reset without packets", async () => {
293
+ const { runtime, internal } = setup();
294
+ await runtime.readVideo({ quality: "high", waitMs: 0 });
295
+ let resolveRead: (value: unknown) => void = () => {};
296
+ fixture.captures[0]!.read.mockImplementationOnce(
297
+ () =>
298
+ new Promise((resolve) => {
299
+ resolveRead = resolve;
300
+ }),
301
+ );
302
+ const pending = runtime.readVideo({ quality: "high", waitMs: 500 });
303
+ await Promise.resolve();
304
+ await Promise.resolve();
305
+ internal.documentGeneration++;
306
+ resolveRead({ status: "ready", streamId: "old", packets: [{ dataBase64: "old" }] });
307
+ expect(await pending).toMatchObject({ status: "reset", packets: [] });
308
+ await runtime.stopVideo();
309
+ });
310
+ it("successful explicit reload clears queued video, never the shared source on mouse motion", async () => {
311
+ const { runtime } = setup();
312
+ await runtime.readVideo({ quality: "high", waitMs: 0 });
313
+ await runtime.reload();
314
+ expect(fixture.captures[0]?.invalidateQueuedFrames).toHaveBeenCalledOnce();
315
+ await runtime.stopVideo();
316
+ });
317
+ it("replacement attachment cannot reuse a former capture's ownership callback", async () => {
318
+ const { runtime, internal } = setup();
319
+ await runtime.readVideo({ quality: "high", waitMs: 0 });
320
+ const owned = fixture.captures[0]!.options as { isCurrent(): boolean };
321
+ expect(owned.isCurrent()).toBe(true);
322
+ internal.attachmentGeneration++;
323
+ expect(owned.isCurrent()).toBe(false);
324
+ await runtime.stopVideo();
325
+ });
326
+ it("blocks replacement capture during an awaited resize stop, then uses the new exact pixels", async () => {
327
+ const { runtime } = setup();
328
+ await runtime.readVideo({ quality: "high", waitMs: 0 });
329
+ const former = fixture.captures[0]!;
330
+ const stopping = Promise.withResolvers<void>();
331
+ former.stop.mockImplementationOnce(() => stopping.promise);
332
+
333
+ const resize = runtime.emulate({
334
+ width: 1280,
335
+ height: 1280,
336
+ captureScale: 1,
337
+ deviceScaleFactor: 1,
338
+ mobile: false,
339
+ touch: false,
340
+ });
341
+ expect((former.options as { isCurrent(): boolean }).isCurrent()).toBe(false);
342
+ expect(await runtime.readVideo({ quality: "high", waitMs: 0 })).toMatchObject({
343
+ status: "reset",
344
+ packets: [],
345
+ });
346
+ expect(fixture.captures).toHaveLength(1);
347
+ stopping.resolve();
348
+ await resize;
349
+
350
+ await runtime.readVideo({ quality: "high", waitMs: 0 });
351
+ expect(fixture.captures).toHaveLength(2);
352
+ expect(fixture.captures[1]!.options).toMatchObject({ width: 1280, height: 1280 });
353
+ expect((fixture.captures[1]!.options as { isCurrent(): boolean }).isCurrent()).toBe(true);
354
+ await runtime.stopVideo();
355
+ });
356
+ it("holds the stop barrier even when no device operation follows", async () => {
357
+ const { runtime } = setup();
358
+ await runtime.readVideo({ quality: "high", waitMs: 0 });
359
+ const deferred = Promise.withResolvers<void>();
360
+ fixture.captures[0]!.stop.mockImplementationOnce(() => deferred.promise);
361
+ const stopping = runtime.stopVideo();
362
+ expect((await runtime.readVideo({ quality: "high", waitMs: 0 })).status).toBe("reset");
363
+ expect(fixture.captures).toHaveLength(1);
364
+ deferred.resolve();
365
+ await stopping;
366
+ await runtime.readVideo({ quality: "high", waitMs: 0 });
367
+ expect(fixture.captures).toHaveLength(2);
368
+ await runtime.stopVideo();
369
+ });
370
+ it("binds the source to the exact saved viewport object, even with unchanged attachment", async () => {
371
+ const { runtime, internal } = setup();
372
+ await runtime.readVideo({ quality: "high", waitMs: 0 });
373
+ const owner = fixture.captures[0]!.options as { isCurrent(): boolean };
374
+ internal.viewport = {
375
+ width: 1280,
376
+ height: 800,
377
+ captureScale: 1,
378
+ deviceScaleFactor: 1,
379
+ mobile: false,
380
+ touch: false,
381
+ };
382
+ expect(owner.isCurrent()).toBe(false);
383
+ await runtime.stopVideo();
384
+ });
385
+ it("restoration owns a continuous source barrier without creating a helper from native metrics", async () => {
386
+ const { runtime, internal, page } = setup();
387
+ await runtime.readVideo({ quality: "high", waitMs: 0 });
388
+ const deferred = Promise.withResolvers<void>();
389
+ fixture.captures[0]!.stop.mockImplementationOnce(() => deferred.promise);
390
+ internal.emulationAppliedPage = null;
391
+ const restoring = internal.restoreConfiguredEmulation(page);
392
+ expect((await runtime.readVideo({ quality: "high", waitMs: 0 })).status).toBe("reset");
393
+ expect(fixture.captures).toHaveLength(1);
394
+ deferred.resolve();
395
+ await restoring;
396
+ await runtime.readVideo({ quality: "high", waitMs: 0 });
397
+ expect(fixture.captures[1]!.options).toMatchObject({ width: 824, height: 1678 });
398
+ await runtime.stopVideo();
399
+ });
400
+ it("blocks startup throughout target selection and releases the gate after a rejected target", async () => {
401
+ const { runtime } = setup();
402
+ const listing = Promise.withResolvers<[]>();
403
+ vi.spyOn(runtime, "targets").mockImplementationOnce(() => listing.promise);
404
+ const selection = runtime.selectTarget("not-saved");
405
+ const rejection = expect(selection).rejects.toThrow("Unknown page target");
406
+ expect((await runtime.readVideo({ quality: "high", waitMs: 0 })).status).toBe("reset");
407
+ expect(fixture.captures).toHaveLength(0);
408
+ listing.resolve([]);
409
+ await rejection;
410
+ await runtime.readVideo({ quality: "high", waitMs: 0 });
411
+ expect(fixture.captures).toHaveLength(1);
412
+ await runtime.stopVideo();
413
+ });
414
+ it("blocks capture while reconnect is restoring the saved page", async () => {
415
+ const { runtime, internal, page, connection } = setup();
416
+ const readiness = Promise.withResolvers<void>();
417
+ internal.assertVersion = () => readiness.promise;
418
+ internal.connectCdp = async () => {
419
+ internal.connection = connection;
420
+ internal.page = page;
421
+ internal.emulationAppliedPage = page;
422
+ };
423
+ const reconnecting = runtime.reconnect();
424
+ expect((await runtime.readVideo({ quality: "high", waitMs: 0 })).status).toBe("reset");
425
+ expect(fixture.captures).toHaveLength(0);
426
+ readiness.resolve();
427
+ await reconnecting;
428
+ await runtime.readVideo({ quality: "high", waitMs: 0 });
429
+ expect(fixture.captures).toHaveLength(1);
430
+ await runtime.stopVideo();
431
+ });
432
+ it("never starts a source during or after shutdown while native close is pending", async () => {
433
+ const { runtime, internal } = setup();
434
+ const closing = Promise.withResolvers<void>();
435
+ internal.invoke = () => closing.promise;
436
+ const shutdown = runtime.shutdown();
437
+ expect((await runtime.readVideo({ quality: "high", waitMs: 0 })).status).toBe("reset");
438
+ expect(fixture.captures).toHaveLength(0);
439
+ closing.resolve();
440
+ await shutdown;
441
+ expect((await runtime.readVideo({ quality: "high", waitMs: 0 })).status).toBe("reset");
442
+ expect(fixture.captures).toHaveLength(0);
443
+ });
444
+ });
@@ -0,0 +1,46 @@
1
+ /** Native JPEG source ordering, independent of arrival time and image decoding.
2
+ * CDP Page.ScreencastFrameMetadata.timestamp is frame-swap TimeSinceEpoch in
3
+ * seconds. Missing/stale metadata cannot certify queued pixels after input.
4
+ * Refused frames still get acknowledged by the runtime and use screenshot fallback.
5
+ */
6
+ import { CAPTURE_MAX_AGE_MS } from "./capture-transport-policy";
7
+
8
+ // Match the existing native-video source-clock bound. This is clock tolerance,
9
+ // never an extension of the one-second native source freshness window.
10
+ const SOURCE_CLOCK_TOLERANCE_MS = 50;
11
+
12
+ /** Own one ordering watermark per runtime. Invalidation revokes every swap at or
13
+ * before its wall-clock boundary plus clock uncertainty; accepted age retains the original monotonic
14
+ * expiry even when an event spent time queued before arrival. */
15
+ export function createScreencastSourceTime() {
16
+ let notBeforeMs = -Infinity;
17
+ let lastSourceMs = -Infinity;
18
+
19
+ return {
20
+ invalidate(nowMs: number): void {
21
+ notBeforeMs = Math.max(notBeforeMs, nowMs);
22
+ },
23
+ accept(timestampSeconds: number | undefined, wallNowMs: number, monotonicNowMs: number) {
24
+ if (typeof timestampSeconds !== "number" || !Number.isFinite(timestampSeconds)) return null;
25
+ const sourceMs = timestampSeconds * 1000;
26
+ const ageMs = wallNowMs - sourceMs;
27
+ if (
28
+ !Number.isFinite(sourceMs) ||
29
+ ageMs < -SOURCE_CLOCK_TOLERANCE_MS ||
30
+ ageMs > CAPTURE_MAX_AGE_MS ||
31
+ // The accepted future-clock tolerance also applies to an old swap.
32
+ // Require source time beyond the entire uncertainty interval after input.
33
+ sourceMs <= notBeforeMs + SOURCE_CLOCK_TOLERANCE_MS ||
34
+ sourceMs <= lastSourceMs
35
+ ) {
36
+ return null;
37
+ }
38
+ lastSourceMs = sourceMs;
39
+ return {
40
+ capturedAt: new Date(sourceMs).toISOString(),
41
+ // Future clock tolerance does not buy extra freshness after receipt.
42
+ receivedAt: monotonicNowMs - Math.max(0, ageMs),
43
+ };
44
+ },
45
+ };
46
+ }
@@ -0,0 +1,136 @@
1
+ import assert from "node:assert/strict";
2
+ import { it } from "vitest";
3
+ import {
4
+ type BrowserGestureAuthority,
5
+ createBrowserInputQueue,
6
+ } from "../client/browser-input-queue";
7
+ import { type BrowserRuntimeClient, SessionManager } from "./browser-policy";
8
+
9
+ it("recovers a captured-before-wheel frame that decodes after invalidation without repeating the first wheel", async () => {
10
+ // No native process or profile: actual queue + policy, with delayed decoder and deterministic clock.
11
+ let token = 0;
12
+ let now = 1_000;
13
+ const calls: string[] = [];
14
+ const client: BrowserRuntimeClient = {
15
+ connect: async () => ({ epoch: 1 }),
16
+ ensureWorkspace: async (workspaceId) => ({
17
+ workspaceId,
18
+ runtimeId: "r".repeat(32),
19
+ createdAt: 1,
20
+ }),
21
+ requestWorkspace: async (_workspace, operation) => {
22
+ calls.push(operation);
23
+ if (operation === "identity") return { userAgent: "Fixture Chromium" };
24
+ if (operation === "state")
25
+ return {
26
+ url: "https://fixture.invalid/",
27
+ title: "Fixture",
28
+ canGoBack: false,
29
+ canGoForward: false,
30
+ inputGeneration: "0:0",
31
+ };
32
+ if (operation === "frame")
33
+ return {
34
+ dataBase64: "eA==",
35
+ byteLength: 1,
36
+ width: 1280,
37
+ height: 800,
38
+ capturedAt: new Date(now).toISOString(),
39
+ };
40
+ if (operation === "cursor") return "default";
41
+ return null;
42
+ },
43
+ archiveWorkspace: async () => {},
44
+ closeWorkspace: async () => {},
45
+ disconnect: () => {},
46
+ };
47
+ const manager = new SessionManager({
48
+ client,
49
+ validateWorkspace: async () => true,
50
+ now: () => now,
51
+ frameCacheMs: 0,
52
+ issueToken: () => String(++token).padStart(32, "0"),
53
+ });
54
+ await manager.connect();
55
+ const { viewerToken } = await manager.attach("owned-fixture", "Viewer");
56
+ const { controlToken } = await manager.acquireControl(viewerToken);
57
+ const a = await manager.capture(viewerToken);
58
+ const context = {
59
+ viewerToken,
60
+ controlToken,
61
+ expected: {
62
+ sessionId: a.state.sessionId,
63
+ runtimeId: a.state.runtimeId!,
64
+ bridgeEpoch: a.state.bridgeEpoch!,
65
+ navigationGeneration: a.state.navigationGeneration,
66
+ viewportGeneration: a.state.viewportGeneration,
67
+ },
68
+ };
69
+ const target = (frame: NonNullable<typeof a.frame>) => ({
70
+ frameId: frame.frameId,
71
+ navigationGeneration: frame.navigationGeneration,
72
+ viewportGeneration: frame.viewportGeneration,
73
+ });
74
+ let current: BrowserGestureAuthority = {
75
+ ...context,
76
+ target: target(a.frame!),
77
+ };
78
+ const errors: unknown[] = [];
79
+ let decoderPending: NonNullable<typeof a.frame> | null = null;
80
+ let waited = 0;
81
+ const queue = createBrowserInputQueue({
82
+ authority: () => current,
83
+ transport: {
84
+ begin: (input) => manager.beginGesture(input),
85
+ end: (input) => manager.endGesture(input),
86
+ update: async (input) => {
87
+ // A different capture is already issued before input; its slow decoder commits after the wheel reply.
88
+ now++;
89
+ decoderPending = (await manager.capture(viewerToken)).frame!;
90
+ const result = await manager.updateGesture(input);
91
+ current = { ...context, target: target(decoderPending) };
92
+ return result;
93
+ },
94
+ },
95
+ onState: () => {},
96
+ onCursor: () => {},
97
+ onError: (error) => errors.push(error),
98
+ onFinish: () => {},
99
+ waitForFrame: async () => {
100
+ waited++;
101
+ now++;
102
+ const fresh = await manager.capture(viewerToken);
103
+ current = { ...context, target: target(fresh.frame!) };
104
+ },
105
+ });
106
+ const flush = async () => {
107
+ for (let i = 0; i < 100; i++) await Promise.resolve();
108
+ };
109
+ try {
110
+ queue.enqueue({
111
+ kind: "scroll",
112
+ point: { x: 100, y: 100, width: 1280, height: 800 },
113
+ deltaX: 0,
114
+ deltaY: 40,
115
+ });
116
+ await flush();
117
+ queue.finish();
118
+ await flush();
119
+ assert.equal(errors.length, 0);
120
+ assert.notEqual(current.target.frameId, a.frame!.frameId);
121
+ queue.enqueue({
122
+ kind: "scroll",
123
+ point: { x: 100, y: 100, width: 1280, height: 800 },
124
+ deltaX: 0,
125
+ deltaY: 40,
126
+ });
127
+ await flush();
128
+ assert.equal(waited, 1);
129
+ assert.equal(errors.length, 0);
130
+ assert.equal(calls.filter((call) => call === "mouse.wheel").length, 2);
131
+ assert.equal(calls.filter((call) => call === "input.begin").length, 2);
132
+ } finally {
133
+ queue.cancel();
134
+ manager.disconnect();
135
+ }
136
+ });
@@ -41,6 +41,7 @@ export class SupervisorClient {
41
41
  private token = "";
42
42
  private epoch = 0;
43
43
  private heartbeatTimer: NodeJS.Timeout | null = null;
44
+ private heartbeatInFlight: Promise<void> | null = null;
44
45
  private receiveBuffer = "";
45
46
  private readonly takeover: boolean;
46
47
  private reconnecting: Promise<BridgeLease> | null = null;
@@ -145,6 +146,17 @@ export class SupervisorClient {
145
146
  workspaceId,
146
147
  });
147
148
  }
149
+
150
+ async closeWorkspace(workspaceId: string, runtimeId: string): Promise<void> {
151
+ await this.ensureLease();
152
+ await this.send({
153
+ method: "workspace.close",
154
+ bridgeId: this.bridgeId,
155
+ epoch: this.epoch,
156
+ workspaceId,
157
+ runtimeId,
158
+ });
159
+ }
148
160
  async requestBrowser<Result = JsonValue>(operation: string, input: JsonValue): Promise<Result> {
149
161
  await this.ensureLease();
150
162
  return (await this.send({
@@ -248,17 +260,28 @@ export class SupervisorClient {
248
260
  private armHeartbeat(intervalMs: number): void {
249
261
  this.clearHeartbeat();
250
262
  this.heartbeatTimer = setInterval(() => {
251
- void this.send({ method: "bridge.heartbeat", bridgeId: this.bridgeId, epoch: this.epoch })
263
+ // A delayed response must not produce duplicate maintenance or let an old
264
+ // connection's completion replace or close the current bridge lease.
265
+ if (this.heartbeatInFlight) return;
266
+ const socket = this.socket;
267
+ const epoch = this.epoch;
268
+ const operation = this.send({ method: "bridge.heartbeat", bridgeId: this.bridgeId, epoch })
252
269
  .then((result) => {
270
+ if (this.socket !== socket || this.epoch !== epoch) return;
253
271
  const lease = result as BridgeLease;
254
272
  this.epoch = lease.epoch;
255
273
  this.lease = lease;
256
274
  })
257
- .catch((error: unknown) =>
275
+ .catch((error: unknown) => {
276
+ if (this.socket !== socket || this.epoch !== epoch) return;
258
277
  this.handleClose(
259
278
  error instanceof Error ? error : new Error("Supervisor heartbeat failed"),
260
- ),
261
- );
279
+ );
280
+ })
281
+ .finally(() => {
282
+ if (this.heartbeatInFlight === operation) this.heartbeatInFlight = null;
283
+ });
284
+ this.heartbeatInFlight = operation;
262
285
  }, intervalMs);
263
286
  this.heartbeatTimer.unref();
264
287
  }
@@ -267,6 +290,7 @@ export class SupervisorClient {
267
290
  if (!this.heartbeatTimer) return;
268
291
  clearInterval(this.heartbeatTimer);
269
292
  this.heartbeatTimer = null;
293
+ this.heartbeatInFlight = null;
270
294
  }
271
295
 
272
296
  private async ensureLease(): Promise<void> {