@omercnet/paseo-shared-browser 1.2.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 (170) 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 +1834 -0
  82. package/package.json +13 -8
  83. package/paseo-plugin.json +3 -1
  84. package/scripts/prepare-dependencies.mjs +1 -1
  85. package/scripts/prepare-runtime.mjs +1 -1
  86. package/server/agent-browser-runtime.ts +1598 -314
  87. package/server/browser-cursor.test.ts +64 -0
  88. package/server/browser-cursor.ts +69 -0
  89. package/server/browser-frame-context.ts +22 -0
  90. package/server/browser-frame-epoch.test.ts +95 -0
  91. package/server/browser-frame-rejection.ts +17 -0
  92. package/server/browser-gesture.ts +120 -0
  93. package/server/browser-operation-diagnostics.test.ts +79 -0
  94. package/server/browser-operation-diagnostics.ts +103 -0
  95. package/server/browser-policy.ts +1313 -151
  96. package/server/browser-video-input-authority.test.ts +208 -0
  97. package/server/browser-video-policy.test.ts +564 -0
  98. package/server/browser-video-socket.test.ts +368 -0
  99. package/server/browser.ts +151 -16
  100. package/server/capture-transport-policy.test.ts +105 -0
  101. package/server/capture-transport-policy.ts +154 -0
  102. package/server/capture-transport-runtime.test.ts +213 -0
  103. package/server/daemon-launch-chain.test.ts +70 -0
  104. package/server/daemon-shutdown.test.ts +289 -0
  105. package/server/discrete-input-freshness.test.ts +364 -0
  106. package/server/discrete-input-native-lifetime.test.ts +150 -0
  107. package/server/display-loss-recovery.test.ts +294 -0
  108. package/server/emulation-restoration.test.ts +262 -0
  109. package/server/input-cleanup.ts +23 -0
  110. package/server/input-generation.test.ts +31 -0
  111. package/server/input-generation.ts +36 -0
  112. package/server/jpeg-capture-demand.test.ts +76 -0
  113. package/server/jpeg-capture-demand.ts +98 -0
  114. package/server/jpeg-frame.ts +29 -0
  115. package/server/keyboard-input.test.ts +61 -0
  116. package/server/keyboard-input.ts +104 -0
  117. package/server/live-input-policy.test.ts +958 -0
  118. package/server/live-input-runtime.test.ts +732 -0
  119. package/server/live-input-supervisor.test.ts +147 -0
  120. package/server/mcp-entry.ts +44 -2
  121. package/server/native-video-backpressure.test.ts +228 -0
  122. package/server/native-video-buffer.test.ts +141 -0
  123. package/server/native-video-buffer.ts +114 -0
  124. package/server/native-video-capture.test.ts +599 -0
  125. package/server/native-video-capture.ts +497 -0
  126. package/server/native-video-extension-source.test.ts +201 -0
  127. package/server/native-video-extension-source.ts +364 -0
  128. package/server/native-video-extension.test.ts +41 -0
  129. package/server/native-video-extension.ts +83 -0
  130. package/server/native-video-packet.ts +9 -0
  131. package/server/native-video-settings.test.ts +245 -0
  132. package/server/navigation-budget.ts +8 -0
  133. package/server/navigation-commit.ts +125 -0
  134. package/server/navigation-policy.test.ts +153 -0
  135. package/server/process-identity.ts +58 -0
  136. package/server/reattach-held-input.test.ts +143 -0
  137. package/server/runtime-frame-reuse.test.ts +295 -0
  138. package/server/runtime-jpeg-demand.test.ts +200 -0
  139. package/server/runtime-jpeg-shutdown.test.ts +210 -0
  140. package/server/runtime-jpeg-source-order.test.ts +201 -0
  141. package/server/runtime-launch-environment.test.ts +111 -0
  142. package/server/runtime-navigation.test.ts +327 -0
  143. package/server/runtime-owner-display.test.ts +152 -0
  144. package/server/runtime-owner.ts +305 -31
  145. package/server/runtime-page-state.test.ts +249 -0
  146. package/server/runtime-protocol.ts +31 -1
  147. package/server/runtime-target-retention.test.ts +181 -0
  148. package/server/runtime-video-opt-in.test.ts +82 -0
  149. package/server/runtime-video.test.ts +444 -0
  150. package/server/screencast-source-time.ts +46 -0
  151. package/server/stale-wheel-admission.test.ts +136 -0
  152. package/server/supervisor-client.ts +28 -4
  153. package/server/supervisor-heartbeat.test.ts +80 -0
  154. package/server/supervisor.ts +553 -55
  155. package/server/video-source-recovery.test.ts +20 -0
  156. package/server/video-source-recovery.ts +64 -0
  157. package/server/viewer-capture-lifetime.ts +51 -0
  158. package/server/virtual-display.test.ts +184 -0
  159. package/server/virtual-display.ts +236 -0
  160. package/shared/browser-display-preferences.ts +46 -0
  161. package/shared/browser-video.ts +80 -0
  162. package/shared/browser.ts +286 -65
  163. package/shared/capture-density.ts +16 -0
  164. package/shared/capture-settings.ts +17 -0
  165. package/shared/device-presets.ts +90 -0
  166. package/shared/resolution-menu.test.ts +120 -0
  167. package/shared/resolution-menu.ts +72 -0
  168. package/shared/video-settings.test.ts +38 -0
  169. package/shared/video-settings.ts +34 -0
  170. package/shared/viewport-limits.ts +5 -0
