@omercnet/paseo-shared-browser 1.2.2-next.232.1 → 1.2.2-next.237.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 (168) 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/package.json +9 -5
  82. package/paseo-plugin.json +3 -1
  83. package/scripts/prepare-runtime.mjs +1 -1
  84. package/server/agent-browser-runtime.ts +1598 -314
  85. package/server/browser-cursor.test.ts +64 -0
  86. package/server/browser-cursor.ts +69 -0
  87. package/server/browser-frame-context.ts +22 -0
  88. package/server/browser-frame-epoch.test.ts +95 -0
  89. package/server/browser-frame-rejection.ts +17 -0
  90. package/server/browser-gesture.ts +120 -0
  91. package/server/browser-operation-diagnostics.test.ts +79 -0
  92. package/server/browser-operation-diagnostics.ts +103 -0
  93. package/server/browser-policy.ts +1313 -151
  94. package/server/browser-video-input-authority.test.ts +208 -0
  95. package/server/browser-video-policy.test.ts +564 -0
  96. package/server/browser-video-socket.test.ts +368 -0
  97. package/server/browser.ts +151 -16
  98. package/server/capture-transport-policy.test.ts +105 -0
  99. package/server/capture-transport-policy.ts +154 -0
  100. package/server/capture-transport-runtime.test.ts +213 -0
  101. package/server/daemon-launch-chain.test.ts +70 -0
  102. package/server/daemon-shutdown.test.ts +289 -0
  103. package/server/discrete-input-freshness.test.ts +364 -0
  104. package/server/discrete-input-native-lifetime.test.ts +150 -0
  105. package/server/display-loss-recovery.test.ts +294 -0
  106. package/server/emulation-restoration.test.ts +262 -0
  107. package/server/input-cleanup.ts +23 -0
  108. package/server/input-generation.test.ts +31 -0
  109. package/server/input-generation.ts +36 -0
  110. package/server/jpeg-capture-demand.test.ts +76 -0
  111. package/server/jpeg-capture-demand.ts +98 -0
  112. package/server/jpeg-frame.ts +29 -0
  113. package/server/keyboard-input.test.ts +61 -0
  114. package/server/keyboard-input.ts +104 -0
  115. package/server/live-input-policy.test.ts +958 -0
  116. package/server/live-input-runtime.test.ts +732 -0
  117. package/server/live-input-supervisor.test.ts +147 -0
  118. package/server/mcp-entry.ts +44 -2
  119. package/server/native-video-backpressure.test.ts +228 -0
  120. package/server/native-video-buffer.test.ts +141 -0
  121. package/server/native-video-buffer.ts +114 -0
  122. package/server/native-video-capture.test.ts +599 -0
  123. package/server/native-video-capture.ts +497 -0
  124. package/server/native-video-extension-source.test.ts +201 -0
  125. package/server/native-video-extension-source.ts +364 -0
  126. package/server/native-video-extension.test.ts +41 -0
  127. package/server/native-video-extension.ts +83 -0
  128. package/server/native-video-packet.ts +9 -0
  129. package/server/native-video-settings.test.ts +245 -0
  130. package/server/navigation-budget.ts +8 -0
  131. package/server/navigation-commit.ts +125 -0
  132. package/server/navigation-policy.test.ts +153 -0
  133. package/server/process-identity.ts +58 -0
  134. package/server/reattach-held-input.test.ts +143 -0
  135. package/server/runtime-frame-reuse.test.ts +295 -0
  136. package/server/runtime-jpeg-demand.test.ts +200 -0
  137. package/server/runtime-jpeg-shutdown.test.ts +210 -0
  138. package/server/runtime-jpeg-source-order.test.ts +201 -0
  139. package/server/runtime-launch-environment.test.ts +111 -0
  140. package/server/runtime-navigation.test.ts +327 -0
  141. package/server/runtime-owner-display.test.ts +152 -0
  142. package/server/runtime-owner.ts +305 -31
  143. package/server/runtime-page-state.test.ts +249 -0
  144. package/server/runtime-protocol.ts +31 -1
  145. package/server/runtime-target-retention.test.ts +181 -0
  146. package/server/runtime-video-opt-in.test.ts +82 -0
  147. package/server/runtime-video.test.ts +444 -0
  148. package/server/screencast-source-time.ts +46 -0
  149. package/server/stale-wheel-admission.test.ts +136 -0
  150. package/server/supervisor-client.ts +28 -4
  151. package/server/supervisor-heartbeat.test.ts +80 -0
  152. package/server/supervisor.ts +553 -55
  153. package/server/video-source-recovery.test.ts +20 -0
  154. package/server/video-source-recovery.ts +64 -0
  155. package/server/viewer-capture-lifetime.ts +51 -0
  156. package/server/virtual-display.test.ts +184 -0
  157. package/server/virtual-display.ts +236 -0
  158. package/shared/browser-display-preferences.ts +46 -0
  159. package/shared/browser-video.ts +80 -0
  160. package/shared/browser.ts +286 -65
  161. package/shared/capture-density.ts +16 -0
  162. package/shared/capture-settings.ts +17 -0
  163. package/shared/device-presets.ts +90 -0
  164. package/shared/resolution-menu.test.ts +120 -0
  165. package/shared/resolution-menu.ts +72 -0
  166. package/shared/video-settings.test.ts +38 -0
  167. package/shared/video-settings.ts +34 -0
  168. package/shared/viewport-limits.ts +5 -0
