@omercnet/paseo-shared-browser 1.2.2-next.232.1 → 1.2.2-next.239.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.
Files changed (169) hide show
  1. package/README.md +312 -25
  2. package/REMOTE_BROWSER_RESEARCH.md +395 -0
  3. package/STREAMING_DESIGN.md +191 -0
  4. package/client/browser-auto-capture-density.test.ts +102 -0
  5. package/client/browser-auto-capture-density.ts +30 -0
  6. package/client/browser-canvas-input.test.ts +198 -0
  7. package/client/browser-canvas-input.ts +202 -0
  8. package/client/browser-canvas-keyboard-web.test.ts +113 -0
  9. package/client/browser-canvas-keyboard-web.ts +230 -0
  10. package/client/browser-canvas-keyboard.test.ts +123 -0
  11. package/client/browser-canvas-keyboard.ts +126 -0
  12. package/client/browser-canvas-layout.test.ts +109 -0
  13. package/client/browser-canvas-layout.ts +51 -0
  14. package/client/browser-canvas-viewport-web.test.ts +77 -0
  15. package/client/browser-canvas-viewport-web.ts +63 -0
  16. package/client/browser-canvas-viewport.tsx +152 -0
  17. package/client/browser-capture-cadence.test.ts +9 -0
  18. package/client/browser-capture-cadence.ts +6 -0
  19. package/client/browser-capture-density-controls.tsx +75 -0
  20. package/client/browser-chrome.tsx +234 -0
  21. package/client/browser-client-presence.ts +184 -0
  22. package/client/browser-compose-controls.test.tsx +275 -0
  23. package/client/browser-compose-controls.tsx +174 -0
  24. package/client/browser-control-button.tsx +109 -0
  25. package/client/browser-control-dialog.tsx +71 -0
  26. package/client/browser-control-request.test.ts +27 -0
  27. package/client/browser-control-request.ts +57 -0
  28. package/client/browser-control-tooltip-web.ts +10 -0
  29. package/client/browser-dialog-body.tsx +22 -0
  30. package/client/browser-emulation-mode.test.ts +75 -0
  31. package/client/browser-emulation-mode.ts +98 -0
  32. package/client/browser-fill-viewport.test.ts +45 -0
  33. package/client/browser-fill-viewport.ts +28 -0
  34. package/client/browser-frame-buffer.test.ts +157 -0
  35. package/client/browser-frame-buffer.ts +148 -0
  36. package/client/browser-frame-image.tsx +62 -0
  37. package/client/browser-image-load.test.ts +23 -0
  38. package/client/browser-image-load.ts +26 -0
  39. package/client/browser-input-queue.test.ts +1201 -0
  40. package/client/browser-input-queue.ts +505 -0
  41. package/client/browser-menu-placement.test.ts +90 -0
  42. package/client/browser-menu-placement.ts +78 -0
  43. package/client/browser-panel-styles.ts +384 -0
  44. package/client/browser-quality-controls.test.ts +26 -0
  45. package/client/browser-quality-controls.tsx +139 -0
  46. package/client/browser-resolution-picker.tsx +189 -0
  47. package/client/browser-tabs.tsx +117 -0
  48. package/client/browser-toolbar-menu-web.test.ts +185 -0
  49. package/client/browser-toolbar-menu-web.ts +159 -0
  50. package/client/browser-toolbar-menu.tsx +255 -0
  51. package/client/browser-toolbar-submenu.tsx +93 -0
  52. package/client/browser-video-bytes.test.ts +44 -0
  53. package/client/browser-video-decoder.test.ts +473 -0
  54. package/client/browser-video-decoder.ts +311 -0
  55. package/client/browser-video-surface.tsx +21 -0
  56. package/client/browser-video-visibility.test.ts +45 -0
  57. package/client/browser-viewer-recovery.test.ts +111 -0
  58. package/client/browser-viewer-recovery.ts +78 -0
  59. package/client/browser.tsx +1642 -1569
  60. package/client/favorite-star.tsx +23 -0
  61. package/client/frame-lifecycle.ts +62 -59
  62. package/client/use-browser-auto-capture-density.test.ts +104 -0
  63. package/client/use-browser-auto-capture-density.ts +43 -0
  64. package/client/use-browser-canvas-input.ts +370 -0
  65. package/client/use-browser-capture-density.ts +87 -0
  66. package/client/use-browser-emulation-mode.ts +56 -0
  67. package/client/use-browser-fill-viewport.test.ts +108 -0
  68. package/client/use-browser-fill-viewport.ts +62 -0
  69. package/client/use-browser-frame-buffer.ts +66 -0
  70. package/client/use-browser-image-capture.ts +139 -0
  71. package/client/use-browser-scoped-mutation.test.ts +118 -0
  72. package/client/use-browser-scoped-mutation.ts +59 -0
  73. package/client/use-browser-video.test.ts +991 -0
  74. package/client/use-browser-video.ts +474 -0
  75. package/client/use-browser-viewer-recovery.ts +26 -0
  76. package/client/use-resolution-favorites.ts +90 -0
  77. package/client/web.test.ts +125 -0
  78. package/client/web.ts +405 -0
  79. package/index.client.tsx +0 -10
  80. package/index.server.ts +32 -0
  81. package/npm-shrinkwrap.json +7 -7
  82. package/package.json +9 -5
  83. package/paseo-plugin.json +3 -1
  84. package/scripts/prepare-runtime.mjs +1 -1
  85. package/server/agent-browser-runtime.ts +1598 -314
  86. package/server/browser-cursor.test.ts +64 -0
  87. package/server/browser-cursor.ts +69 -0
  88. package/server/browser-frame-context.ts +22 -0
  89. package/server/browser-frame-epoch.test.ts +95 -0
  90. package/server/browser-frame-rejection.ts +17 -0
  91. package/server/browser-gesture.ts +120 -0
  92. package/server/browser-operation-diagnostics.test.ts +79 -0
  93. package/server/browser-operation-diagnostics.ts +103 -0
  94. package/server/browser-policy.ts +1313 -151
  95. package/server/browser-video-input-authority.test.ts +208 -0
  96. package/server/browser-video-policy.test.ts +564 -0
  97. package/server/browser-video-socket.test.ts +368 -0
  98. package/server/browser.ts +151 -16
  99. package/server/capture-transport-policy.test.ts +105 -0
  100. package/server/capture-transport-policy.ts +154 -0
  101. package/server/capture-transport-runtime.test.ts +213 -0
  102. package/server/daemon-launch-chain.test.ts +70 -0
  103. package/server/daemon-shutdown.test.ts +289 -0
  104. package/server/discrete-input-freshness.test.ts +364 -0
  105. package/server/discrete-input-native-lifetime.test.ts +150 -0
  106. package/server/display-loss-recovery.test.ts +294 -0
  107. package/server/emulation-restoration.test.ts +262 -0
  108. package/server/input-cleanup.ts +23 -0
  109. package/server/input-generation.test.ts +31 -0
  110. package/server/input-generation.ts +36 -0
  111. package/server/jpeg-capture-demand.test.ts +76 -0
  112. package/server/jpeg-capture-demand.ts +98 -0
  113. package/server/jpeg-frame.ts +29 -0
  114. package/server/keyboard-input.test.ts +61 -0
  115. package/server/keyboard-input.ts +104 -0
  116. package/server/live-input-policy.test.ts +958 -0
  117. package/server/live-input-runtime.test.ts +732 -0
  118. package/server/live-input-supervisor.test.ts +147 -0
  119. package/server/mcp-entry.ts +44 -2
  120. package/server/native-video-backpressure.test.ts +228 -0
  121. package/server/native-video-buffer.test.ts +141 -0
  122. package/server/native-video-buffer.ts +114 -0
  123. package/server/native-video-capture.test.ts +599 -0
  124. package/server/native-video-capture.ts +497 -0
  125. package/server/native-video-extension-source.test.ts +201 -0
  126. package/server/native-video-extension-source.ts +364 -0
  127. package/server/native-video-extension.test.ts +41 -0
  128. package/server/native-video-extension.ts +83 -0
  129. package/server/native-video-packet.ts +9 -0
  130. package/server/native-video-settings.test.ts +245 -0
  131. package/server/navigation-budget.ts +8 -0
  132. package/server/navigation-commit.ts +125 -0
  133. package/server/navigation-policy.test.ts +153 -0
  134. package/server/process-identity.ts +58 -0
  135. package/server/reattach-held-input.test.ts +143 -0
  136. package/server/runtime-frame-reuse.test.ts +295 -0
  137. package/server/runtime-jpeg-demand.test.ts +200 -0
  138. package/server/runtime-jpeg-shutdown.test.ts +210 -0
  139. package/server/runtime-jpeg-source-order.test.ts +201 -0
  140. package/server/runtime-launch-environment.test.ts +111 -0
  141. package/server/runtime-navigation.test.ts +327 -0
  142. package/server/runtime-owner-display.test.ts +152 -0
  143. package/server/runtime-owner.ts +305 -31
  144. package/server/runtime-page-state.test.ts +249 -0
  145. package/server/runtime-protocol.ts +31 -1
  146. package/server/runtime-target-retention.test.ts +181 -0
  147. package/server/runtime-video-opt-in.test.ts +82 -0
  148. package/server/runtime-video.test.ts +444 -0
  149. package/server/screencast-source-time.ts +46 -0
  150. package/server/stale-wheel-admission.test.ts +136 -0
  151. package/server/supervisor-client.ts +28 -4
  152. package/server/supervisor-heartbeat.test.ts +80 -0
  153. package/server/supervisor.ts +553 -55
  154. package/server/video-source-recovery.test.ts +20 -0
  155. package/server/video-source-recovery.ts +64 -0
  156. package/server/viewer-capture-lifetime.ts +51 -0
  157. package/server/virtual-display.test.ts +184 -0
  158. package/server/virtual-display.ts +236 -0
  159. package/shared/browser-display-preferences.ts +46 -0
  160. package/shared/browser-video.ts +80 -0
  161. package/shared/browser.ts +286 -65
  162. package/shared/capture-density.ts +16 -0
  163. package/shared/capture-settings.ts +17 -0
  164. package/shared/device-presets.ts +90 -0
  165. package/shared/resolution-menu.test.ts +120 -0
  166. package/shared/resolution-menu.ts +72 -0
  167. package/shared/video-settings.test.ts +38 -0
  168. package/shared/video-settings.ts +34 -0
  169. package/shared/viewport-limits.ts +5 -0
