@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,1201 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import type { BrowserGestureEvent, BrowserState } from "../shared/browser";
3
+ import { createBrowserCanvasInput } from "./browser-canvas-input";
4
+ import {
5
+ type BrowserGestureAuthority,
6
+ type BrowserGestureControl,
7
+ type BrowserGestureTransport,
8
+ createBrowserInputQueue,
9
+ } from "./browser-input-queue";
10
+
11
+ const authority: BrowserGestureAuthority = {
12
+ viewerToken: "viewer",
13
+ controlToken: "control",
14
+ expected: {
15
+ sessionId: "session",
16
+ runtimeId: "runtime",
17
+ bridgeEpoch: 1,
18
+ navigationGeneration: 1,
19
+ viewportGeneration: 1,
20
+ },
21
+ target: {
22
+ frameId: "decoded-front",
23
+ navigationGeneration: 1,
24
+ viewportGeneration: 1,
25
+ },
26
+ };
27
+ const freshAuthority: BrowserGestureAuthority = {
28
+ ...authority,
29
+ target: { ...authority.target, frameId: "fresh-front" },
30
+ };
31
+ const state = { captureScale: 1 } as BrowserState;
32
+ const point = (x: number) => ({ x, y: 1, width: 800, height: 600 });
33
+ const move = (x: number): BrowserGestureEvent => ({
34
+ kind: "move",
35
+ point: point(x),
36
+ });
37
+ function deferred<T>() {
38
+ let resolve!: (value: T) => void;
39
+ let reject!: (error: Error) => void;
40
+ return {
41
+ promise: new Promise<T>((yes, no) => {
42
+ resolve = yes;
43
+ reject = no;
44
+ }),
45
+ resolve,
46
+ reject,
47
+ };
48
+ }
49
+ async function flush() {
50
+ for (let i = 0; i < 12; i++) await Promise.resolve();
51
+ }
52
+ function fixture(
53
+ waitForFrame?: (afterFrameId: string, maxWaitMs?: number) => Promise<void>,
54
+ separateControl = false,
55
+ ) {
56
+ let current: BrowserGestureAuthority | null = authority;
57
+ let control: BrowserGestureControl | null = authority;
58
+ const sent: Parameters<BrowserGestureTransport["update"]>[0][] = [];
59
+ const ended: Parameters<BrowserGestureTransport["end"]>[0][] = [];
60
+ const errors: unknown[] = [];
61
+ const cursors: unknown[] = [];
62
+ const navigationCompleted: boolean[] = [];
63
+ const states: BrowserState[] = [];
64
+ let projectState: ((state: BrowserState) => void) | null = null;
65
+ let finished = 0;
66
+ const beginGate = deferred<Awaited<ReturnType<BrowserGestureTransport["begin"]>>>();
67
+ const transport: BrowserGestureTransport = {
68
+ begin: async () => await beginGate.promise,
69
+ update: async (input) => {
70
+ sent.push(input);
71
+ return {
72
+ state,
73
+ gestureId: input.gestureId,
74
+ nextSequence: input.sequence + 1,
75
+ cursor: "pointer",
76
+ };
77
+ },
78
+ end: async (input) => {
79
+ ended.push(input);
80
+ return { state, cursor: null };
81
+ },
82
+ };
83
+ const queue = createBrowserInputQueue({
84
+ transport,
85
+ authority: () => current,
86
+ ...(separateControl ? { controlAuthority: () => control } : {}),
87
+ onState: (next) => {
88
+ states.push(next);
89
+ projectState?.(next);
90
+ },
91
+ onCursor: (cursor) => cursors.push(cursor),
92
+ onError: (error) => errors.push(error),
93
+ onFinish: () => {
94
+ finished += 1;
95
+ },
96
+ onNavigationComplete: () => navigationCompleted.push(true),
97
+ ...(waitForFrame ? { waitForFrame } : {}),
98
+ });
99
+ return {
100
+ queue,
101
+ sent,
102
+ ended,
103
+ errors,
104
+ cursors,
105
+ beginGate,
106
+ transport,
107
+ navigationCompleted,
108
+ states,
109
+ finishes: () => finished,
110
+ projectState(callback: (state: BrowserState) => void) {
111
+ projectState = callback;
112
+ },
113
+ changeControl(next: BrowserGestureControl | null) {
114
+ control = next;
115
+ },
116
+ changeAuthority(next: BrowserGestureAuthority | null) {
117
+ current = next;
118
+ },
119
+ };
120
+ }
121
+
122
+ describe("bounded browser input queue", () => {
123
+ it("never sends orphan moves after a canvas touch starts without decoded-frame authority", async () => {
124
+ const f = fixture(undefined, true);
125
+ const input = createBrowserCanvasInput({
126
+ enabled: () => true,
127
+ viewport: () => ({ width: 800, height: 600 }),
128
+ enqueue: f.queue.enqueue,
129
+ finish: f.queue.finish,
130
+ cancel: f.queue.cancel,
131
+ onPoint: vi.fn(),
132
+ onActivity: vi.fn(),
133
+ });
134
+ const contact = { ...point(10), id: 0 };
135
+ f.changeAuthority(null);
136
+ expect(input.touch("start", [contact])).toBe(false);
137
+ f.changeAuthority(authority);
138
+ input.touch("move", [{ ...contact, x: 30 }]);
139
+ input.touch("end", []);
140
+ input.touch("start", [contact]);
141
+ f.beginGate.resolve({ state, gestureId: "fresh-touch", nextSequence: 1 });
142
+ await flush();
143
+ expect(f.sent.map(({ event }) => event)).toEqual([
144
+ { kind: "touch", type: "start", points: [contact] },
145
+ ]);
146
+ expect(f.errors).toEqual([]);
147
+ input.touch("end", []);
148
+ await flush();
149
+ f.queue.finish();
150
+ await flush();
151
+ });
152
+
153
+ it("completes acknowledged Enter navigation before state projection revokes the decoded frame", async () => {
154
+ const f = fixture();
155
+ const next: BrowserState = {
156
+ ...state,
157
+ status: "ready",
158
+ controller: "self",
159
+ sessionId: "session",
160
+ runtimeId: "runtime",
161
+ bridgeEpoch: 1,
162
+ viewportGeneration: 1,
163
+ navigationGeneration: 2,
164
+ };
165
+ f.projectState((reply) => {
166
+ if (reply.navigationGeneration === 2) f.changeAuthority(null);
167
+ });
168
+ f.transport.update = async (input) => {
169
+ f.sent.push(input);
170
+ return {
171
+ state: next,
172
+ gestureId: input.gestureId,
173
+ nextSequence: input.sequence + 1,
174
+ cursor: null,
175
+ completion: "navigation",
176
+ };
177
+ };
178
+ f.queue.enqueue({
179
+ kind: "key",
180
+ type: "down",
181
+ key: "Enter",
182
+ code: "Enter",
183
+ modifiers: 0,
184
+ repeat: false,
185
+ });
186
+ f.queue.enqueue({
187
+ kind: "key",
188
+ type: "up",
189
+ key: "Enter",
190
+ code: "Enter",
191
+ modifiers: 0,
192
+ repeat: false,
193
+ });
194
+ f.queue.enqueue({ kind: "text", text: "never type on the destination" });
195
+ f.queue.finish();
196
+ f.beginGate.resolve({ state, gestureId: "owned", nextSequence: 1 });
197
+ await flush();
198
+ expect(f.sent).toHaveLength(1);
199
+ expect(f.errors).toEqual([]);
200
+ expect(f.ended).toEqual([]);
201
+ expect(f.navigationCompleted).toEqual([true]);
202
+ expect(f.finishes()).toBe(1);
203
+ expect(f.cursors.at(-1)).toBeNull();
204
+ });
205
+
206
+ it("keeps the acknowledged click release but drops later old-page wheel and end", async () => {
207
+ const f = fixture();
208
+ f.transport.update = async (input) => {
209
+ f.sent.push(input);
210
+ if (input.event.kind !== "up") {
211
+ return {
212
+ state,
213
+ gestureId: input.gestureId,
214
+ nextSequence: input.sequence + 1,
215
+ cursor: null,
216
+ };
217
+ }
218
+ return {
219
+ state: {
220
+ ...state,
221
+ status: "ready",
222
+ controller: "self",
223
+ sessionId: "session",
224
+ runtimeId: "runtime",
225
+ bridgeEpoch: 1,
226
+ viewportGeneration: 1,
227
+ navigationGeneration: 2,
228
+ },
229
+ gestureId: input.gestureId,
230
+ nextSequence: input.sequence + 1,
231
+ cursor: null,
232
+ completion: "navigation",
233
+ };
234
+ };
235
+ f.projectState((reply) => {
236
+ if (reply.navigationGeneration === 2) f.changeAuthority(null);
237
+ });
238
+ f.queue.enqueue({ kind: "down", point: point(1), button: "left", clickCount: 1 });
239
+ f.queue.enqueue({ kind: "up", point: point(1), button: "left", clickCount: 1 });
240
+ f.queue.enqueue({ kind: "scroll", point: point(1), deltaX: 0, deltaY: 10 });
241
+ f.queue.finish();
242
+ f.beginGate.resolve({ state, gestureId: "owned", nextSequence: 1 });
243
+ await flush();
244
+ expect(f.sent.map((input) => input.event.kind)).toEqual(["down", "up"]);
245
+ expect(f.errors).toEqual([]);
246
+ expect(f.navigationCompleted).toEqual([true]);
247
+ expect(f.ended).toEqual([]);
248
+ });
249
+
250
+ it("does not grant navigation completion for changed runtime, bridge, viewport, controller or sequence", async () => {
251
+ const next: BrowserState = {
252
+ ...state,
253
+ status: "ready",
254
+ controller: "self",
255
+ sessionId: "session",
256
+ runtimeId: "runtime",
257
+ bridgeEpoch: 1,
258
+ viewportGeneration: 1,
259
+ navigationGeneration: 2,
260
+ };
261
+ for (const altered of [
262
+ { sessionId: "another-session" },
263
+ { runtimeId: "another-runtime" },
264
+ { bridgeEpoch: 2 },
265
+ { viewportGeneration: 2 },
266
+ { controller: "other" as const },
267
+ { navigationGeneration: 1 },
268
+ { status: "error" as const },
269
+ ]) {
270
+ const f = fixture();
271
+ f.transport.update = async (input) => {
272
+ f.sent.push(input);
273
+ return {
274
+ state: { ...next, ...altered },
275
+ gestureId: input.gestureId,
276
+ nextSequence: input.sequence + 1,
277
+ cursor: null,
278
+ completion: "navigation",
279
+ };
280
+ };
281
+ f.queue.enqueue(move(1));
282
+ f.queue.enqueue(move(2));
283
+ f.beginGate.resolve({ state, gestureId: "owned", nextSequence: 1 });
284
+ await flush();
285
+ expect(f.navigationCompleted).toEqual([]);
286
+ expect(f.sent).toHaveLength(1);
287
+ expect(f.errors).toHaveLength(1);
288
+ expect(f.ended).toHaveLength(1);
289
+ }
290
+ const sequence = fixture();
291
+ sequence.transport.update = async (input) => ({
292
+ state: next,
293
+ gestureId: input.gestureId,
294
+ nextSequence: input.sequence + 2,
295
+ cursor: null,
296
+ completion: "navigation",
297
+ });
298
+ sequence.queue.enqueue(move(1));
299
+ sequence.beginGate.resolve({ state, gestureId: "owned", nextSequence: 1 });
300
+ await flush();
301
+ expect(sequence.errors).toHaveLength(1);
302
+ expect(sequence.navigationCompleted).toEqual([]);
303
+ });
304
+
305
+ it("unmarked navigation and replacement control retain the existing context rejection", async () => {
306
+ const f = fixture();
307
+ const next: BrowserState = { ...state, navigationGeneration: 2 };
308
+ f.projectState((reply) => {
309
+ if (reply.navigationGeneration === 2) f.changeAuthority(null);
310
+ });
311
+ f.transport.update = async (input) => ({
312
+ state: next,
313
+ gestureId: input.gestureId,
314
+ nextSequence: input.sequence + 1,
315
+ cursor: null,
316
+ });
317
+ f.queue.enqueue(move(1));
318
+ f.beginGate.resolve({ state, gestureId: "owned", nextSequence: 1 });
319
+ await flush();
320
+ expect(f.errors).toHaveLength(1);
321
+ expect(f.navigationCompleted).toEqual([]);
322
+
323
+ const changed = fixture();
324
+ changed.transport.update = async (input) => {
325
+ changed.changeAuthority({ ...authority, controlToken: "replacement" });
326
+ return {
327
+ state: {
328
+ ...next,
329
+ status: "ready",
330
+ controller: "self",
331
+ sessionId: "session",
332
+ runtimeId: "runtime",
333
+ bridgeEpoch: 1,
334
+ viewportGeneration: 1,
335
+ },
336
+ gestureId: input.gestureId,
337
+ nextSequence: input.sequence + 1,
338
+ cursor: null,
339
+ completion: "navigation",
340
+ };
341
+ };
342
+ changed.queue.enqueue(move(1));
343
+ changed.beginGate.resolve({ state, gestureId: "owned", nextSequence: 1 });
344
+ await flush();
345
+ expect(changed.navigationCompleted).toEqual([]);
346
+ expect(changed.states).toEqual([state]);
347
+ expect(changed.ended).toHaveLength(1);
348
+ });
349
+ it("coalesces only motion and wheel distance while keeping press/release sequence", async () => {
350
+ const f = fixture();
351
+ f.queue.enqueue(move(1));
352
+ for (let i = 2; i < 100; i++) f.queue.enqueue(move(i));
353
+ f.queue.enqueue({
354
+ kind: "down",
355
+ point: point(99),
356
+ button: "left",
357
+ clickCount: 1,
358
+ });
359
+ f.queue.enqueue({ kind: "scroll", point: point(99), deltaX: 2, deltaY: 5 });
360
+ f.queue.enqueue({
361
+ kind: "scroll",
362
+ point: point(100),
363
+ deltaX: 3,
364
+ deltaY: 7,
365
+ });
366
+ f.queue.enqueue({
367
+ kind: "up",
368
+ point: point(100),
369
+ button: "left",
370
+ clickCount: 1,
371
+ });
372
+ f.queue.finish();
373
+ f.beginGate.resolve({ state, gestureId: "owned", nextSequence: 1 });
374
+ await flush();
375
+ expect(f.sent.map((input) => input.event.kind)).toEqual([
376
+ "move",
377
+ "move",
378
+ "down",
379
+ "scroll",
380
+ "up",
381
+ ]);
382
+ expect(f.sent.map((input) => input.sequence)).toEqual([1, 2, 3, 4, 5]);
383
+ expect(f.sent[2]?.target).toEqual(authority.target);
384
+ expect(f.sent[0]?.target).toBeUndefined();
385
+ expect(f.sent[3]?.event).toMatchObject({ deltaX: 5, deltaY: 12 });
386
+ expect(f.ended[0]).toMatchObject({ sequence: 6, cancel: false });
387
+ expect(f.errors).toEqual([]);
388
+ });
389
+
390
+ it("keeps touch-ID edges through 60Hz motion and partial release", async () => {
391
+ const f = fixture();
392
+ const touch = (
393
+ type: "start" | "move" | "end",
394
+ ids: number[],
395
+ x: number,
396
+ ): BrowserGestureEvent => ({
397
+ kind: "touch",
398
+ type,
399
+ points: ids.map((id) => ({ ...point(x + id), id })),
400
+ });
401
+ f.queue.enqueue(touch("start", [0], 1));
402
+ f.queue.enqueue(touch("start", [0, 1], 2));
403
+ for (let i = 0; i < 180; i++) f.queue.enqueue(touch("move", [0, 1], i));
404
+ f.queue.enqueue(touch("move", [1], 181));
405
+ f.queue.enqueue(touch("end", [], 182));
406
+ f.queue.finish();
407
+ f.beginGate.resolve({ state, gestureId: "touch-channel", nextSequence: 1 });
408
+ await flush();
409
+ expect(
410
+ f.sent.map((input) =>
411
+ input.event.kind === "touch"
412
+ ? [input.event.type, input.event.points.map((point) => point.id)]
413
+ : null,
414
+ ),
415
+ ).toEqual([
416
+ ["start", [0]],
417
+ ["start", [0, 1]],
418
+ ["move", [0, 1]],
419
+ ["move", [1]],
420
+ ["end", []],
421
+ ]);
422
+ expect(f.sent[0]?.target).toEqual(authority.target);
423
+ expect(f.sent[1]?.target).toBeUndefined();
424
+ expect(f.errors).toEqual([]);
425
+ });
426
+
427
+ it("revokes late begin replies on lease replacement and never sends queued presses", async () => {
428
+ const f = fixture();
429
+ f.queue.enqueue({
430
+ kind: "down",
431
+ point: point(1),
432
+ button: "left",
433
+ clickCount: 1,
434
+ });
435
+ f.changeAuthority({ ...authority, controlToken: "replacement" });
436
+ f.queue.cancel();
437
+ f.beginGate.resolve({ state, gestureId: "obsolete", nextSequence: 1 });
438
+ await flush();
439
+ expect(f.sent).toEqual([]);
440
+ expect(f.ended[0]).toMatchObject({ gestureId: "obsolete", cancel: true });
441
+ expect(f.cursors.filter(Boolean)).toEqual([]);
442
+ });
443
+
444
+ it("does not replay an unknown update and cancels with original identity", async () => {
445
+ const f = fixture();
446
+ let attempts = 0;
447
+ f.transport.update = async () => {
448
+ attempts++;
449
+ throw new Error("Unknown outcome");
450
+ };
451
+ f.queue.enqueue({
452
+ kind: "down",
453
+ point: point(1),
454
+ button: "left",
455
+ clickCount: 1,
456
+ });
457
+ f.queue.enqueue(move(2));
458
+ f.queue.enqueue({
459
+ kind: "up",
460
+ point: point(2),
461
+ button: "left",
462
+ clickCount: 1,
463
+ });
464
+ f.beginGate.resolve({ state, gestureId: "owned", nextSequence: 1 });
465
+ await flush();
466
+ expect(attempts).toBe(1);
467
+ expect(f.ended[0]).toMatchObject({ gestureId: "owned", cancel: true });
468
+ expect(f.errors).toHaveLength(1);
469
+ });
470
+
471
+ it("holds at one in-flight update and never publishes a late cursor into a new generation", async () => {
472
+ const f = fixture();
473
+ const updateGate = deferred<Awaited<ReturnType<BrowserGestureTransport["update"]>>>();
474
+ let attempts = 0;
475
+ f.transport.update = async () => {
476
+ attempts++;
477
+ return await updateGate.promise;
478
+ };
479
+ f.queue.enqueue(move(1));
480
+ f.beginGate.resolve({ state, gestureId: "owned", nextSequence: 1 });
481
+ await flush();
482
+ f.queue.enqueue(move(2));
483
+ f.changeAuthority({
484
+ ...authority,
485
+ expected: { ...authority.expected, viewportGeneration: 2 },
486
+ });
487
+ f.queue.cancel();
488
+ updateGate.resolve({
489
+ state,
490
+ gestureId: "owned",
491
+ nextSequence: 2,
492
+ cursor: "text",
493
+ });
494
+ await flush();
495
+ expect(attempts).toBe(1);
496
+ expect(f.cursors.filter(Boolean)).toEqual([]);
497
+ expect(f.ended[0]).toMatchObject({ sequence: 2, cancel: true });
498
+ });
499
+
500
+ it("changes hover mouse to touch without discarding the first unsent touch", async () => {
501
+ const f = fixture();
502
+ const began: string[] = [];
503
+ f.transport.begin = async (input) => {
504
+ began.push(input.pointerKind);
505
+ return { state, gestureId: `channel-${began.length}`, nextSequence: 1 };
506
+ };
507
+ f.queue.enqueue(move(1));
508
+ await flush();
509
+ f.queue.enqueue({
510
+ kind: "touch",
511
+ type: "start",
512
+ points: [{ ...point(2), id: 7 }],
513
+ });
514
+ await flush();
515
+ expect(began).toEqual(["mouse", "touch"]);
516
+ expect(f.ended[0]).toMatchObject({ gestureId: "channel-1", cancel: false });
517
+ expect(f.sent[1]).toMatchObject({
518
+ gestureId: "channel-2",
519
+ event: { type: "start" },
520
+ });
521
+ expect(f.errors).toEqual([]);
522
+ });
523
+
524
+ it("waits for actual decoded pixels between independent presses, never for a held second finger", async () => {
525
+ const frame = deferred<void>();
526
+ const waited: string[] = [];
527
+ const f = fixture(async (id) => {
528
+ waited.push(id);
529
+ await frame.promise;
530
+ });
531
+ f.beginGate.resolve({ state, gestureId: "owned", nextSequence: 1 });
532
+ f.queue.enqueue({ kind: "scroll", point: point(1), deltaX: 0, deltaY: 20 });
533
+ await flush();
534
+ f.queue.enqueue({
535
+ kind: "down",
536
+ point: point(1),
537
+ button: "left",
538
+ clickCount: 1,
539
+ });
540
+ f.queue.enqueue({
541
+ kind: "up",
542
+ point: point(1),
543
+ button: "left",
544
+ clickCount: 1,
545
+ });
546
+ await flush();
547
+ expect(f.sent.map((input) => input.event.kind)).toEqual(["scroll"]);
548
+ expect(waited).toEqual(["decoded-front"]);
549
+ f.changeAuthority({
550
+ ...authority,
551
+ target: { ...authority.target, frameId: "new-front" },
552
+ });
553
+ frame.resolve();
554
+ await flush();
555
+ expect(f.sent[1]?.target?.frameId).toBe("new-front");
556
+ expect(f.sent.map((input) => input.event.kind)).toEqual(["scroll", "down", "up"]);
557
+ expect(f.errors).toEqual([]);
558
+ });
559
+
560
+ it("drains queued mouseup then leave before normal cleanup without fresh-frame waits", async () => {
561
+ const waits: string[] = [];
562
+ const f = fixture(async (id) => {
563
+ waits.push(id);
564
+ });
565
+ f.queue.enqueue({
566
+ kind: "down",
567
+ point: point(1),
568
+ button: "left",
569
+ clickCount: 1,
570
+ });
571
+ f.queue.enqueue({
572
+ kind: "up",
573
+ point: point(1),
574
+ button: "left",
575
+ clickCount: 1,
576
+ });
577
+ f.queue.finish();
578
+ f.queue.enqueue({ kind: "leave" });
579
+ f.queue.finish();
580
+ f.beginGate.resolve({ state, gestureId: "owned", nextSequence: 1 });
581
+ await flush();
582
+ expect(f.sent.map((input) => input.event.kind)).toEqual(["down", "up", "leave"]);
583
+ expect(f.ended).toHaveLength(1);
584
+ expect(f.ended[0]?.cancel).toBe(false);
585
+ expect(waits).toEqual([]);
586
+ expect(f.errors).toEqual([]);
587
+ });
588
+ it("refreshes only a known unadmitted begin, then sends the pending wheel once", async () => {
589
+ const f = fixture(async () => {
590
+ f.changeAuthority({
591
+ ...authority,
592
+ target: { ...authority.target, frameId: "post-scroll-decoded" },
593
+ });
594
+ });
595
+ let begins = 0;
596
+ f.transport.begin = async () => {
597
+ begins += 1;
598
+ return begins === 1
599
+ ? { state, admission: "stale-frame" }
600
+ : { state, gestureId: "owned", nextSequence: 1 };
601
+ };
602
+ f.queue.enqueue({ kind: "scroll", point: point(1), deltaX: 0, deltaY: 20 });
603
+ await flush();
604
+ expect(begins).toBe(2);
605
+ expect(f.sent).toHaveLength(1);
606
+ expect(f.errors).toEqual([]);
607
+ });
608
+
609
+ it("does not retry begin or update after transport error or uncertain physical publication", async () => {
610
+ for (const failure of ["begin", "update"] as const) {
611
+ const f = fixture(async () => {
612
+ throw new Error("Must not wait/retry");
613
+ });
614
+ let begins = 0;
615
+ let updates = 0;
616
+ f.transport.begin = async () => {
617
+ begins += 1;
618
+ if (failure === "begin") throw new Error("Outcome unknown");
619
+ return { state, gestureId: "owned", nextSequence: 1 };
620
+ };
621
+ f.transport.update = async () => {
622
+ updates += 1;
623
+ throw new Error("Outcome unknown");
624
+ };
625
+ f.queue.enqueue({ kind: "scroll", point: point(1), deltaX: 0, deltaY: 20 });
626
+ await flush();
627
+ expect(begins).toBe(1);
628
+ expect(updates).toBe(failure === "begin" ? 0 : 1);
629
+ expect(f.errors).toHaveLength(1);
630
+ }
631
+ });
632
+
633
+ it("bounds stale admission and refuses replacement control while waiting for decode", async () => {
634
+ const f = fixture(async () => {
635
+ f.changeAuthority({
636
+ ...authority,
637
+ target: { ...authority.target, frameId: String(++front) },
638
+ });
639
+ });
640
+ let front = 0;
641
+ let begins = 0;
642
+ f.transport.begin = async () => {
643
+ begins += 1;
644
+ return { state, admission: "stale-frame" };
645
+ };
646
+ f.queue.enqueue(move(1));
647
+ await flush();
648
+ expect(begins).toBe(3);
649
+ expect(f.sent).toHaveLength(0);
650
+ expect(f.errors).toHaveLength(1);
651
+ const changed = fixture(async () => {
652
+ changed.changeAuthority({ ...authority, controlToken: "replacement" });
653
+ });
654
+ let changedBegins = 0;
655
+ changed.transport.begin = async () => {
656
+ changedBegins += 1;
657
+ return { state, admission: "stale-frame" };
658
+ };
659
+ changed.queue.enqueue(move(1));
660
+ await flush();
661
+ expect(changedBegins).toBe(1);
662
+ expect(changed.sent).toHaveLength(0);
663
+ });
664
+
665
+ it("shares one four-second admission wait budget across stale decoder handoffs", async () => {
666
+ let clock = 1_000;
667
+ const now = vi.spyOn(Date, "now").mockImplementation(() => clock);
668
+ try {
669
+ const budgets: number[] = [];
670
+ const f = fixture(async (_id, budget) => {
671
+ budgets.push(budget!);
672
+ clock += budgets.length === 1 ? 3_000 : 1_001;
673
+ f.changeAuthority({
674
+ ...authority,
675
+ target: { ...authority.target, frameId: String(clock) },
676
+ });
677
+ });
678
+ let begins = 0;
679
+ f.transport.begin = async () => {
680
+ begins += 1;
681
+ return { state, admission: "stale-frame" };
682
+ };
683
+ f.queue.enqueue(move(1));
684
+ await flush();
685
+ expect(budgets).toEqual([4_000, 1_000]);
686
+ expect(begins).toBe(2);
687
+ expect(f.sent).toHaveLength(0);
688
+ expect(f.errors).toHaveLength(1);
689
+ } finally {
690
+ now.mockRestore();
691
+ }
692
+ });
693
+
694
+ it("does not begin under replacement control after the initial invalidated-frame wait", async () => {
695
+ const f = fixture(async () => {
696
+ f.changeAuthority({ ...authority, controlToken: "replacement" });
697
+ });
698
+ let begins = 0;
699
+ f.transport.begin = async () => {
700
+ begins += 1;
701
+ return { state, gestureId: "owned", nextSequence: 1 };
702
+ };
703
+ f.queue.enqueue({ kind: "scroll", point: point(1), deltaX: 0, deltaY: 20 });
704
+ await flush();
705
+ f.queue.finish();
706
+ await flush();
707
+ f.queue.enqueue(move(1));
708
+ await flush();
709
+ expect(begins).toBe(1);
710
+ expect(f.sent).toHaveLength(1);
711
+ expect(f.errors).toEqual([]);
712
+ });
713
+ });
714
+
715
+ it("keeps keyboard repeat/Unicode edges ordered and a held modifier alive across mouse release", async () => {
716
+ const f = fixture();
717
+ const key = {
718
+ kind: "key",
719
+ type: "down",
720
+ key: "Control",
721
+ code: "ControlLeft",
722
+ modifiers: 2,
723
+ repeat: false,
724
+ } as const;
725
+ f.queue.enqueue(key);
726
+ f.queue.enqueue({ kind: "down", point: point(1), button: "left", clickCount: 1 });
727
+ f.queue.enqueue({ kind: "up", point: point(1), button: "left", clickCount: 1 });
728
+ f.queue.finish();
729
+ f.beginGate.resolve({ state, gestureId: "owned", nextSequence: 1 });
730
+ await flush();
731
+ expect(f.ended).toEqual([]);
732
+ f.queue.enqueue({ ...key, type: "up", modifiers: 0 });
733
+ f.queue.enqueue({
734
+ kind: "key",
735
+ type: "down",
736
+ key: "é",
737
+ code: "KeyE",
738
+ modifiers: 0,
739
+ repeat: false,
740
+ });
741
+ f.queue.enqueue({
742
+ kind: "key",
743
+ type: "down",
744
+ key: "é",
745
+ code: "KeyE",
746
+ modifiers: 0,
747
+ repeat: true,
748
+ });
749
+ f.queue.enqueue({ kind: "key", type: "up", key: "é", code: "KeyE", modifiers: 0, repeat: false });
750
+ f.queue.enqueue({ kind: "text", text: "漢字\n😀" });
751
+ f.queue.finish();
752
+ await flush();
753
+ expect(f.sent.map((input) => input.event.kind)).toEqual([
754
+ "key",
755
+ "down",
756
+ "up",
757
+ "key",
758
+ "key",
759
+ "key",
760
+ "key",
761
+ "text",
762
+ ]);
763
+ expect(f.sent.map((input) => input.sequence)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
764
+ expect(f.ended).toHaveLength(1);
765
+ expect(f.errors).toEqual([]);
766
+ });
767
+
768
+ it("never merges normal scrolling with a differently modified wheel gesture", async () => {
769
+ const f = fixture();
770
+ f.queue.enqueue({ kind: "scroll", point: point(1), deltaX: 0, deltaY: 10, modifiers: 0 });
771
+ f.queue.enqueue({ kind: "scroll", point: point(1), deltaX: 0, deltaY: 20, modifiers: 2 });
772
+ f.queue.enqueue({ kind: "scroll", point: point(1), deltaX: 0, deltaY: 30, modifiers: 2 });
773
+ f.beginGate.resolve({ state, gestureId: "owned", nextSequence: 1 });
774
+ await flush();
775
+ expect(f.sent.map((input) => input.event)).toEqual([
776
+ { kind: "scroll", point: point(1), deltaX: 0, deltaY: 10, modifiers: 0 },
777
+ { kind: "scroll", point: point(1), deltaX: 0, deltaY: 50, modifiers: 2 },
778
+ ]);
779
+ f.queue.cancel();
780
+ await flush();
781
+ });
782
+
783
+ it("keeps a held drag/release/end when pixels are revoked, but a new press then needs decoded pixels", async () => {
784
+ const f = fixture(undefined, true);
785
+ f.queue.enqueue({ kind: "down", button: "left", point: point(1), clickCount: 1 });
786
+ f.beginGate.resolve({ state, gestureId: "opaque", nextSequence: 1 });
787
+ await flush();
788
+ f.changeAuthority(null);
789
+ expect(f.queue.enqueue(move(50))).toBe(true);
790
+ expect(f.queue.enqueue({ kind: "up", button: "left", point: point(50), clickCount: 1 })).toBe(
791
+ true,
792
+ );
793
+ f.queue.finish();
794
+ await flush();
795
+ expect(f.sent.map((value) => value.event.kind)).toEqual(["down", "move", "up"]);
796
+ expect(f.sent.slice(1).every((value) => value.target === undefined)).toBe(true);
797
+ expect(f.ended).toEqual([expect.objectContaining({ gestureId: "opaque", cancel: false })]);
798
+ expect(f.errors).toEqual([]);
799
+ // No retained admission survives the closed channel.
800
+ expect(f.queue.enqueue({ kind: "down", button: "left", point: point(50), clickCount: 1 })).toBe(
801
+ false,
802
+ );
803
+ expect(f.sent).toHaveLength(3);
804
+ });
805
+
806
+ it("keeps an admitted keyup, but a new press after revoked pixels is refused before publication", async () => {
807
+ const f = fixture(undefined, true);
808
+ f.queue.enqueue({
809
+ kind: "key",
810
+ type: "down",
811
+ key: "Control",
812
+ code: "ControlLeft",
813
+ modifiers: 2,
814
+ repeat: false,
815
+ });
816
+ f.beginGate.resolve({ state, gestureId: "opaque", nextSequence: 1 });
817
+ await flush();
818
+ f.changeAuthority(null);
819
+ expect(
820
+ f.queue.enqueue({
821
+ kind: "key",
822
+ type: "up",
823
+ key: "Control",
824
+ code: "ControlLeft",
825
+ modifiers: 0,
826
+ repeat: false,
827
+ }),
828
+ ).toBe(true);
829
+ await flush();
830
+ f.queue.enqueue({ kind: "down", button: "left", point: point(2), clickCount: 1 });
831
+ await flush();
832
+ expect(f.sent.map((value) => value.event.kind)).toEqual(["key", "key"]);
833
+ expect(f.errors).toHaveLength(1);
834
+ expect(f.ended).toEqual([expect.objectContaining({ cancel: true })]);
835
+ });
836
+
837
+ it("control incarnation replacement still cancels an admitted continuation before publication", async () => {
838
+ const f = fixture(undefined, true);
839
+ f.queue.enqueue({ kind: "down", button: "left", point: point(1), clickCount: 1 });
840
+ f.beginGate.resolve({ state, gestureId: "opaque", nextSequence: 1 });
841
+ await flush();
842
+ const gate = deferred<Awaited<ReturnType<BrowserGestureTransport["update"]>>>();
843
+ f.transport.update = async (input) => {
844
+ f.sent.push(input);
845
+ return await gate.promise;
846
+ };
847
+ f.queue.enqueue(move(2));
848
+ f.queue.enqueue(move(3));
849
+ f.changeAuthority(null);
850
+ f.changeControl({ ...authority, expected: { ...authority.expected, runtimeId: "replacement" } });
851
+ gate.resolve({ state, gestureId: "opaque", nextSequence: 3, cursor: null });
852
+ await flush();
853
+ expect(f.sent.map((value) => value.event.kind)).toEqual(["down", "move"]);
854
+ expect(f.ended.at(-1)?.cancel).toBe(true);
855
+ });
856
+
857
+ /** Simulate native idle expiry while the client runs no scheduled cleanup callbacks. */
858
+ function idleFixture() {
859
+ let clock = 0;
860
+ const clockSpy = vi.spyOn(performance, "now").mockImplementation(() => clock);
861
+ const f = fixture(undefined, true);
862
+ const beginnings: BrowserGestureAuthority[] = [];
863
+ f.transport.begin = async (input) => {
864
+ beginnings.push(input);
865
+ return { state, gestureId: `opaque-${beginnings.length}`, nextSequence: 1 };
866
+ };
867
+ const publish = f.transport.update;
868
+ f.transport.update = async (input) => {
869
+ if (clock >= 5_000 && input.gestureId === "opaque-1") {
870
+ throw new Error(
871
+ "Workspace runtime input.check failed: Live browser input attachment changed",
872
+ );
873
+ }
874
+ return publish(input);
875
+ };
876
+ const end = f.transport.end;
877
+ f.transport.end = async (input) => {
878
+ if (clock >= 5_000 && input.gestureId === "opaque-1" && !input.cancel) {
879
+ throw new Error("Browser gesture is unavailable");
880
+ }
881
+ return end(input);
882
+ };
883
+ return {
884
+ ...f,
885
+ beginnings,
886
+ advanceClock: (elapsedMs: number) => {
887
+ clock += elapsedMs;
888
+ },
889
+ async cleanup() {
890
+ f.queue.cancel();
891
+ await flush();
892
+ clockSpy.mockRestore();
893
+ },
894
+ };
895
+ }
896
+
897
+ it("reopens an expired channel only from a newer decoded frame, never from the old admission", async () => {
898
+ const f = idleFixture();
899
+ try {
900
+ f.queue.enqueue({ kind: "down", button: "left", point: point(1), clickCount: 1 });
901
+ f.queue.enqueue({ kind: "up", button: "left", point: point(1), clickCount: 1 });
902
+ await flush();
903
+ f.changeAuthority(freshAuthority);
904
+ // Simulate a paused client: no timer or finish callback ran before native expiry.
905
+ f.advanceClock(6_000);
906
+ f.queue.enqueue({ kind: "down", button: "left", point: point(2), clickCount: 1 });
907
+ await flush();
908
+ expect(f.errors).toEqual([]);
909
+ expect(f.beginnings).toHaveLength(2);
910
+ expect(f.beginnings[1]?.target).toEqual(freshAuthority.target);
911
+ expect(f.sent.map((input) => input.gestureId)).toEqual(["opaque-1", "opaque-1", "opaque-2"]);
912
+ expect(f.sent.at(-1)?.target).toEqual(freshAuthority.target);
913
+ } finally {
914
+ await f.cleanup();
915
+ }
916
+ });
917
+
918
+ it("retires a late idle finish without addressing the expired channel", async () => {
919
+ const f = idleFixture();
920
+ try {
921
+ f.queue.enqueue(move(1));
922
+ await flush();
923
+ f.advanceClock(6_000);
924
+ f.queue.finish();
925
+ await flush();
926
+ expect(f.errors).toEqual([]);
927
+ expect(f.ended).toEqual([]);
928
+ expect(f.finishes()).toBe(1);
929
+ f.changeAuthority(freshAuthority);
930
+ expect(f.queue.enqueue(move(2))).toBe(true);
931
+ await flush();
932
+ expect(f.beginnings).toHaveLength(2);
933
+ expect(f.sent.at(-1)?.gestureId).toBe("opaque-2");
934
+ } finally {
935
+ await f.cleanup();
936
+ }
937
+ });
938
+
939
+ it("never reopens a held channel or replays its rejected release after idle expiry", async () => {
940
+ const f = idleFixture();
941
+ try {
942
+ f.queue.enqueue({ kind: "down", button: "left", point: point(1), clickCount: 1 });
943
+ await flush();
944
+ f.advanceClock(6_000);
945
+ f.queue.enqueue({ kind: "up", button: "left", point: point(1), clickCount: 1 });
946
+ await flush();
947
+ expect(f.beginnings).toHaveLength(1);
948
+ expect(f.sent).toHaveLength(1);
949
+ expect(f.errors).toHaveLength(1);
950
+ expect(f.ended).toMatchObject([{ gestureId: "opaque-1", cancel: true }]);
951
+ } finally {
952
+ await f.cleanup();
953
+ }
954
+ });
955
+
956
+ it("discards suspended idle admission when native control identity changes", async () => {
957
+ const f = idleFixture();
958
+ try {
959
+ f.queue.enqueue(move(1));
960
+ await flush();
961
+ f.advanceClock(6_000);
962
+ f.changeAuthority(null);
963
+ f.changeControl({ ...authority, controlToken: "replacement-control" });
964
+ expect(f.queue.enqueue(move(2))).toBe(false);
965
+ await flush();
966
+ expect(f.beginnings).toHaveLength(1);
967
+ expect(f.sent).toHaveLength(1);
968
+ expect(f.ended).toMatchObject([{ gestureId: "opaque-1", cancel: true }]);
969
+ } finally {
970
+ await f.cleanup();
971
+ }
972
+ });
973
+
974
+ it("never reopens on a spent receipt: the next press waits for a newer decoded frame", async () => {
975
+ let waited = 0;
976
+ const f: ReturnType<typeof fixture> = fixture(async (afterFrameId) => {
977
+ waited += 1;
978
+ expect(afterFrameId).toBe(authority.target.frameId);
979
+ f.changeAuthority(freshAuthority);
980
+ }, true);
981
+ const beginnings: BrowserGestureAuthority[] = [];
982
+ f.transport.begin = async (input) => {
983
+ beginnings.push(input);
984
+ return { state, gestureId: `opaque-${beginnings.length}`, nextSequence: 1 };
985
+ };
986
+ f.queue.enqueue({ kind: "down", button: "left", point: point(1), clickCount: 1 });
987
+ f.queue.enqueue({ kind: "up", button: "left", point: point(1), clickCount: 1 });
988
+ await flush();
989
+ f.queue.finish();
990
+ await flush();
991
+ expect(f.queue.enqueue({ kind: "down", button: "left", point: point(2), clickCount: 1 })).toBe(
992
+ true,
993
+ );
994
+ await flush();
995
+ expect(waited).toBe(1);
996
+ expect(beginnings).toHaveLength(2);
997
+ expect(beginnings[1]?.target).toEqual(freshAuthority.target);
998
+ expect(f.sent.at(-1)?.gestureId).toBe("opaque-2");
999
+ expect(f.sent.at(-1)?.target).toEqual(freshAuthority.target);
1000
+ expect(f.errors).toEqual([]);
1001
+ f.queue.cancel();
1002
+ await flush();
1003
+ f.changeControl(null);
1004
+ expect(f.queue.enqueue(move(3))).toBe(false);
1005
+ });
1006
+
1007
+ it("does not create geometry admission from an unknown begin or visual-only frame", async () => {
1008
+ const f = fixture(undefined, true);
1009
+ f.changeAuthority(null);
1010
+ expect(f.queue.enqueue(move(1))).toBe(false);
1011
+ f.changeAuthority(authority);
1012
+ f.transport.begin = async () => {
1013
+ throw new Error("Unknown publication");
1014
+ };
1015
+ f.queue.enqueue(move(1));
1016
+ await flush();
1017
+ expect(f.errors).toHaveLength(1);
1018
+ f.changeAuthority(null);
1019
+ expect(f.queue.enqueue(move(2))).toBe(false);
1020
+ expect(f.sent).toEqual([]);
1021
+ });
1022
+
1023
+ it("does not retain admitted geometry after an uncertain update", async () => {
1024
+ const f = fixture(undefined, true);
1025
+ f.transport.begin = async () => ({ state, gestureId: "opaque", nextSequence: 1 });
1026
+ f.transport.update = async () => {
1027
+ throw new Error("Lost published update reply");
1028
+ };
1029
+ f.queue.enqueue({ kind: "down", button: "left", point: point(1), clickCount: 1 });
1030
+ await flush();
1031
+ f.changeAuthority(null);
1032
+ expect(f.queue.enqueue(move(2))).toBe(false);
1033
+ expect(f.errors).toHaveLength(1);
1034
+ expect(f.ended).toEqual([expect.objectContaining({ cancel: true })]);
1035
+ });
1036
+
1037
+ it.each(["viewer", "controller", "session", "runtime", "bridge", "document", "viewport"])(
1038
+ "discards idle geometry across %s replacement without a decoded replacement basis",
1039
+ async (field) => {
1040
+ const f = fixture(undefined, true);
1041
+ let begins = 0;
1042
+ f.transport.begin = async () => ({ state, gestureId: `opaque-${++begins}`, nextSequence: 1 });
1043
+ f.queue.enqueue({ kind: "scroll", point: point(1), deltaX: 0, deltaY: 10 });
1044
+ await flush();
1045
+ f.queue.finish();
1046
+ await flush();
1047
+ f.changeAuthority(null);
1048
+ const replacement = structuredClone(authority);
1049
+ if (field === "viewer") replacement.viewerToken = "new-viewer";
1050
+ if (field === "controller") replacement.controlToken = "new-control";
1051
+ if (field === "session") replacement.expected.sessionId = "new-session";
1052
+ if (field === "runtime") replacement.expected.runtimeId = "new-runtime";
1053
+ if (field === "bridge") replacement.expected.bridgeEpoch = 2;
1054
+ if (field === "document") replacement.expected.navigationGeneration = 2;
1055
+ if (field === "viewport") replacement.expected.viewportGeneration = 2;
1056
+ f.changeControl(replacement);
1057
+ expect(f.queue.enqueue({ kind: "down", button: "left", point: point(2), clickCount: 1 })).toBe(
1058
+ false,
1059
+ );
1060
+ expect(begins).toBe(1);
1061
+ expect(f.sent).toHaveLength(1);
1062
+ },
1063
+ );
1064
+
1065
+ it("gives each independent touch contact its own decoded receipt; fingers within a gesture and text reuse the channel", async () => {
1066
+ const f: ReturnType<typeof fixture> = fixture(async () => {
1067
+ f.changeAuthority(freshAuthority);
1068
+ }, true);
1069
+ const touch = (type: "start" | "move" | "end", ids: number[]): BrowserGestureEvent => ({
1070
+ kind: "touch",
1071
+ type,
1072
+ points: ids.map((id) => ({ ...point(id + 1), id })),
1073
+ });
1074
+ f.queue.enqueue(touch("start", [0]));
1075
+ f.beginGate.resolve({ state, gestureId: "opaque", nextSequence: 1 });
1076
+ await flush();
1077
+ f.queue.enqueue(touch("start", [0, 1]));
1078
+ f.queue.enqueue(touch("move", [1]));
1079
+ f.queue.enqueue(touch("end", []));
1080
+ f.queue.enqueue(touch("start", [2]));
1081
+ f.queue.enqueue(touch("end", []));
1082
+ f.queue.enqueue({ kind: "text", text: "Unicode π" });
1083
+ await flush();
1084
+ expect(f.sent.map((input) => input.event.kind)).toEqual([
1085
+ "touch",
1086
+ "touch",
1087
+ "touch",
1088
+ "touch",
1089
+ "touch",
1090
+ "touch",
1091
+ "text",
1092
+ ]);
1093
+ expect(f.sent.map((input) => input.sequence)).toEqual([1, 2, 3, 4, 5, 6, 7]);
1094
+ expect(f.sent.map((input) => input.target?.frameId)).toEqual([
1095
+ authority.target.frameId,
1096
+ undefined,
1097
+ undefined,
1098
+ undefined,
1099
+ freshAuthority.target.frameId,
1100
+ undefined,
1101
+ undefined,
1102
+ ]);
1103
+ expect(f.errors).toEqual([]);
1104
+ f.queue.cancel();
1105
+ });
1106
+
1107
+ it("preserves a complete tap while begin waits and only decoded authority is revoked", async () => {
1108
+ const f = fixture(undefined, true);
1109
+ const start: BrowserGestureEvent = {
1110
+ kind: "touch",
1111
+ type: "start",
1112
+ points: [{ ...point(1), id: 0 }],
1113
+ };
1114
+ const end: BrowserGestureEvent = { kind: "touch", type: "end", points: [] };
1115
+ expect(f.queue.enqueue(start)).toBe(true);
1116
+ f.changeAuthority(null);
1117
+ expect(f.queue.enqueue(end)).toBe(true);
1118
+ f.queue.finish();
1119
+ expect(f.sent).toEqual([]);
1120
+ f.beginGate.resolve({ state, gestureId: "pending-tap", nextSequence: 1 });
1121
+ await flush();
1122
+ expect(f.sent.map((input) => input.event)).toEqual([start, end]);
1123
+ expect(f.sent.map((input) => input.sequence)).toEqual([1, 2]);
1124
+ expect(f.ended).toEqual([
1125
+ expect.objectContaining({ gestureId: "pending-tap", sequence: 3, cancel: false }),
1126
+ ]);
1127
+ expect(f.errors).toEqual([]);
1128
+ });
1129
+
1130
+ it("discards pending tap edges after actual control replacement", async () => {
1131
+ const f = fixture(undefined, true);
1132
+ f.queue.enqueue({ kind: "touch", type: "start", points: [{ ...point(1), id: 0 }] });
1133
+ f.changeAuthority(null);
1134
+ f.changeControl({ ...authority, controlToken: "replacement" });
1135
+ expect(f.queue.enqueue({ kind: "touch", type: "end", points: [] })).toBe(false);
1136
+ f.beginGate.resolve({ state, gestureId: "old-pending-tap", nextSequence: 1 });
1137
+ await flush();
1138
+ expect(f.sent).toEqual([]);
1139
+ expect(f.ended).toEqual([
1140
+ expect.objectContaining({ gestureId: "old-pending-tap", sequence: 1, cancel: true }),
1141
+ ]);
1142
+ });
1143
+
1144
+ it("publishes no buffered tap after unknown begin failure and never reuses its basis", async () => {
1145
+ const f = fixture(undefined, true);
1146
+ f.queue.enqueue({ kind: "touch", type: "start", points: [{ ...point(1), id: 0 }] });
1147
+ f.changeAuthority(null);
1148
+ expect(f.queue.enqueue({ kind: "touch", type: "end", points: [] })).toBe(true);
1149
+ f.beginGate.reject(new Error("Unknown admission outcome"));
1150
+ await flush();
1151
+ expect(f.sent).toEqual([]);
1152
+ expect(f.ended).toEqual([]);
1153
+ expect(f.errors).toHaveLength(1);
1154
+ expect(f.queue.enqueue({ kind: "touch", type: "start", points: [{ ...point(2), id: 0 }] })).toBe(
1155
+ false,
1156
+ );
1157
+ });
1158
+
1159
+ it("preserves release during stale-frame waiting until fresh same-context admission", async () => {
1160
+ const frameGate = deferred<void>();
1161
+ let waiting = false;
1162
+ const f = fixture(async () => {
1163
+ waiting = true;
1164
+ await frameGate.promise;
1165
+ }, true);
1166
+ const beginnings: BrowserGestureAuthority[] = [];
1167
+ f.transport.begin = async (input) => {
1168
+ beginnings.push(input);
1169
+ return beginnings.length === 1
1170
+ ? { state, admission: "stale-frame" }
1171
+ : { state, gestureId: "fresh-tap", nextSequence: 1 };
1172
+ };
1173
+ const start: BrowserGestureEvent = {
1174
+ kind: "touch",
1175
+ type: "start",
1176
+ points: [{ ...point(1), id: 0 }],
1177
+ };
1178
+ const end: BrowserGestureEvent = { kind: "touch", type: "end", points: [] };
1179
+ f.queue.enqueue(start);
1180
+ await flush();
1181
+ expect(waiting).toBe(true);
1182
+ f.changeAuthority(null);
1183
+ expect(f.queue.enqueue(end)).toBe(true);
1184
+ f.queue.finish();
1185
+ expect(f.sent).toEqual([]);
1186
+ f.changeAuthority({
1187
+ ...authority,
1188
+ target: { ...authority.target, frameId: "fresh-after-stale" },
1189
+ });
1190
+ frameGate.resolve();
1191
+ await flush();
1192
+ expect(beginnings.map((input) => input.target.frameId)).toEqual([
1193
+ "decoded-front",
1194
+ "fresh-after-stale",
1195
+ ]);
1196
+ expect(f.sent.map((input) => input.event)).toEqual([start, end]);
1197
+ expect(f.ended).toEqual([
1198
+ expect.objectContaining({ gestureId: "fresh-tap", sequence: 3, cancel: false }),
1199
+ ]);
1200
+ expect(f.errors).toEqual([]);
1201
+ });