@@ -0,0 +1,564 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import type { BrowserVideoReadInput, NativeVideoPacket } from "../shared/browser-video";
3
+ import { type BrowserRuntimeClient, SessionManager } from "./browser-policy";
4
+ import type { JsonValue } from "./runtime-protocol";
5
+
6
+ const managers: SessionManager[] = [];
7
+ afterEach(() => {
8
+ for (const manager of managers.splice(0)) manager.disconnect();
9
+ });
10
+ function fixture(monotonicStart?: number) {
11
+ let now = Date.parse("2026-10-03T12:00:00.000Z");
12
+ let monotonicNow = monotonicStart ?? now;
13
+ let token = 0;
14
+ let generation = "1:1";
15
+ let url = "https://fixture.invalid/";
16
+ let wait: Promise<void> | null = null;
17
+ let arrived = () => {};
18
+ let failBarrier = false;
19
+ let readStatus: "ready" | "waiting" | "unsupported" | "throw" = "ready";
20
+ const calls: string[] = [];
21
+ const videoInputs: unknown[] = [];
22
+ const stateInputs: unknown[] = [];
23
+ let metadataError: Error | null = null;
24
+ let duringMetadata: (() => void) | null = null;
25
+ let capacity = false;
26
+ const packet: NativeVideoPacket & { capturedAtMonotonicMs: number } = {
27
+ streamId: "v".repeat(32),
28
+ captureGeneration: 1,
29
+ sequence: 1,
30
+ timestampUs: 1000,
31
+ type: "key",
32
+ codec: "vp8",
33
+ width: 1280,
34
+ height: 800,
35
+ capturedAt: new Date(now).toISOString(),
36
+ capturedAtMonotonicMs: monotonicNow,
37
+ dataBase64: "AA==",
38
+ };
39
+ const client: BrowserRuntimeClient = {
40
+ connect: async () => ({ epoch: 1 }),
41
+ ensureWorkspace: async (workspaceId) => ({
42
+ workspaceId,
43
+ runtimeId: "r".repeat(32),
44
+ createdAt: now,
45
+ }),
46
+ archiveWorkspace: async () => {},
47
+ closeWorkspace: async () => {},
48
+ disconnect() {},
49
+ async requestWorkspace(_workspaceId, operation, params) {
50
+ calls.push(operation);
51
+ if (operation === "video.invalidate" && failBarrier) throw new Error("Fixture media failure");
52
+ if (operation === "identity") return { userAgent: "Fixture" };
53
+ if (operation === "state") {
54
+ stateInputs.push(params);
55
+ duringMetadata?.();
56
+ if (metadataError) throw metadataError;
57
+ return {
58
+ url,
59
+ title: "Fixture",
60
+ inputGeneration: generation,
61
+ canGoBack: false,
62
+ canGoForward: false,
63
+ };
64
+ }
65
+ if (operation === "video.read") {
66
+ videoInputs.push(params);
67
+ arrived();
68
+ await wait;
69
+ if (readStatus === "throw") throw new Error("Fixture startup failed");
70
+ return {
71
+ status: readStatus,
72
+ ...(capacity
73
+ ? { reasonCode: "encoder-capacity", reason: "Video encoder capacity is busy" }
74
+ : {}),
75
+ streamId: packet.streamId,
76
+ inputGeneration: generation,
77
+ packets: readStatus === "ready" ? [packet] : [],
78
+ } as unknown as JsonValue;
79
+ }
80
+ return null;
81
+ },
82
+ };
83
+ const manager = new SessionManager({
84
+ client,
85
+ validateWorkspace: async () => true,
86
+ now: () => now,
87
+ monotonicNow: () => monotonicNow,
88
+ issueToken: () => `t${String(++token).padStart(32, "0")}`,
89
+ });
90
+ managers.push(manager);
91
+ return {
92
+ manager,
93
+ packet,
94
+ readStatus(status: typeof readStatus) {
95
+ readStatus = status;
96
+ },
97
+ capacity() {
98
+ capacity = true;
99
+ readStatus = "unsupported";
100
+ },
101
+ failBarrier() {
102
+ failBarrier = true;
103
+ },
104
+ calls,
105
+ videoInputs,
106
+ stateInputs,
107
+ failMetadata(error: Error | null) {
108
+ metadataError = error;
109
+ },
110
+ duringMetadata(callback: () => void) {
111
+ duringMetadata = callback;
112
+ },
113
+ advance(ms: number) {
114
+ now += ms;
115
+ monotonicNow += ms;
116
+ },
117
+ shiftWall(ms: number) {
118
+ now += ms;
119
+ },
120
+ change() {
121
+ generation = "1:2";
122
+ url = "https://fixture.invalid/new";
123
+ },
124
+ block() {
125
+ let release!: () => void;
126
+ wait = new Promise((resolve) => {
127
+ release = resolve;
128
+ });
129
+ const started = new Promise<void>((resolve) => {
130
+ arrived = resolve;
131
+ });
132
+ return { release, started };
133
+ },
134
+ };
135
+ }
136
+ function input(viewerToken: string): BrowserVideoReadInput {
137
+ return {
138
+ viewerToken,
139
+ quality: "high",
140
+ streamId: null,
141
+ afterSequence: 0,
142
+ waitMs: 250,
143
+ requestKeyFrame: true,
144
+ };
145
+ }
146
+
147
+ describe("video authority", () => {
148
+ it("reconciles external document changes without a JPEG/status request and discards the transition batch", async () => {
149
+ const f = fixture();
150
+ const a = await f.manager.attach("workspace", "A");
151
+ const reads = f.calls.filter((call) => call === "state").length;
152
+ expect((await f.manager.readVideo(input(a.viewerToken))).status).toBe("ready");
153
+ expect(f.calls.filter((call) => call === "state")).toHaveLength(reads);
154
+ f.change();
155
+ const transition = await f.manager.readVideo(input(a.viewerToken));
156
+ expect(transition.status).toBe("reset");
157
+ expect(transition.packets).toEqual([]);
158
+ expect(transition.state.url).toBe("https://fixture.invalid/new");
159
+ expect(transition.state.navigationGeneration).toBeGreaterThan(a.state.navigationGeneration);
160
+ expect(f.stateInputs.at(-1)).toEqual({ timeoutMs: 3000 });
161
+ const next = await f.manager.readVideo(input(a.viewerToken));
162
+ expect(next.status).toBe("ready");
163
+ expect(next.state.navigationGeneration).toBe(transition.state.navigationGeneration);
164
+ expect(next.packets[0]!.frame.navigationGeneration).toBe(transition.state.navigationGeneration);
165
+ expect(f.calls.filter((call) => call === "state")).toHaveLength(reads + 1);
166
+ });
167
+
168
+ it("keeps failed mismatch reconciliation visibly unavailable and retries no native input", async () => {
169
+ const f = fixture();
170
+ const a = await f.manager.attach("workspace", "A");
171
+ f.change();
172
+ f.failMetadata(new Error("Bounded metadata observation timed out"));
173
+ const failed = await f.manager.readVideo(input(a.viewerToken));
174
+ expect(failed.status).toBe("reset");
175
+ expect(failed.packets).toEqual([]);
176
+ expect(failed.state.status).toBe("error");
177
+ expect(failed.state.url).toBe(a.state.url);
178
+ expect(failed.state.error).toContain("timed out");
179
+ f.failMetadata(null);
180
+ const recovered = await f.manager.readVideo(input(a.viewerToken));
181
+ expect(recovered.status).toBe("reset");
182
+ expect(recovered.packets).toEqual([]);
183
+ expect(recovered.state.status).toBe("ready");
184
+ expect((await f.manager.readVideo(input(a.viewerToken))).status).toBe("ready");
185
+ expect(f.calls).not.toContain("mouse.down");
186
+ });
187
+
188
+ it("does not revive an expired viewer while reconciling a native generation mismatch", async () => {
189
+ const f = fixture();
190
+ const a = await f.manager.attach("workspace", "A");
191
+ f.change();
192
+ f.duringMetadata(() => f.advance(46_000));
193
+ await expect(f.manager.readVideo(input(a.viewerToken))).rejects.toThrow("Viewer token");
194
+ });
195
+
196
+ it("keeps ordinary admitted click and end replies metadata-free while video continues", async () => {
197
+ const f = fixture();
198
+ const a = await f.manager.attach("workspace", "A");
199
+ const { controlToken } = await f.manager.acquireControl(a.viewerToken);
200
+ const first = await f.manager.readVideo(input(a.viewerToken));
201
+ const frame = first.packets[0]!.frame;
202
+ const expected = {
203
+ sessionId: first.state.sessionId,
204
+ runtimeId: first.state.runtimeId!,
205
+ bridgeEpoch: first.state.bridgeEpoch!,
206
+ navigationGeneration: first.state.navigationGeneration,
207
+ viewportGeneration: first.state.viewportGeneration,
208
+ };
209
+ const target = {
210
+ frameId: frame.frameId,
211
+ navigationGeneration: frame.navigationGeneration,
212
+ viewportGeneration: frame.viewportGeneration,
213
+ };
214
+ const context = { viewerToken: a.viewerToken, controlToken, expected };
215
+ const preflightReads = f.stateInputs.length;
216
+ const begin = await f.manager.beginGesture({ ...context, target, pointerKind: "mouse" });
217
+ if (!("gestureId" in begin)) throw new Error("Fixture expected admitted input");
218
+ expect(f.stateInputs).toHaveLength(preflightReads + 1);
219
+ const reads = f.stateInputs.length;
220
+ // A target metadata failure must not enter the hot acknowledgement path.
221
+ // The native input.check observations still fence every published edge.
222
+ f.failMetadata(new Error("Target metadata would be blocked"));
223
+ const point = { x: 20, y: 20, width: 1280, height: 800 };
224
+ const down = await f.manager.updateGesture({
225
+ ...context,
226
+ gestureId: begin.gestureId,
227
+ sequence: 1,
228
+ target,
229
+ event: { kind: "down", point, button: "left", clickCount: 1 },
230
+ });
231
+ expect(down.state.status).toBe("ready");
232
+ expect((await f.manager.readVideo(input(a.viewerToken))).status).toBe("ready");
233
+ await f.manager.updateGesture({
234
+ ...context,
235
+ gestureId: begin.gestureId,
236
+ sequence: 2,
237
+ event: { kind: "up", point, button: "left", clickCount: 1 },
238
+ });
239
+ const end = await f.manager.endGesture({
240
+ ...context,
241
+ gestureId: begin.gestureId,
242
+ sequence: 3,
243
+ cancel: false,
244
+ });
245
+ expect(end.state.status).toBe("ready");
246
+ expect(f.stateInputs).toHaveLength(reads);
247
+ expect(f.calls.filter((call) => call === "mouse.down")).toHaveLength(1);
248
+ expect(f.calls.filter((call) => call === "mouse.up")).toHaveLength(1);
249
+ expect(f.calls.filter((call) => call === "input.end")).toHaveLength(1);
250
+ expect(f.calls.filter((call) => call === "input.check").length).toBeGreaterThanOrEqual(9);
251
+ expect(f.calls).not.toContain("video.invalidate");
252
+ });
253
+
254
+ it("deduplicates an exact source receipt across viewers without pretending it is a JPEG", async () => {
255
+ const f = fixture();
256
+ const a = await f.manager.attach("workspace", "A");
257
+ const b = await f.manager.attach("workspace", "B");
258
+ const first = await f.manager.readVideo(input(a.viewerToken));
259
+ const second = await f.manager.readVideo(input(b.viewerToken));
260
+ expect(first.packets[0]!.frame.frameId).toBe(second.packets[0]!.frame.frameId);
261
+ expect(first.packets[0]!.frame).not.toHaveProperty("mimeType");
262
+ expect(first.packets[0]!.frame.capturedAt).toBe(f.packet.capturedAt);
263
+ });
264
+ it("leaves status and detach available while a native read waits", async () => {
265
+ const f = fixture();
266
+ const a = await f.manager.attach("workspace", "A");
267
+ const block = f.block();
268
+ const pending = f.manager.readVideo(input(a.viewerToken));
269
+ await block.started;
270
+ await f.manager.status(a.viewerToken);
271
+ await f.manager.detach(a.viewerToken);
272
+ block.release();
273
+ await expect(pending).rejects.toThrow("Viewer token");
274
+ });
275
+ it("bounds one pending read per viewer and permits another viewer", async () => {
276
+ const f = fixture();
277
+ const a = await f.manager.attach("workspace", "A");
278
+ const b = await f.manager.attach("workspace", "B");
279
+ const block = f.block();
280
+ const pending = f.manager.readVideo(input(a.viewerToken));
281
+ await block.started;
282
+ await expect(f.manager.readVideo(input(a.viewerToken))).rejects.toThrow("already pending");
283
+ const other = f.manager.readVideo(input(b.viewerToken));
284
+ block.release();
285
+ expect((await pending).status).toBe("ready");
286
+ expect((await other).status).toBe("ready");
287
+ });
288
+ it("returns no old pixels across a document change during a read", async () => {
289
+ const f = fixture();
290
+ const a = await f.manager.attach("workspace", "A");
291
+ const block = f.block();
292
+ const pending = f.manager.readVideo(input(a.viewerToken));
293
+ await block.started;
294
+ f.change();
295
+ block.release();
296
+ expect((await pending).packets).toEqual([]);
297
+ });
298
+ it("cannot reissue pre-action pixels from a reply held across a settled command", async () => {
299
+ const f = fixture();
300
+ const a = await f.manager.attach("workspace", "A");
301
+ const control = await f.manager.acquireControl(a.viewerToken);
302
+ const initial = await f.manager.readVideo(input(a.viewerToken));
303
+ const authority = initial.packets[0]!.frame;
304
+ const block = f.block();
305
+ const pending = f.manager.readVideo({ ...input(a.viewerToken), requestKeyFrame: false });
306
+ await block.started;
307
+ await f.manager.sendInput({
308
+ viewerToken: a.viewerToken,
309
+ controlToken: control.controlToken,
310
+ expected: {
311
+ sessionId: initial.state.sessionId,
312
+ navigationGeneration: initial.state.navigationGeneration,
313
+ viewportGeneration: initial.state.viewportGeneration,
314
+ },
315
+ target: {
316
+ frameId: authority.frameId,
317
+ navigationGeneration: authority.navigationGeneration,
318
+ viewportGeneration: authority.viewportGeneration,
319
+ },
320
+ event: { kind: "type", text: "owned fixture" },
321
+ });
322
+ block.release();
323
+ const late = await pending;
324
+ expect(late.status).toBe("reset");
325
+ expect(late.packets).toEqual([]);
326
+ });
327
+ it("preserves an accepted input outcome if media fails and still fences later old packets", async () => {
328
+ const f = fixture();
329
+ const a = await f.manager.attach("workspace", "A");
330
+ const control = await f.manager.acquireControl(a.viewerToken);
331
+ const initial = await f.manager.readVideo(input(a.viewerToken));
332
+ const authority = initial.packets[0]!.frame;
333
+ f.advance(1);
334
+ f.failBarrier();
335
+ await expect(
336
+ f.manager.sendInput({
337
+ viewerToken: a.viewerToken,
338
+ controlToken: control.controlToken,
339
+ expected: {
340
+ sessionId: initial.state.sessionId,
341
+ navigationGeneration: initial.state.navigationGeneration,
342
+ viewportGeneration: initial.state.viewportGeneration,
343
+ },
344
+ target: {
345
+ frameId: authority.frameId,
346
+ navigationGeneration: authority.navigationGeneration,
347
+ viewportGeneration: authority.viewportGeneration,
348
+ },
349
+ event: { kind: "type", text: "owned fixture" },
350
+ }),
351
+ ).resolves.toHaveProperty("state");
352
+ expect((await f.manager.readVideo(input(a.viewerToken))).packets).toEqual([]);
353
+ });
354
+ it("refuses old source receipts and wrong native dimensions", async () => {
355
+ const f = fixture();
356
+ const a = await f.manager.attach("workspace", "A");
357
+ f.advance(1001);
358
+ expect((await f.manager.readVideo(input(a.viewerToken))).status).toBe("reset");
359
+ f.packet.capturedAt = new Date(Date.parse(f.packet.capturedAt) + 1001).toISOString();
360
+ f.packet.capturedAtMonotonicMs += 1001;
361
+ f.packet.width = 400;
362
+ expect((await f.manager.readVideo(input(a.viewerToken))).status).toBe("reset");
363
+ });
364
+ it("refuses malformed payloads and accepts no media without a viewer", async () => {
365
+ const f = fixture();
366
+ await expect(f.manager.readVideo(input("x".repeat(32)))).rejects.toThrow("Viewer token");
367
+ const a = await f.manager.attach("workspace", "A");
368
+ f.packet.codec = "";
369
+ await expect(f.manager.readVideo(input(a.viewerToken))).rejects.toThrow("invalid video packet");
370
+ });
371
+ });
372
+
373
+ it.each(["waiting", "unsupported", "throw"] as const)(
374
+ "stops capture on final detach after a %s read without receipts",
375
+ async (status) => {
376
+ const f = fixture();
377
+ const a = await f.manager.attach("workspace", "A");
378
+ f.readStatus(status);
379
+ await f.manager.readVideo(input(a.viewerToken)).catch(() => undefined);
380
+ await f.manager.detach(a.viewerToken);
381
+ expect(f.calls.filter((call) => call === "video.stop")).toHaveLength(1);
382
+ },
383
+ );
384
+
385
+ it("stops failed capture when the last viewer expires without a detach request", async () => {
386
+ vi.useFakeTimers();
387
+ try {
388
+ const f = fixture();
389
+ const a = await f.manager.attach("workspace", "A");
390
+ f.readStatus("unsupported");
391
+ await f.manager.readVideo(input(a.viewerToken));
392
+ f.advance(120_000);
393
+ await vi.advanceTimersByTimeAsync(120_000);
394
+ expect(f.calls.filter((call) => call === "video.stop")).toHaveLength(1);
395
+ } finally {
396
+ vi.useRealTimers();
397
+ }
398
+ });
399
+
400
+ it("keeps capture when a viewer renews its lease and cancels expiry on reset", async () => {
401
+ vi.useFakeTimers();
402
+ try {
403
+ const f = fixture();
404
+ const a = await f.manager.attach("workspace", "A");
405
+ f.readStatus("unsupported");
406
+ await f.manager.readVideo(input(a.viewerToken));
407
+ f.advance(30_000);
408
+ await vi.advanceTimersByTimeAsync(30_000);
409
+ await f.manager.status(a.viewerToken);
410
+ f.advance(20_000);
411
+ await vi.advanceTimersByTimeAsync(20_000);
412
+ expect(f.calls).not.toContain("video.stop");
413
+ f.manager.reset();
414
+ f.advance(120_000);
415
+ await vi.advanceTimersByTimeAsync(120_000);
416
+ expect(f.calls).not.toContain("video.stop");
417
+ } finally {
418
+ vi.useRealTimers();
419
+ }
420
+ });
421
+
422
+ it("forwards independent bounded video settings while retaining viewer/frame authority", async () => {
423
+ const f = fixture();
424
+ const viewer = await f.manager.attach("workspace", "Viewer");
425
+ await f.manager.readVideo({ ...input(viewer.viewerToken), bitrate: 24_000_000, fps: 60 });
426
+ expect(f.videoInputs).toEqual([expect.objectContaining({ bitrate: 24_000_000, fps: 60 })]);
427
+ });
428
+
429
+ it("preserves the typed capacity fallback without issuing mutation packets", async () => {
430
+ const f = fixture();
431
+ const attached = await f.manager.attach("workspace", "A");
432
+ f.capacity();
433
+ const reply = await f.manager.readVideo(input(attached.viewerToken));
434
+ expect(reply.status).toBe("unsupported");
435
+ expect(reply.reasonCode).toBe("encoder-capacity");
436
+ expect(reply.packets).toEqual([]);
437
+ expect(f.calls.some((call) => call.startsWith("input."))).toBe(false);
438
+ });
439
+
440
+ it.each([-10_000, 10_000])(
441
+ "keeps fresh video admissible across a %i ms wall-clock correction",
442
+ async (jump) => {
443
+ const f = fixture();
444
+ const a = await f.manager.attach("workspace", "A");
445
+ f.shiftWall(jump);
446
+ const reply = await f.manager.readVideo(input(a.viewerToken));
447
+ expect(reply.status).toBe("ready");
448
+ expect(reply.packets).toHaveLength(1);
449
+ expect(reply.packets[0]).not.toHaveProperty("capturedAtMonotonicMs");
450
+ f.advance(1001);
451
+ expect((await f.manager.readVideo(input(a.viewerToken))).status).toBe("reset");
452
+ },
453
+ );
454
+
455
+ it.each([-10_000, 10_000])(
456
+ "preserves the post-action video fence across a %i ms wall-clock correction",
457
+ async (jump) => {
458
+ const f = fixture();
459
+ const a = await f.manager.attach("workspace", "A");
460
+ const control = await f.manager.acquireControl(a.viewerToken);
461
+ const initial = await f.manager.readVideo(input(a.viewerToken));
462
+ const frame = initial.packets[0]!.frame;
463
+ f.advance(1);
464
+ await f.manager.sendInput({
465
+ viewerToken: a.viewerToken,
466
+ controlToken: control.controlToken,
467
+ expected: initial.state,
468
+ target: frame,
469
+ event: { kind: "type", text: "owned fixture" },
470
+ });
471
+ f.shiftWall(jump);
472
+ // Even an apparently new wall timestamp cannot promote pre-action pixels.
473
+ f.packet.capturedAt = new Date(Date.parse(f.packet.capturedAt) + jump + 1).toISOString();
474
+ expect((await f.manager.readVideo(input(a.viewerToken))).status).toBe("reset");
475
+ f.advance(100);
476
+ f.packet.capturedAtMonotonicMs += 101;
477
+ f.packet.timestampUs += 101_000;
478
+ f.packet.sequence += 1;
479
+ expect((await f.manager.readVideo(input(a.viewerToken))).status).toBe("ready");
480
+ },
481
+ );
482
+
483
+ it.each([-10_000, 0, 10_000])(
484
+ "cannot restore pre-gesture input authority across a %i ms wall correction, including a zero elapsed fence",
485
+ async (jump) => {
486
+ const f = fixture(0);
487
+ const a = await f.manager.attach("workspace", "A");
488
+ const { controlToken } = await f.manager.acquireControl(a.viewerToken);
489
+ const initial = await f.manager.readVideo(input(a.viewerToken));
490
+ const target = initial.packets[0]!.frame;
491
+ const context = {
492
+ viewerToken: a.viewerToken,
493
+ controlToken,
494
+ expected: {
495
+ ...initial.state,
496
+ runtimeId: initial.state.runtimeId!,
497
+ bridgeEpoch: initial.state.bridgeEpoch!,
498
+ },
499
+ };
500
+ const begin = await f.manager.beginGesture({ ...context, target, pointerKind: "touch" });
501
+ if (!("gestureId" in begin)) throw new Error("Fixture input was not admitted");
502
+ const point = { x: 20, y: 20, width: 1280, height: 800 };
503
+ await f.manager.updateGesture({
504
+ ...context,
505
+ gestureId: begin.gestureId,
506
+ sequence: 1,
507
+ target,
508
+ event: { kind: "touch", type: "start", points: [{ ...point, id: 0 }] },
509
+ });
510
+ await f.manager.updateGesture({
511
+ ...context,
512
+ gestureId: begin.gestureId,
513
+ sequence: 2,
514
+ event: { kind: "touch", type: "end", points: [] },
515
+ });
516
+ await f.manager.endGesture({
517
+ ...context,
518
+ gestureId: begin.gestureId,
519
+ sequence: 3,
520
+ cancel: false,
521
+ });
522
+ f.shiftWall(jump);
523
+ // Re-reading the original receipt can display it, but cannot re-arm a discrete press.
524
+ const old = await f.manager.readVideo(input(a.viewerToken));
525
+ expect(old.status).toBe("ready");
526
+ expect(old.packets[0]!.frame.frameId).toBe(target.frameId);
527
+ await expect(
528
+ f.manager.sendInput({ ...context, target, event: { kind: "type", text: "old pixels" } }),
529
+ ).rejects.toThrow("frame is stale");
530
+ f.advance(100);
531
+ f.packet.capturedAtMonotonicMs = 100;
532
+ f.packet.sequence++;
533
+ f.packet.timestampUs += 100_000;
534
+ const fresh = await f.manager.readVideo(input(a.viewerToken));
535
+ await expect(
536
+ f.manager.sendInput({
537
+ ...context,
538
+ target: fresh.packets[0]!.frame,
539
+ event: { kind: "type", text: "fresh pixels" },
540
+ }),
541
+ ).resolves.toHaveProperty("state");
542
+ },
543
+ );
544
+
545
+ it("fails closed when private native timing is missing", async () => {
546
+ const f = fixture();
547
+ const a = await f.manager.attach("workspace", "A");
548
+ Reflect.deleteProperty(f.packet, "capturedAtMonotonicMs");
549
+ await expect(f.manager.readVideo(input(a.viewerToken))).rejects.toThrow("invalid video packet");
550
+ });
551
+
552
+ it("rejects future native time and refuses to re-stamp an existing receipt", async () => {
553
+ const f = fixture(0);
554
+ const a = await f.manager.attach("workspace", "A");
555
+ f.packet.capturedAtMonotonicMs = 51;
556
+ expect((await f.manager.readVideo(input(a.viewerToken))).status).toBe("reset");
557
+ f.packet.capturedAtMonotonicMs = 50;
558
+ const reply = await f.manager.readVideo(input(a.viewerToken));
559
+ expect(reply.status).toBe("ready");
560
+ expect(reply.packets[0]!.frame).not.toHaveProperty("capturedAtMonotonicMs");
561
+ f.advance(1);
562
+ f.packet.capturedAtMonotonicMs = 51;
563
+ await expect(f.manager.readVideo(input(a.viewerToken))).rejects.toThrow("receipt timestamp");
564
+ });