@@ -0,0 +1,289 @@
1
+ /** The session daemon is signalled only while it is still the exact process this runtime launched. */
2
+ import { type ChildProcess, spawn } from "node:child_process";
3
+ import { mkdir, mkdtemp, readFile, rm, writeFile } 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
+ import { RUNTIME_OWNER_VARIABLE } from "./process-identity";
9
+
10
+ const probe = vi.hoisted(() => ({
11
+ unreadable: false,
12
+ /** Simulated /proc ownership for platforms without it: pid -> start ticks. */
13
+ simulated: null as Map<number, string> | null,
14
+ }));
15
+ vi.mock("./process-identity", async (importOriginal) => {
16
+ const actual = await importOriginal<typeof import("./process-identity")>();
17
+ return {
18
+ ...actual,
19
+ probeProcess: (pid: number, nonce: string) => {
20
+ if (probe.unreadable) return Promise.resolve({ status: "unknown" as const });
21
+ if (!probe.simulated) return actual.probeProcess(pid, nonce);
22
+ // Simulated ownership (platforms without /proc): a stable start time for owned pids,
23
+ // real liveness so a killed child is observed absent rather than owned forever.
24
+ if (!actual.processExists(pid)) return Promise.resolve({ status: "absent" as const });
25
+ const startTicks = probe.simulated.get(pid);
26
+ return Promise.resolve(
27
+ startTicks === undefined
28
+ ? { status: "foreign" as const }
29
+ : { status: "owned" as const, identity: { pid, startTicks } },
30
+ );
31
+ },
32
+ };
33
+ });
34
+
35
+ const cli = vi.hoisted(() => ({ onClose: null as null | (() => void), closes: 0 }));
36
+ vi.mock("node:child_process", async (importOriginal) => ({
37
+ ...(await importOriginal<typeof import("node:child_process")>()),
38
+ execFile: (
39
+ _file: string,
40
+ args: string[],
41
+ _options: unknown,
42
+ callback: (error: null, result: { stdout: string }) => void,
43
+ ) => {
44
+ if (args.includes("close")) {
45
+ cli.closes += 1;
46
+ cli.onClose?.();
47
+ }
48
+ callback(null, { stdout: args.includes("--version") ? "0.37.1\n" : "{}\n" });
49
+ },
50
+ }));
51
+
52
+ const children: ChildProcess[] = [];
53
+ const directories: string[] = [];
54
+ afterEach(async () => {
55
+ for (const child of children.splice(0)) child.kill("SIGKILL");
56
+ await Promise.all(
57
+ directories.splice(0).map((path) => rm(path, { recursive: true, force: true })),
58
+ );
59
+ cli.onClose = null;
60
+ cli.closes = 0;
61
+ probe.unreadable = false;
62
+ probe.simulated = null;
63
+ vi.restoreAllMocks();
64
+ });
65
+
66
+ /** A harmless process this test owns; `nonce` makes it descend from the runtime's own launch. */
67
+ async function ownChild(nonce?: string): Promise<ChildProcess> {
68
+ const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], {
69
+ stdio: "ignore",
70
+ env: nonce ? { ...process.env, [RUNTIME_OWNER_VARIABLE]: nonce } : process.env,
71
+ });
72
+ children.push(child);
73
+ await new Promise((resolve) => child.once("spawn", resolve));
74
+ return child;
75
+ }
76
+
77
+ const alive = (pid: number) => {
78
+ try {
79
+ process.kill(pid, 0);
80
+ return true;
81
+ } catch {
82
+ return false;
83
+ }
84
+ };
85
+ const exited = (child: ChildProcess) =>
86
+ child.exitCode !== null || child.signalCode !== null
87
+ ? Promise.resolve()
88
+ : new Promise((resolve) => child.once("exit", resolve));
89
+
90
+ async function fixture(waitMs = 400) {
91
+ const directory = await mkdtemp(join(tmpdir(), "shared-browser-daemon-"));
92
+ directories.push(directory);
93
+ const ipc = join(directory, "ipc");
94
+ const profile = join(directory, "profile");
95
+ await mkdir(ipc, { recursive: true });
96
+ const runtime = new AgentBrowserRuntime({
97
+ binaryPath: process.execPath,
98
+ executablePath: join(directory, "unlaunched-chromium"),
99
+ profilePath: profile,
100
+ ipcDirectory: ipc,
101
+ session: "owned",
102
+ daemonExitWaitMs: waitMs,
103
+ });
104
+ const native = runtime as unknown as {
105
+ ownerNonce: string;
106
+ daemonLaunched: boolean;
107
+ captureDaemon(): Promise<void>;
108
+ daemon: { pid: number; identity: { pid: number; startTicks: string } | null } | null;
109
+ };
110
+ const recordPid = async (pid: number) => {
111
+ await mkdir(profile, { recursive: true });
112
+ await writeFile(join(ipc, "owned.pid"), String(pid));
113
+ await writeFile(join(ipc, "owned.sock"), "");
114
+ native.daemonLaunched = true;
115
+ await native.captureDaemon();
116
+ };
117
+ return { runtime, native, recordPid, ipc, profile };
118
+ }
119
+
120
+ describe.skipIf(process.platform !== "linux")("owned daemon shutdown", () => {
121
+ it("waits for the acknowledged close to finish the daemon without signalling it, preserving the profile", async () => {
122
+ const f = await fixture();
123
+ const daemon = await ownChild(f.native.ownerNonce);
124
+ await f.recordPid(daemon.pid as number);
125
+ expect(f.native.daemon?.identity?.pid).toBe(daemon.pid);
126
+ const kill = vi.spyOn(process, "kill");
127
+ cli.onClose = () => void setTimeout(() => daemon.kill("SIGTERM"), 100);
128
+ await f.runtime.shutdown();
129
+ await exited(daemon);
130
+ expect(kill).not.toHaveBeenCalledWith(daemon.pid, "SIGKILL");
131
+ await expect(readFile(join(f.ipc, "owned.sock"))).rejects.toThrow();
132
+ await expect(readFile(join(f.profile, "."))).rejects.toMatchObject({ code: "EISDIR" });
133
+ });
134
+
135
+ it("force-terminates a daemon that outlives the bounded wait and confirms exit before returning", async () => {
136
+ const f = await fixture(200);
137
+ const daemon = await ownChild(f.native.ownerNonce);
138
+ await f.recordPid(daemon.pid as number);
139
+ const kill = vi.spyOn(process, "kill");
140
+ await f.runtime.shutdown();
141
+ // exit was confirmed before shutdown returned; the OS only has to deliver the status
142
+ await exited(daemon);
143
+ // Portable contract (signalCode is POSIX-only): the runtime asked for SIGKILL of the
144
+ // owned pid, and that process is observably gone.
145
+ expect(kill).toHaveBeenCalledWith(daemon.pid, "SIGKILL");
146
+ expect(daemon.pid && alive(daemon.pid)).toBe(false);
147
+ expect(cli.closes).toBe(1);
148
+ await expect(readFile(join(f.ipc, "owned.pid"))).rejects.toThrow();
149
+ });
150
+
151
+ it("fails shutdown instead of reporting success when a signalled daemon does not exit", async () => {
152
+ const f = await fixture(200);
153
+ const daemon = await ownChild(f.native.ownerNonce);
154
+ await f.recordPid(daemon.pid as number);
155
+ const kill = vi.spyOn(process, "kill").mockImplementation(() => true);
156
+ await expect(f.runtime.shutdown(true)).rejects.toThrow("not confirmed");
157
+ expect(kill).toHaveBeenCalledWith(daemon.pid, "SIGKILL");
158
+ expect(daemon.pid && alive(daemon.pid)).toBe(true);
159
+ });
160
+
161
+ it("never signals a verified PID that was recycled after capture", async () => {
162
+ const f = await fixture(200);
163
+ const recorded = await ownChild(f.native.ownerNonce);
164
+ await f.recordPid(recorded.pid as number);
165
+ const identity = f.native.daemon?.identity;
166
+ expect(identity).toBeTruthy();
167
+ // Same PID, different birth: the recorded start time no longer matches.
168
+ f.native.daemon = {
169
+ pid: recorded.pid as number,
170
+ identity: { ...(identity as { pid: number; startTicks: string }), startTicks: "1" },
171
+ };
172
+ const kill = vi.spyOn(process, "kill");
173
+ await f.runtime.shutdown(true);
174
+ expect(kill).not.toHaveBeenCalledWith(recorded.pid, "SIGKILL");
175
+ expect(recorded.pid && alive(recorded.pid)).toBe(true);
176
+ });
177
+
178
+ it("does not own a same-executable process from another session named by the PID file", async () => {
179
+ const f = await fixture(200);
180
+ // Same executable as the runtime's own children, but not launched by this runtime.
181
+ const otherSession = await ownChild();
182
+ await f.recordPid(otherSession.pid as number);
183
+ expect(f.native.daemon?.identity).toBeNull();
184
+ const kill = vi.spyOn(process, "kill");
185
+ await expect(f.runtime.shutdown(true)).rejects.toThrow("not confirmed");
186
+ expect(kill).not.toHaveBeenCalledWith(otherSession.pid, "SIGKILL");
187
+ expect(otherSession.pid && alive(otherSession.pid)).toBe(true);
188
+ });
189
+ });
190
+
191
+ describe("unverifiable daemon identity (platforms without /proc, unowned PIDs)", () => {
192
+ it("accepts a graceful close once the recorded process is observed gone, without signalling", async () => {
193
+ const f = await fixture();
194
+ const daemon = await ownChild();
195
+ await f.recordPid(daemon.pid as number);
196
+ const kill = vi.spyOn(process, "kill");
197
+ cli.onClose = () => void setTimeout(() => daemon.kill("SIGTERM"), 100);
198
+ await f.runtime.shutdown();
199
+ expect(kill).not.toHaveBeenCalledWith(daemon.pid, "SIGKILL");
200
+ await expect(readFile(join(f.ipc, "owned.sock"))).rejects.toThrow();
201
+ });
202
+
203
+ it("neither signals nor reports success when the recorded process stays alive", async () => {
204
+ const f = await fixture(150);
205
+ const daemon = await ownChild();
206
+ await f.recordPid(daemon.pid as number);
207
+ const kill = vi.spyOn(process, "kill");
208
+ await expect(f.runtime.shutdown()).rejects.toThrow("not confirmed");
209
+ expect(kill).not.toHaveBeenCalledWith(daemon.pid, "SIGKILL");
210
+ });
211
+
212
+ it("has nothing to observe or signal for a runtime that never launched", async () => {
213
+ // Never launched: ordinary cleanup has nothing to observe.
214
+ const f = await fixture(150);
215
+ await f.native.captureDaemon();
216
+ expect(f.native.daemon).toBeNull();
217
+ await expect(f.runtime.shutdown()).resolves.toBeUndefined();
218
+ });
219
+ });
220
+
221
+ /** Real /proc ownership on Linux; an explicit simulation of it everywhere (macOS, Windows have no /proc). */
222
+ const ownershipModes =
223
+ process.platform === "linux" ? (["real", "simulated"] as const) : (["simulated"] as const);
224
+ describe.each(ownershipModes)(
225
+ "unknown daemon state is never confirmed exit (%s ownership)",
226
+ (mode) => {
227
+ /** Marks a child as this runtime's own daemon when ownership is simulated. */
228
+ const own = (daemon: ChildProcess) => {
229
+ if (mode === "simulated") probe.simulated = new Map([[daemon.pid as number, "42"]]);
230
+ };
231
+ it("fails closed, then recovers once the PID record is observable, when capture found no PID", async () => {
232
+ const f = await fixture(150);
233
+ const daemon = await ownChild(f.native.ownerNonce);
234
+ own(daemon);
235
+ f.native.daemonLaunched = true; // launched, but the PID file is unreadable at capture
236
+ await f.native.captureDaemon();
237
+ expect(f.native.daemon).toBeNull();
238
+ const kill = vi.spyOn(process, "kill");
239
+ await expect(f.runtime.shutdown()).rejects.toThrow("not confirmed");
240
+ await expect(f.runtime.shutdown(true)).rejects.toThrow("not confirmed");
241
+ expect(kill).not.toHaveBeenCalledWith(daemon.pid, "SIGKILL");
242
+ expect(daemon.pid && alive(daemon.pid)).toBe(true);
243
+ // The record becomes readable again: shutdown re-captures, re-verifies and finishes.
244
+ await writeFile(join(f.ipc, "owned.pid"), String(daemon.pid));
245
+ await f.runtime.shutdown(true);
246
+ await exited(daemon);
247
+ // Portable contract (signalCode is POSIX-only): the runtime asked for SIGKILL of the
248
+ // owned pid, and that process is observably gone.
249
+ expect(kill).toHaveBeenCalledWith(daemon.pid, "SIGKILL");
250
+ expect(daemon.pid && alive(daemon.pid)).toBe(false);
251
+ });
252
+
253
+ it("does not treat a later unreadable identity as exit and never signals on it", async () => {
254
+ const f = await fixture(150);
255
+ const daemon = await ownChild(f.native.ownerNonce);
256
+ own(daemon);
257
+ await f.recordPid(daemon.pid as number);
258
+ expect(f.native.daemon?.identity).toBeTruthy();
259
+ probe.unreadable = true;
260
+ const kill = vi.spyOn(process, "kill");
261
+ await expect(f.runtime.shutdown()).rejects.toThrow("not confirmed");
262
+ await expect(f.runtime.shutdown(true)).rejects.toThrow("not confirmed");
263
+ expect(kill).not.toHaveBeenCalledWith(daemon.pid, "SIGKILL");
264
+ expect(daemon.pid && alive(daemon.pid)).toBe(true);
265
+ await expect(readFile(join(f.ipc, "owned.sock"))).resolves.toBeDefined(); // metadata kept
266
+ // Identity is readable again: the original verification authorises the forced exit.
267
+ probe.unreadable = false;
268
+ await f.runtime.shutdown(true);
269
+ await exited(daemon);
270
+ // Portable contract (signalCode is POSIX-only): the runtime asked for SIGKILL of the
271
+ // owned pid, and that process is observably gone.
272
+ expect(kill).toHaveBeenCalledWith(daemon.pid, "SIGKILL");
273
+ expect(daemon.pid && alive(daemon.pid)).toBe(false);
274
+ });
275
+
276
+ it("shares one in-flight shutdown and never reports success after a failed one", async () => {
277
+ const f = await fixture(150);
278
+ const daemon = await ownChild();
279
+ await f.recordPid(daemon.pid as number); // unowned: observable, not signalled
280
+ const outcomes = await Promise.allSettled([f.runtime.shutdown(), f.runtime.shutdown()]);
281
+ expect(outcomes.map((outcome) => outcome.status)).toEqual(["rejected", "rejected"]);
282
+ await expect(f.runtime.shutdown()).rejects.toThrow("not confirmed");
283
+ daemon.kill("SIGKILL");
284
+ await exited(daemon);
285
+ await expect(f.runtime.shutdown()).resolves.toBeUndefined();
286
+ await expect(f.runtime.shutdown()).resolves.toBeUndefined();
287
+ });
288
+ },
289
+ );
@@ -0,0 +1,364 @@
1
+ /** Exercises real capture/input policy with delayed native replies, without starting a daemon. */
2
+ import { expect, it } from "vitest";
3
+ import type { BrowserInputEvent, BrowserState } from "../shared/browser";
4
+ import type { BrowserFrameAuthority } from "../shared/browser-video";
5
+ import { SessionManager } from "./browser-policy";
6
+
7
+ /** One owned document and injected elapsed clock make publication boundaries observable. */
8
+ async function fixture() {
9
+ let clock = 1_000;
10
+ let monotonicClock = 1_000;
11
+ let ids = 0;
12
+ let document = "1:1";
13
+ let intercept: (operation: string) => void = () => {};
14
+ const calls: string[] = [];
15
+ const manager = new SessionManager({
16
+ now: () => clock,
17
+ monotonicNow: () => monotonicClock,
18
+ validateWorkspace: async () => true,
19
+ issueToken: () => String(++ids).padStart(32, "0"),
20
+ client: {
21
+ connect: async () => ({ epoch: 1 }),
22
+ ensureWorkspace: async (workspaceId) => ({
23
+ workspaceId,
24
+ runtimeId: "r".repeat(32),
25
+ createdAt: 1,
26
+ }),
27
+ archiveWorkspace: async () => {},
28
+ ...{ closeWorkspace: async () => {} },
29
+ disconnect: () => {},
30
+ requestWorkspace: async (_workspace, operation) => {
31
+ calls.push(operation);
32
+ intercept(operation);
33
+ if (operation === "identity") return { userAgent: "Fixture" };
34
+ if (operation === "state") {
35
+ return {
36
+ url: "https://fixture.invalid/",
37
+ title: "Fixture",
38
+ inputGeneration: document,
39
+ canGoBack: false,
40
+ canGoForward: false,
41
+ };
42
+ }
43
+ if (operation === "frame") {
44
+ return {
45
+ dataBase64: "eA==",
46
+ byteLength: 1,
47
+ width: 1280,
48
+ height: 800,
49
+ capturedAt: new Date(clock).toISOString(),
50
+ };
51
+ }
52
+ if (operation === "video.read") {
53
+ return {
54
+ status: "ready",
55
+ streamId: "v".repeat(32),
56
+ inputGeneration: document,
57
+ packets: [
58
+ {
59
+ streamId: "v".repeat(32),
60
+ captureGeneration: 1,
61
+ sequence: 1,
62
+ timestampUs: 1000,
63
+ type: "key",
64
+ codec: "vp8",
65
+ width: 1280,
66
+ height: 800,
67
+ capturedAt: new Date(clock).toISOString(),
68
+ capturedAtMonotonicMs: monotonicClock,
69
+ dataBase64: "AA==",
70
+ },
71
+ ],
72
+ };
73
+ }
74
+ return null;
75
+ },
76
+ },
77
+ });
78
+ await manager.connect();
79
+ const { viewerToken } = await manager.attach("owned", "Fixture");
80
+ const { controlToken } = await manager.acquireControl(viewerToken);
81
+ return {
82
+ manager,
83
+ viewerToken,
84
+ calls,
85
+ advance: (milliseconds: number) => {
86
+ clock += milliseconds;
87
+ monotonicClock += milliseconds;
88
+ },
89
+ advanceWall: (milliseconds: number) => {
90
+ clock += milliseconds;
91
+ },
92
+ advanceMonotonic: (milliseconds: number) => {
93
+ monotonicClock += milliseconds;
94
+ },
95
+ replaceDocument: () => {
96
+ document = "1:2";
97
+ },
98
+ intercept: (callback: (operation: string) => void) => {
99
+ intercept = callback;
100
+ },
101
+ capture: () => manager.capture(viewerToken),
102
+ videoCapture: async () => {
103
+ const reply = await manager.readVideo({
104
+ viewerToken,
105
+ quality: "high",
106
+ streamId: null,
107
+ afterSequence: 0,
108
+ waitMs: 0,
109
+ requestKeyFrame: true,
110
+ });
111
+ return { state: reply.state, frame: reply.packets[0]!.frame };
112
+ },
113
+ send: (
114
+ capture: { state: BrowserState; frame: BrowserFrameAuthority | null },
115
+ event: BrowserInputEvent,
116
+ ) => {
117
+ const state = capture.state;
118
+ return manager.sendInput({
119
+ viewerToken,
120
+ controlToken,
121
+ expected: {
122
+ sessionId: state.sessionId,
123
+ runtimeId: state.runtimeId!,
124
+ bridgeEpoch: state.bridgeEpoch!,
125
+ navigationGeneration: state.navigationGeneration,
126
+ viewportGeneration: state.viewportGeneration,
127
+ },
128
+ target: capture.frame!,
129
+ event,
130
+ });
131
+ },
132
+ };
133
+ }
134
+
135
+ const click: BrowserInputEvent = {
136
+ kind: "click",
137
+ point: { x: 100, y: 100, width: 1280, height: 800 },
138
+ button: "left",
139
+ clickCount: 1,
140
+ };
141
+
142
+ it("refuses a capture whose final metadata replaced the same-URL document", async () => {
143
+ const f = await fixture();
144
+ try {
145
+ let pixelsCaptured = false;
146
+ f.intercept((operation) => {
147
+ if (operation === "frame") pixelsCaptured = true;
148
+ if (operation === "state" && pixelsCaptured) f.replaceDocument();
149
+ });
150
+ await expect(f.capture()).rejects.toThrow("changed during capture");
151
+ expect(f.calls).not.toContain("mouse.down");
152
+ } finally {
153
+ f.manager.disconnect();
154
+ }
155
+ });
156
+
157
+ it("refuses capture authority already expired while final metadata was pending", async () => {
158
+ const f = await fixture();
159
+ try {
160
+ let pixelsCaptured = false;
161
+ f.intercept((operation) => {
162
+ if (operation === "frame") pixelsCaptured = true;
163
+ if (operation === "state" && pixelsCaptured) f.advance(6_000);
164
+ });
165
+ await expect(f.capture()).rejects.toThrow("expired during validation");
166
+ } finally {
167
+ f.manager.disconnect();
168
+ }
169
+ });
170
+
171
+ it("does not extend capture age by the time spent validating metadata", async () => {
172
+ const f = await fixture();
173
+ try {
174
+ let pixelsCaptured = false;
175
+ f.intercept((operation) => {
176
+ if (operation === "frame") pixelsCaptured = true;
177
+ if (operation === "state" && pixelsCaptured) f.advance(4_000);
178
+ });
179
+ const capture = await f.capture();
180
+ f.intercept(() => {});
181
+ f.advance(1_001);
182
+ await expect(f.send(capture, click)).rejects.toThrow(
183
+ "Browser frame is stale (stage=admission; reason=expired; ageMs=5001)",
184
+ );
185
+ expect(f.calls).not.toContain("mouse.down");
186
+ } finally {
187
+ f.manager.disconnect();
188
+ }
189
+ });
190
+
191
+ for (const delayedOperation of ["mouse.move", "mouse.down"]) {
192
+ it(`finishes one admitted click after an eight-second ${delayedOperation} reply`, async () => {
193
+ const f = await fixture();
194
+ try {
195
+ const capture = await f.capture();
196
+ f.intercept((operation) => {
197
+ if (operation === delayedOperation) f.advance(8_000);
198
+ });
199
+ await expect(f.send(capture, click)).resolves.toHaveProperty("state");
200
+ expect(f.calls.filter((operation) => operation === "mouse.down")).toHaveLength(1);
201
+ expect(f.calls.filter((operation) => operation === "mouse.up")).toHaveLength(1);
202
+ } finally {
203
+ f.manager.disconnect();
204
+ }
205
+ });
206
+ }
207
+
208
+ it("still refuses an expired frame before publishing input", async () => {
209
+ const f = await fixture();
210
+ try {
211
+ const capture = await f.capture();
212
+ f.advance(5_000);
213
+ await expect(f.send(capture, click)).rejects.toThrow(
214
+ "Browser frame is stale (stage=admission; reason=expired; ageMs=5000)",
215
+ );
216
+ expect(
217
+ f.calls.filter((operation) => /^(?:mouse\.|key\.|text\.|input\.)/.test(operation)),
218
+ ).toEqual([]);
219
+ } finally {
220
+ f.manager.disconnect();
221
+ }
222
+ });
223
+
224
+ it("refuses a document replacement after down while releasing the original press once", async () => {
225
+ const f = await fixture();
226
+ try {
227
+ const capture = await f.capture();
228
+ f.intercept((operation) => {
229
+ if (operation === "mouse.down") {
230
+ f.advance(8_000);
231
+ f.replaceDocument();
232
+ }
233
+ });
234
+ await expect(f.send(capture, click)).rejects.toThrow(
235
+ "Browser frame is stale (stage=admitted-input; reason=context-changed)",
236
+ );
237
+ expect(f.calls.filter((operation) => operation === "mouse.down")).toHaveLength(1);
238
+ expect(f.calls.filter((operation) => operation === "mouse.up")).toHaveLength(1);
239
+ expect(f.calls.filter((operation) => operation === "input.end")).toHaveLength(1);
240
+ } finally {
241
+ f.manager.disconnect();
242
+ }
243
+ });
244
+
245
+ it("refuses bridge replacement during movement before any button press", async () => {
246
+ const f = await fixture();
247
+ try {
248
+ const capture = await f.capture();
249
+ f.intercept((operation) => {
250
+ if (operation === "mouse.move") f.manager.setBridgeEpoch(2);
251
+ });
252
+ await expect(f.send(capture, click)).rejects.toThrow("stale");
253
+ expect(f.calls).not.toContain("mouse.down");
254
+ } finally {
255
+ f.manager.disconnect();
256
+ }
257
+ });
258
+
259
+ it("does not extend a control lease while a discrete action is awaiting native work", async () => {
260
+ const f = await fixture();
261
+ try {
262
+ const capture = await f.capture();
263
+ f.intercept((operation) => {
264
+ if (operation === "mouse.move") f.advance(30_000);
265
+ });
266
+ await expect(f.send(capture, click)).rejects.toThrow("control lease");
267
+ expect(f.calls).not.toContain("mouse.down");
268
+ } finally {
269
+ f.manager.disconnect();
270
+ }
271
+ });
272
+
273
+ it("revokes the admitted observation when a published press loses its acknowledgement", async () => {
274
+ const f = await fixture();
275
+ try {
276
+ const capture = await f.capture();
277
+ f.intercept((operation) => {
278
+ if (operation === "mouse.down") throw new Error("Mutation outcome is unknown");
279
+ });
280
+ await expect(f.send(capture, click)).rejects.toThrow("outcome is unknown");
281
+ f.intercept(() => {});
282
+ const operationsBeforeReplay = f.calls.length;
283
+ await expect(f.send(capture, click)).rejects.toThrow(
284
+ "Browser frame is stale (stage=admission; reason=missing-or-revoked)",
285
+ );
286
+ expect(f.calls.slice(operationsBeforeReplay)).toEqual([]);
287
+ expect(f.calls.filter((operation) => operation === "mouse.down")).toHaveLength(1);
288
+ } finally {
289
+ f.manager.disconnect();
290
+ }
291
+ });
292
+
293
+ /** Admission diagnostics must describe the same clock and receipt the policy refuses. */
294
+ for (const skew of [-8_000, 8_000]) {
295
+ it(`expires video authority at five monotonic seconds despite wall skew ${skew}`, async () => {
296
+ const f = await fixture();
297
+ try {
298
+ const capture = await f.videoCapture();
299
+ f.advanceWall(skew);
300
+ f.advanceMonotonic(5_000);
301
+ await expect(f.send(capture, click)).rejects.toThrow(
302
+ "Browser frame is stale (stage=admission; reason=expired; ageMs=5000)",
303
+ );
304
+ expect(
305
+ f.calls.filter((operation) => /^(?:mouse\.|key\.|text\.|input\.)/.test(operation)),
306
+ ).toEqual([]);
307
+ } finally {
308
+ f.manager.disconnect();
309
+ }
310
+ });
311
+ }
312
+
313
+ it("admits video authority before monotonic expiry despite a forward wall-clock jump", async () => {
314
+ const f = await fixture();
315
+ try {
316
+ const capture = await f.videoCapture();
317
+ f.advanceWall(8_000);
318
+ f.advanceMonotonic(4_999);
319
+ await expect(f.send(capture, click)).resolves.toHaveProperty("state");
320
+ expect(f.calls.filter((operation) => operation === "mouse.down")).toHaveLength(1);
321
+ expect(f.calls.filter((operation) => operation === "mouse.up")).toHaveLength(1);
322
+ } finally {
323
+ f.manager.disconnect();
324
+ }
325
+ });
326
+
327
+ it("reports a known generation mismatch with age without publishing native input", async () => {
328
+ const f = await fixture();
329
+ try {
330
+ const capture = await f.capture();
331
+ f.advance(25);
332
+ const changed = {
333
+ ...capture,
334
+ frame: { ...capture.frame!, navigationGeneration: capture.frame!.navigationGeneration + 1 },
335
+ };
336
+ await expect(f.send(changed, click)).rejects.toThrow(
337
+ "Browser frame is stale (stage=admission; reason=context-changed; ageMs=25)",
338
+ );
339
+ expect(
340
+ f.calls.filter((operation) => /^(?:mouse\.|key\.|text\.|input\.)/.test(operation)),
341
+ ).toEqual([]);
342
+ } finally {
343
+ f.manager.disconnect();
344
+ }
345
+ });
346
+
347
+ it("preserves expiry evidence before pruning and reports the retired receipt without age", async () => {
348
+ const f = await fixture();
349
+ try {
350
+ const capture = await f.capture();
351
+ f.advance(5_000);
352
+ await expect(f.send(capture, click)).rejects.toThrow(
353
+ "Browser frame is stale (stage=admission; reason=expired; ageMs=5000)",
354
+ );
355
+ await expect(f.send(capture, click)).rejects.toThrow(
356
+ "Browser frame is stale (stage=admission; reason=missing-or-revoked)",
357
+ );
358
+ expect(
359
+ f.calls.filter((operation) => /^(?:mouse\.|key\.|text\.|input\.)/.test(operation)),
360
+ ).toEqual([]);
361
+ } finally {
362
+ f.manager.disconnect();
363
+ }
364
+ });