@@ -0,0 +1,473 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { BrowserVideoPacket } from "../shared/browser-video";
3
+ import {
4
+ type BrowserVideoCodec,
5
+ createBrowserVideoDecoder,
6
+ type DecodedBrowserVideoFrame,
7
+ } from "./browser-video-decoder";
8
+
9
+ function packet(sequence = 1, type: "key" | "delta" = "key", generation = 1): BrowserVideoPacket {
10
+ return {
11
+ streamId: "s".repeat(32),
12
+ captureGeneration: generation,
13
+ sequence,
14
+ timestampUs: sequence * 1000,
15
+ type,
16
+ codec: "vp8",
17
+ width: 1280,
18
+ height: 800,
19
+ dataBase64: "AA==",
20
+ capturedAt: "2026-10-03T00:00:00.000Z",
21
+ frame: {
22
+ frameId: String(sequence).padStart(32, "f"),
23
+ sessionId: "a".repeat(32),
24
+ navigationGeneration: 1,
25
+ viewportGeneration: 1,
26
+ width: 1280,
27
+ height: 800,
28
+ capturedAt: "2026-10-03T00:00:00.000Z",
29
+ },
30
+ };
31
+ }
32
+ function fixture(sourceVisuals = false, nativeDraw?: () => Promise<void>) {
33
+ let currentEpoch = 1;
34
+ let currentNavigation = 1;
35
+ const admitted: number[] = [];
36
+ let queueSize = 0;
37
+ let scheduled: (() => void) | null = null;
38
+ const callbacks: {
39
+ output(frame: DecodedBrowserVideoFrame): void;
40
+ error(error: unknown): void;
41
+ dequeue(): void;
42
+ }[] = [];
43
+ const decoded: unknown[] = [];
44
+ const drawn: number[] = [];
45
+ const presented: number[] = [];
46
+ const closed: number[] = [];
47
+ const errors: unknown[] = [];
48
+ let keys = 0;
49
+ const decoder = createBrowserVideoDecoder({
50
+ environment: {
51
+ createDecoder(value) {
52
+ callbacks.push(value);
53
+ return {
54
+ get decodeQueueSize() {
55
+ return queueSize;
56
+ },
57
+ configure() {},
58
+ decode(chunk) {
59
+ decoded.push(chunk);
60
+ },
61
+ close() {},
62
+ } satisfies BrowserVideoCodec;
63
+ },
64
+ createChunk: (value) => value,
65
+ decodeBase64: () => new Uint8Array([0]),
66
+ scheduleDraw(draw) {
67
+ scheduled = draw;
68
+ return () => {
69
+ scheduled = null;
70
+ };
71
+ },
72
+ draw(frame) {
73
+ drawn.push(frame.timestamp);
74
+ return nativeDraw?.();
75
+ },
76
+ },
77
+ isCurrent: (value, epoch) =>
78
+ epoch === currentEpoch && value.frame.navigationGeneration === currentNavigation,
79
+ ...(sourceVisuals
80
+ ? {
81
+ isSourceCurrent: (value: BrowserVideoPacket) =>
82
+ value.frame.navigationGeneration === currentNavigation,
83
+ }
84
+ : {}),
85
+ onPresented(value, epoch) {
86
+ presented.push(value.sequence);
87
+ if (epoch === currentEpoch) admitted.push(value.sequence);
88
+ },
89
+ onNeedKeyFrame() {
90
+ keys++;
91
+ },
92
+ onError(error) {
93
+ errors.push(error);
94
+ },
95
+ });
96
+ const output = (timestamp: number, codecIndex = callbacks.length - 1, width = 1280) => {
97
+ callbacks[codecIndex]!.output({
98
+ timestamp,
99
+ displayWidth: width,
100
+ displayHeight: 800,
101
+ close() {
102
+ closed.push(timestamp);
103
+ },
104
+ });
105
+ };
106
+ return {
107
+ decoder,
108
+ decoded,
109
+ drawn,
110
+ presented,
111
+ admitted,
112
+ codecs: () => callbacks.length,
113
+ navigation: (value: number) => {
114
+ currentNavigation = value;
115
+ },
116
+ closed,
117
+ errors,
118
+ output,
119
+ paint() {
120
+ const draw = scheduled;
121
+ scheduled = null;
122
+ draw?.();
123
+ },
124
+ epoch(value: number) {
125
+ currentEpoch = value;
126
+ },
127
+ pressure(value: number) {
128
+ queueSize = value;
129
+ },
130
+ drain() {
131
+ callbacks.at(-1)?.dequeue();
132
+ },
133
+ keys: () => keys,
134
+ };
135
+ }
136
+
137
+ describe("video decode admission", () => {
138
+ it("preserves a full 32-packet relay burst while actual asynchronous decoder capacity drains", async () => {
139
+ let queueSize = 0;
140
+ let maximumQueue = 0;
141
+ let codecCloses = 0;
142
+ let keys = 0;
143
+ const decoded: number[] = [];
144
+ const presented: number[] = [];
145
+ let draw: (() => void) | null = null;
146
+ const decoder = createBrowserVideoDecoder({
147
+ environment: {
148
+ createDecoder(callbacks) {
149
+ return {
150
+ get decodeQueueSize() {
151
+ return queueSize;
152
+ },
153
+ configure() {},
154
+ decode(value) {
155
+ const chunk = value as { timestamp: number };
156
+ decoded.push(chunk.timestamp);
157
+ queueSize++;
158
+ maximumQueue = Math.max(maximumQueue, queueSize);
159
+ // Native WebCodecs dispatches asynchronous dequeue/output tasks,
160
+ // rather than draining during the hook's synchronous batch loop.
161
+ queueMicrotask(() => {
162
+ queueSize--;
163
+ callbacks.dequeue();
164
+ callbacks.output({
165
+ timestamp: chunk.timestamp,
166
+ displayWidth: 1280,
167
+ displayHeight: 800,
168
+ close() {},
169
+ });
170
+ });
171
+ },
172
+ close() {
173
+ codecCloses++;
174
+ },
175
+ };
176
+ },
177
+ createChunk: (value) => value,
178
+ decodeBase64: () => new Uint8Array([0]),
179
+ scheduleDraw(callback) {
180
+ draw = callback;
181
+ return () => {
182
+ draw = null;
183
+ };
184
+ },
185
+ draw() {},
186
+ },
187
+ isCurrent: () => true,
188
+ onPresented: (value) => presented.push(value.sequence),
189
+ onNeedKeyFrame: () => {
190
+ keys++;
191
+ },
192
+ onError: (error) => {
193
+ throw error;
194
+ },
195
+ });
196
+ for (let sequence = 1; sequence <= 32; sequence++) {
197
+ expect(decoder.receive(packet(sequence, sequence === 1 ? "key" : "delta"), 1)).toBe(true);
198
+ }
199
+ expect(decoded).toHaveLength(4);
200
+ await new Promise<void>((resolve) => setImmediate(resolve));
201
+ expect(decoded).toEqual(Array.from({ length: 32 }, (_, index) => (index + 1) * 1000));
202
+ expect(maximumQueue).toBe(4);
203
+ expect(codecCloses).toBe(0);
204
+ expect(keys).toBe(0);
205
+ const paint = draw as (() => void) | null;
206
+ paint?.();
207
+ expect(presented).toEqual([32]);
208
+ decoder.close();
209
+ });
210
+
211
+ it("rejects queued old authority at dequeue before dispatching or painting it", () => {
212
+ const f = fixture();
213
+ f.decoder.receive(packet(), 1);
214
+ f.pressure(4);
215
+ f.decoder.receive(packet(2, "delta"), 1);
216
+ f.epoch(2);
217
+ f.pressure(0);
218
+ f.drain();
219
+ f.output(1000);
220
+ f.paint();
221
+ expect(f.decoded).toHaveLength(1);
222
+ expect(f.presented).toEqual([]);
223
+ expect(f.keys()).toBe(1);
224
+ });
225
+
226
+ it("bounds buffered chunks even when the codec dequeues without producing output", () => {
227
+ const f = fixture();
228
+ for (let sequence = 1; sequence <= 64; sequence++) {
229
+ expect(f.decoder.receive(packet(sequence, sequence === 1 ? "key" : "delta"), 1)).toBe(true);
230
+ }
231
+ expect(f.decoder.receive(packet(65, "delta"), 1)).toBe(false);
232
+ expect(f.keys()).toBe(1);
233
+ expect(f.decoder.receive(packet(66, "key"), 1)).toBe(true);
234
+ f.output(1000, 0);
235
+ f.paint();
236
+ expect(f.presented).toEqual([]);
237
+ });
238
+
239
+ it("bounds retained encoded payload independently of packet count", () => {
240
+ const f = fixture();
241
+ const payload = "A".repeat(3 * 1024 * 1024);
242
+ expect(f.decoder.receive({ ...packet(), dataBase64: payload }, 1)).toBe(true);
243
+ f.pressure(4);
244
+ expect(f.decoder.receive({ ...packet(2, "delta"), dataBase64: payload }, 1)).toBe(true);
245
+ expect(f.decoder.receive({ ...packet(3, "delta"), dataBase64: payload }, 1)).toBe(false);
246
+ expect(f.keys()).toBe(1);
247
+ expect(f.decoded).toHaveLength(1);
248
+ });
249
+ it("admits only after actual decode and local draw", () => {
250
+ const f = fixture();
251
+ f.decoder.receive(packet(), 1);
252
+ expect(f.presented).toEqual([]);
253
+ f.output(1000);
254
+ expect(f.presented).toEqual([]);
255
+ f.paint();
256
+ expect(f.drawn).toEqual([1000]);
257
+ expect(f.presented).toEqual([1]);
258
+ expect(f.closed).toEqual([1000]);
259
+ });
260
+ it("drops frames invalidated while decoding or awaiting local paint", () => {
261
+ const f = fixture();
262
+ f.decoder.receive(packet(), 1);
263
+ f.output(1000);
264
+ f.epoch(2);
265
+ f.paint();
266
+ expect(f.drawn).toEqual([]);
267
+ expect(f.closed).toEqual([1000]);
268
+ });
269
+ it("closes superseded frames and paints only the newest output", () => {
270
+ const f = fixture();
271
+ f.decoder.receive(packet(), 1);
272
+ f.decoder.receive(packet(2, "delta"), 1);
273
+ f.output(1000);
274
+ f.output(2000);
275
+ f.paint();
276
+ expect(f.drawn).toEqual([2000]);
277
+ expect(f.closed).toEqual([1000, 2000]);
278
+ });
279
+ it("does not let a reordered older decoder output replace the queued newest frame", () => {
280
+ const f = fixture();
281
+ f.decoder.receive(packet(), 1);
282
+ f.decoder.receive(packet(2, "delta"), 1);
283
+ f.output(2000);
284
+ f.output(1000);
285
+ f.paint();
286
+ expect(f.drawn).toEqual([2000]);
287
+ expect(f.closed).toEqual([1000, 2000]);
288
+ });
289
+ it("never decodes a delta after a transport gap", () => {
290
+ const f = fixture();
291
+ f.decoder.receive(packet(), 1);
292
+ expect(f.decoder.receive(packet(3, "delta"), 1)).toBe(false);
293
+ expect(f.decoded).toHaveLength(1);
294
+ expect(f.keys()).toBe(1);
295
+ expect(f.decoder.receive(packet(4, "key"), 1)).toBe(true);
296
+ });
297
+ it("ignores a late old-decoder output even when its timestamp collides", () => {
298
+ const f = fixture();
299
+ f.decoder.receive(packet(), 1);
300
+ f.decoder.receive(packet(1, "key", 2), 1);
301
+ f.output(1000, 0);
302
+ f.paint();
303
+ expect(f.drawn).toEqual([]);
304
+ f.output(1000, 1);
305
+ f.paint();
306
+ expect(f.presented).toEqual([1]);
307
+ });
308
+ it("bounds codec pressure and ignores duplicate delivery", () => {
309
+ const f = fixture();
310
+ f.decoder.receive(packet(), 1);
311
+ f.decoder.receive(packet(), 1);
312
+ expect(f.decoded).toHaveLength(1);
313
+ f.pressure(4);
314
+ expect(f.decoder.receive(packet(2, "delta"), 1)).toBe(true);
315
+ expect(f.decoded).toHaveLength(1);
316
+ expect(f.keys()).toBe(0);
317
+ f.pressure(0);
318
+ f.drain();
319
+ expect(f.decoded).toHaveLength(2);
320
+ });
321
+ it("refuses mismatched decoded geometry", () => {
322
+ const f = fixture();
323
+ f.decoder.receive(packet(), 1);
324
+ f.output(1000, 0, 400);
325
+ f.paint();
326
+ expect(f.drawn).toEqual([]);
327
+ expect(f.errors).toHaveLength(1);
328
+ });
329
+ it("closes pending and late frames on idempotent disposal", () => {
330
+ const f = fixture();
331
+ f.decoder.receive(packet(), 1);
332
+ f.output(1000);
333
+ f.decoder.close();
334
+ f.decoder.close();
335
+ f.output(1000);
336
+ f.paint();
337
+ expect(f.drawn).toEqual([]);
338
+ expect(f.closed).toEqual([1000, 1000]);
339
+ });
340
+ });
341
+
342
+ it("source-qualified old-epoch visuals keep the delta chain without input admission", () => {
343
+ const f = fixture(true);
344
+ f.decoder.receive(packet(1), 1);
345
+ f.output(1000);
346
+ f.paint();
347
+ f.epoch(2);
348
+ expect(f.decoder.receive(packet(2, "delta"), 1)).toBe(true);
349
+ f.output(2000);
350
+ f.paint();
351
+ expect(f.drawn).toEqual([1000, 2000]);
352
+ expect(f.admitted).toEqual([1]);
353
+ expect(f.decoder.receive(packet(3, "delta"), 2)).toBe(true);
354
+ f.output(3000);
355
+ f.paint();
356
+ expect(f.admitted).toEqual([1, 3]);
357
+ expect(f.codecs()).toBe(1);
358
+ expect(f.keys()).toBe(0);
359
+ });
360
+
361
+ it("source-only visual continuity still refuses obsolete navigation and unknown delta gaps", () => {
362
+ const f = fixture(true);
363
+ f.decoder.receive(packet(1), 1);
364
+ f.navigation(2);
365
+ f.output(1000);
366
+ f.paint();
367
+ expect(f.drawn).toEqual([]);
368
+ expect(f.decoder.receive(packet(2, "delta"), 1)).toBe(false);
369
+ const next = packet(3);
370
+ next.frame.navigationGeneration = 2;
371
+ expect(f.decoder.receive(next, 1)).toBe(true);
372
+ f.output(3000);
373
+ f.paint();
374
+ const gap = packet(5, "delta");
375
+ gap.frame.navigationGeneration = 2;
376
+ expect(f.decoder.receive(gap, 1)).toBe(false);
377
+ expect(f.keys()).toBe(1);
378
+ });
379
+
380
+ it("replaces a blocked decoder backlog only at a complete newer key without requesting recovery", () => {
381
+ const f = fixture();
382
+ expect(f.decoder.receive(packet(1, "key"), 1)).toBe(true);
383
+ f.pressure(4);
384
+ expect(f.decoder.receive(packet(2, "delta"), 1)).toBe(true);
385
+ expect(f.decoder.receive(packet(3, "delta"), 1)).toBe(true);
386
+ expect(f.decoder.receive(packet(4, "key"), 1)).toBe(true);
387
+ f.output(1000, 0);
388
+ f.paint();
389
+ expect(f.presented).toEqual([]);
390
+ f.pressure(0);
391
+ f.drain();
392
+ f.output(4000);
393
+ f.paint();
394
+ expect(f.decoded.map((value) => (value as { timestamp: number }).timestamp)).toEqual([
395
+ 1000, 4000,
396
+ ]);
397
+ expect(f.presented).toEqual([4]);
398
+ expect(f.keys()).toBe(0);
399
+ expect(f.errors).toEqual([]);
400
+ });
401
+
402
+ it("catch-up retains the original input epoch even when newer key pixels remain visually valid", () => {
403
+ const f = fixture(true);
404
+ f.decoder.receive(packet(1, "key"), 1);
405
+ f.pressure(4);
406
+ f.decoder.receive(packet(2, "delta"), 1);
407
+ f.decoder.receive(packet(3, "key"), 1);
408
+ f.epoch(2);
409
+ f.pressure(0);
410
+ f.drain();
411
+ f.output(3000);
412
+ f.paint();
413
+ expect(f.presented).toEqual([3]);
414
+ expect(f.admitted).toEqual([]);
415
+ expect(f.keys()).toBe(0);
416
+ });
417
+
418
+ /** A native acknowledgement may arrive after mutation or decoder replacement. */
419
+ it.each(["current", "mutation", "reset"] as const)(
420
+ "admits asynchronous native paint only for a current receipt (%s)",
421
+ async (invalidation) => {
422
+ let complete = () => {};
423
+ const f = fixture(
424
+ false,
425
+ () =>
426
+ new Promise<void>((resolve) => {
427
+ complete = resolve;
428
+ }),
429
+ );
430
+ f.decoder.receive(packet(), 1);
431
+ f.output(1000);
432
+ f.paint();
433
+ expect(f.presented).toEqual([]);
434
+ if (invalidation === "mutation") f.epoch(2);
435
+ if (invalidation === "reset") f.decoder.reset();
436
+ complete();
437
+ await Promise.resolve();
438
+ expect(f.presented).toEqual(invalidation === "current" ? [1] : []);
439
+ expect(f.closed).toEqual([1000]);
440
+ f.decoder.close();
441
+ },
442
+ );
443
+
444
+ it("serializes native paints while keeping only the newest waiting frame", async () => {
445
+ let complete = () => {};
446
+ const f = fixture(
447
+ false,
448
+ () =>
449
+ new Promise<void>((resolve) => {
450
+ complete = resolve;
451
+ }),
452
+ );
453
+ f.decoder.receive(packet(), 1);
454
+ f.output(1000);
455
+ f.paint();
456
+ f.decoder.receive(packet(2, "delta"), 1);
457
+ f.output(2000);
458
+ f.decoder.receive(packet(3, "delta"), 1);
459
+ f.output(3000);
460
+ f.paint();
461
+ expect(f.drawn).toEqual([1000]);
462
+ expect(f.presented).toEqual([]);
463
+ complete();
464
+ await Promise.resolve();
465
+ expect(f.presented).toEqual([1]);
466
+ f.paint();
467
+ expect(f.drawn).toEqual([1000, 3000]);
468
+ complete();
469
+ await Promise.resolve();
470
+ expect(f.presented).toEqual([1, 3]);
471
+ expect(f.closed).toEqual([2000, 1000, 3000]);
472
+ f.decoder.close();
473
+ });