@omercnet/paseo-shared-browser 1.2.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/OVERVIEW.md +2 -2
  2. package/README.md +316 -27
  3. package/REMOTE_BROWSER_RESEARCH.md +395 -0
  4. package/STREAMING_DESIGN.md +196 -0
  5. package/client/browser-auto-capture-density.test.ts +102 -0
  6. package/client/browser-auto-capture-density.ts +30 -0
  7. package/client/browser-canvas-input.test.ts +198 -0
  8. package/client/browser-canvas-input.ts +202 -0
  9. package/client/browser-canvas-keyboard-web.test.ts +113 -0
  10. package/client/browser-canvas-keyboard-web.ts +230 -0
  11. package/client/browser-canvas-keyboard.test.ts +123 -0
  12. package/client/browser-canvas-keyboard.ts +126 -0
  13. package/client/browser-canvas-layout.test.ts +109 -0
  14. package/client/browser-canvas-layout.ts +51 -0
  15. package/client/browser-canvas-viewport-web.test.ts +77 -0
  16. package/client/browser-canvas-viewport-web.ts +63 -0
  17. package/client/browser-canvas-viewport.tsx +152 -0
  18. package/client/browser-capture-cadence.test.ts +9 -0
  19. package/client/browser-capture-cadence.ts +6 -0
  20. package/client/browser-capture-density-controls.tsx +75 -0
  21. package/client/browser-chrome.tsx +234 -0
  22. package/client/browser-client-presence.ts +184 -0
  23. package/client/browser-compose-controls.test.tsx +275 -0
  24. package/client/browser-compose-controls.tsx +174 -0
  25. package/client/browser-control-button.tsx +109 -0
  26. package/client/browser-control-dialog.tsx +71 -0
  27. package/client/browser-control-request.test.ts +27 -0
  28. package/client/browser-control-request.ts +57 -0
  29. package/client/browser-control-tooltip-web.ts +10 -0
  30. package/client/browser-dialog-body.tsx +22 -0
  31. package/client/browser-emulation-mode.test.ts +75 -0
  32. package/client/browser-emulation-mode.ts +98 -0
  33. package/client/browser-fill-viewport.test.ts +45 -0
  34. package/client/browser-fill-viewport.ts +28 -0
  35. package/client/browser-frame-buffer.test.ts +157 -0
  36. package/client/browser-frame-buffer.ts +148 -0
  37. package/client/browser-frame-image.tsx +62 -0
  38. package/client/browser-image-load.test.ts +23 -0
  39. package/client/browser-image-load.ts +26 -0
  40. package/client/browser-input-queue.test.ts +1254 -0
  41. package/client/browser-input-queue.ts +519 -0
  42. package/client/browser-menu-placement.test.ts +90 -0
  43. package/client/browser-menu-placement.ts +78 -0
  44. package/client/browser-panel-styles.ts +384 -0
  45. package/client/browser-quality-controls.test.ts +26 -0
  46. package/client/browser-quality-controls.tsx +139 -0
  47. package/client/browser-resolution-picker.tsx +189 -0
  48. package/client/browser-tabs.tsx +117 -0
  49. package/client/browser-toolbar-menu-web.test.ts +185 -0
  50. package/client/browser-toolbar-menu-web.ts +159 -0
  51. package/client/browser-toolbar-menu.tsx +255 -0
  52. package/client/browser-toolbar-submenu.tsx +93 -0
  53. package/client/browser-video-bytes.test.ts +44 -0
  54. package/client/browser-video-currency.ts +45 -0
  55. package/client/browser-video-decoder.test.ts +473 -0
  56. package/client/browser-video-decoder.ts +311 -0
  57. package/client/browser-video-surface.tsx +21 -0
  58. package/client/browser-video-visibility.test.ts +45 -0
  59. package/client/browser-viewer-recovery.test.ts +111 -0
  60. package/client/browser-viewer-recovery.ts +78 -0
  61. package/client/browser.tsx +1650 -1579
  62. package/client/favorite-star.tsx +23 -0
  63. package/client/frame-lifecycle.ts +62 -59
  64. package/client/sidebar.tsx +1 -2
  65. package/client/use-browser-auto-capture-density.test.ts +104 -0
  66. package/client/use-browser-auto-capture-density.ts +43 -0
  67. package/client/use-browser-canvas-input.ts +375 -0
  68. package/client/use-browser-capture-density.ts +87 -0
  69. package/client/use-browser-emulation-mode.ts +56 -0
  70. package/client/use-browser-fill-viewport.test.ts +108 -0
  71. package/client/use-browser-fill-viewport.ts +62 -0
  72. package/client/use-browser-frame-buffer.ts +66 -0
  73. package/client/use-browser-image-capture.ts +139 -0
  74. package/client/use-browser-scoped-mutation.test.ts +118 -0
  75. package/client/use-browser-scoped-mutation.ts +59 -0
  76. package/client/use-browser-video.test.ts +1060 -0
  77. package/client/use-browser-video.ts +481 -0
  78. package/client/use-browser-viewer-recovery.ts +26 -0
  79. package/client/use-resolution-favorites.ts +90 -0
  80. package/client/web.test.ts +125 -0
  81. package/client/web.ts +405 -0
  82. package/index.client.tsx +0 -10
  83. package/index.server.ts +32 -4
  84. package/npm-shrinkwrap.json +9 -9
  85. package/package.json +10 -5
  86. package/paseo-plugin.json +1 -1
  87. package/scripts/prepare-runtime.mjs +1 -1
  88. package/server/agent-browser-runtime.ts +1601 -314
  89. package/server/browser-cursor.test.ts +64 -0
  90. package/server/browser-cursor.ts +69 -0
  91. package/server/browser-frame-context.ts +22 -0
  92. package/server/browser-frame-epoch.test.ts +95 -0
  93. package/server/browser-frame-rejection.ts +17 -0
  94. package/server/browser-gesture.ts +120 -0
  95. package/server/browser-operation-diagnostics.test.ts +79 -0
  96. package/server/browser-operation-diagnostics.ts +103 -0
  97. package/server/browser-policy.ts +1316 -151
  98. package/server/browser-video-input-authority.test.ts +211 -0
  99. package/server/browser-video-policy.test.ts +564 -0
  100. package/server/browser-video-socket.test.ts +368 -0
  101. package/server/browser.ts +151 -16
  102. package/server/capture-transport-policy.test.ts +105 -0
  103. package/server/capture-transport-policy.ts +154 -0
  104. package/server/capture-transport-runtime.test.ts +213 -0
  105. package/server/daemon-launch-chain.test.ts +70 -0
  106. package/server/daemon-shutdown.test.ts +289 -0
  107. package/server/discrete-input-freshness.test.ts +364 -0
  108. package/server/discrete-input-native-lifetime.test.ts +150 -0
  109. package/server/display-loss-recovery.test.ts +294 -0
  110. package/server/emulation-restoration.test.ts +262 -0
  111. package/server/input-cleanup.ts +23 -0
  112. package/server/input-generation.test.ts +31 -0
  113. package/server/input-generation.ts +36 -0
  114. package/server/jpeg-capture-demand.test.ts +76 -0
  115. package/server/jpeg-capture-demand.ts +98 -0
  116. package/server/jpeg-frame.ts +29 -0
  117. package/server/keyboard-input.test.ts +61 -0
  118. package/server/keyboard-input.ts +104 -0
  119. package/server/live-input-policy.test.ts +958 -0
  120. package/server/live-input-runtime.test.ts +732 -0
  121. package/server/live-input-supervisor.test.ts +147 -0
  122. package/server/mcp-entry.ts +44 -2
  123. package/server/native-video-backpressure.test.ts +228 -0
  124. package/server/native-video-buffer.test.ts +141 -0
  125. package/server/native-video-buffer.ts +114 -0
  126. package/server/native-video-capture.test.ts +599 -0
  127. package/server/native-video-capture.ts +497 -0
  128. package/server/native-video-extension-source.test.ts +201 -0
  129. package/server/native-video-extension-source.ts +364 -0
  130. package/server/native-video-extension.test.ts +41 -0
  131. package/server/native-video-extension.ts +83 -0
  132. package/server/native-video-packet.ts +9 -0
  133. package/server/native-video-settings.test.ts +245 -0
  134. package/server/navigation-budget.ts +8 -0
  135. package/server/navigation-commit.ts +125 -0
  136. package/server/navigation-policy.test.ts +153 -0
  137. package/server/process-identity.ts +58 -0
  138. package/server/reattach-held-input.test.ts +143 -0
  139. package/server/runtime-frame-reuse.test.ts +295 -0
  140. package/server/runtime-jpeg-demand.test.ts +200 -0
  141. package/server/runtime-jpeg-shutdown.test.ts +210 -0
  142. package/server/runtime-jpeg-source-order.test.ts +201 -0
  143. package/server/runtime-launch-environment.test.ts +111 -0
  144. package/server/runtime-navigation.test.ts +327 -0
  145. package/server/runtime-owner-display.test.ts +152 -0
  146. package/server/runtime-owner.ts +340 -31
  147. package/server/runtime-page-state.test.ts +249 -0
  148. package/server/runtime-protocol.ts +31 -1
  149. package/server/runtime-target-retention.test.ts +181 -0
  150. package/server/runtime-video-opt-in.test.ts +82 -0
  151. package/server/runtime-video.test.ts +444 -0
  152. package/server/screencast-source-time.ts +46 -0
  153. package/server/stale-wheel-admission.test.ts +136 -0
  154. package/server/supervisor-client.ts +28 -4
  155. package/server/supervisor-heartbeat.test.ts +80 -0
  156. package/server/supervisor.ts +553 -55
  157. package/server/tab-close-confirmation.test.ts +97 -0
  158. package/server/video-source-recovery.test.ts +20 -0
  159. package/server/video-source-recovery.ts +64 -0
  160. package/server/viewer-capture-lifetime.ts +51 -0
  161. package/server/virtual-display.test.ts +184 -0
  162. package/server/virtual-display.ts +236 -0
  163. package/shared/browser-display-preferences.ts +46 -0
  164. package/shared/browser-video.ts +83 -0
  165. package/shared/browser.ts +286 -65
  166. package/shared/capture-density.ts +16 -0
  167. package/shared/capture-settings.ts +17 -0
  168. package/shared/device-presets.ts +90 -0
  169. package/shared/resolution-menu.test.ts +120 -0
  170. package/shared/resolution-menu.ts +72 -0
  171. package/shared/video-settings.test.ts +38 -0
  172. package/shared/video-settings.ts +34 -0
  173. package/shared/viewport-limits.ts +5 -0
@@ -0,0 +1,958 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ type BrowserGestureEvent,
4
+ type BrowserInputEvent,
5
+ MAX_HELD_BROWSER_KEYS,
6
+ } from "../shared/browser";
7
+ import { JPEG_QUALITY } from "../shared/capture-settings";
8
+ import { type BrowserRuntimeClient, SessionManager } from "./browser-policy";
9
+
10
+ /** Exercise the real policy with a deterministic owned runtime; no browser or services are started. */
11
+ async function fixture(
12
+ timing: { now?: () => number; viewerTtlMs?: number; controlLeaseMs?: number } = {},
13
+ ) {
14
+ let counter = 0;
15
+ let url = "https://fixture.invalid/";
16
+ let inputGeneration = "0:0";
17
+ let pinnedGeneration: string | null = null;
18
+ let pinnedUrl: string | null = null;
19
+ let intercept: (operation: string) => Promise<void> = async () => {};
20
+ const calls: { operation: string; input: unknown }[] = [];
21
+ const client: BrowserRuntimeClient = {
22
+ connect: async () => ({ epoch: 1 }),
23
+ ensureWorkspace: async (workspaceId) => ({
24
+ workspaceId,
25
+ runtimeId: "r".repeat(32),
26
+ createdAt: 1,
27
+ }),
28
+ requestWorkspace: async (_workspace, operation, input) => {
29
+ calls.push({ operation, input });
30
+ await intercept(operation);
31
+ if (operation === "input.begin") {
32
+ const expectedInputGeneration = (input as { expectedInputGeneration?: string | null })
33
+ .expectedInputGeneration;
34
+ if (expectedInputGeneration != null && expectedInputGeneration !== inputGeneration) {
35
+ throw new Error("Live browser input attachment changed before binding");
36
+ }
37
+ pinnedGeneration = inputGeneration;
38
+ pinnedUrl = url;
39
+ }
40
+ if (
41
+ operation === "input.check" &&
42
+ (pinnedGeneration !== inputGeneration || pinnedUrl !== url)
43
+ )
44
+ throw new Error("Live browser input attachment changed");
45
+ if (operation === "identity") return { userAgent: "Fixture Chromium" };
46
+ if (operation === "state")
47
+ return { url, title: "Fixture", canGoBack: false, canGoForward: false, inputGeneration };
48
+ if (operation === "frame")
49
+ return {
50
+ dataBase64: "eA==",
51
+ byteLength: 1,
52
+ width: 1280,
53
+ height: 800,
54
+ capturedAt: new Date().toISOString(),
55
+ };
56
+ if (operation === "cursor") return "pointer";
57
+ return null;
58
+ },
59
+ archiveWorkspace: async () => {},
60
+ closeWorkspace: async () => {},
61
+ disconnect: () => {},
62
+ };
63
+ const manager = new SessionManager({
64
+ validateWorkspace: async () => true,
65
+ client,
66
+ issueToken: () => (++counter).toString().padStart(32, "0"),
67
+ ...timing,
68
+ });
69
+ await manager.connect();
70
+ const { viewerToken } = await manager.attach("owned-workspace", "Viewer");
71
+ const { controlToken } = await manager.acquireControl(viewerToken);
72
+ const capture = await manager.capture(viewerToken);
73
+ const frame = capture.frame!;
74
+ const expected = {
75
+ sessionId: capture.state.sessionId,
76
+ runtimeId: capture.state.runtimeId!,
77
+ bridgeEpoch: capture.state.bridgeEpoch!,
78
+ navigationGeneration: capture.state.navigationGeneration,
79
+ viewportGeneration: capture.state.viewportGeneration,
80
+ };
81
+ const context = { viewerToken, controlToken, expected };
82
+ const target = {
83
+ frameId: frame.frameId,
84
+ navigationGeneration: frame.navigationGeneration,
85
+ viewportGeneration: frame.viewportGeneration,
86
+ };
87
+ let gestureId = "";
88
+ let sequence = 1;
89
+ return {
90
+ manager,
91
+ calls,
92
+ context,
93
+ target,
94
+ begin: async (pointerKind: "mouse" | "touch" = "mouse") => {
95
+ const reply = await manager.beginGesture({ ...context, target, pointerKind });
96
+ if ("admission" in reply) throw new Error("Fixture expected accepted frame");
97
+ gestureId = reply.gestureId;
98
+ sequence = reply.nextSequence;
99
+ return reply;
100
+ },
101
+ update: async (event: BrowserGestureEvent, withTarget = false) => {
102
+ const result = await manager.updateGesture({
103
+ ...context,
104
+ gestureId,
105
+ sequence,
106
+ event,
107
+ ...(withTarget ? { target } : {}),
108
+ });
109
+ sequence = result.nextSequence;
110
+ return result;
111
+ },
112
+ end: async (cancel = false, overrideSequence?: number) =>
113
+ manager.endGesture({ ...context, gestureId, sequence: overrideSequence ?? sequence, cancel }),
114
+ changeUrl: (value: string) => {
115
+ url = value;
116
+ },
117
+ reloadSameUrl: () => {
118
+ inputGeneration = "0:1";
119
+ },
120
+ replaceTarget: () => {
121
+ inputGeneration = "1:0";
122
+ },
123
+ intercept: (fn: typeof intercept) => {
124
+ intercept = fn;
125
+ },
126
+ };
127
+ }
128
+
129
+ const point = (x = 50, y = 40) => ({ x, y, width: 640, height: 400 });
130
+ afterEach(() => vi.useRealTimers());
131
+
132
+ describe("owned ordered live input", () => {
133
+ it("rejects a thirty-third distinct held key before native publication and cancels the bounded channel", async () => {
134
+ const state = await fixture();
135
+ try {
136
+ await state.begin();
137
+ for (let index = 0; index < MAX_HELD_BROWSER_KEYS; index++) {
138
+ await state.update({
139
+ kind: "key",
140
+ type: "down",
141
+ key: "Unidentified",
142
+ code: `Unknown${index}`,
143
+ modifiers: 0,
144
+ repeat: false,
145
+ });
146
+ }
147
+ await expect(
148
+ state.update({
149
+ kind: "key",
150
+ type: "down",
151
+ key: "Unidentified",
152
+ code: "UnknownExtra",
153
+ modifiers: 0,
154
+ repeat: false,
155
+ }),
156
+ ).rejects.toThrow("Too many simultaneously held");
157
+ expect(state.calls.filter(({ operation }) => operation === "input.key")).toHaveLength(
158
+ MAX_HELD_BROWSER_KEYS,
159
+ );
160
+ expect(state.calls.filter(({ operation }) => operation === "input.end")).toHaveLength(1);
161
+ } finally {
162
+ await state.manager.disconnect();
163
+ }
164
+ });
165
+
166
+ it("releases held input before a mode-only change with unchanged display dimensions", async () => {
167
+ const state = await fixture();
168
+ try {
169
+ await state.begin();
170
+ await state.update({ kind: "down", button: "left", clickCount: 1, point: point() }, true);
171
+ const beforeChange = state.calls.length;
172
+ const changed = await state.manager.applyDevicePreset({
173
+ ...state.context,
174
+ presetId: "pixel-7-sharp",
175
+ preserveDisplay: true,
176
+ });
177
+
178
+ expect(changed.state.viewport).toEqual({ width: 1280, height: 800 });
179
+ expect(changed.state.captureScale).toBe(1);
180
+ // The runtime's input.end owns release of every held mouse/touch/key input.
181
+ const changeCalls = state.calls.slice(beforeChange);
182
+ const released = changeCalls.findIndex(({ operation }) => operation === "input.end");
183
+ const emulated = changeCalls.findIndex(({ operation }) => operation === "emulate");
184
+ expect(released).toBeGreaterThan(-1);
185
+ expect(emulated).toBeGreaterThan(released);
186
+ await expect(state.update({ kind: "move", point: point(90, 60) })).rejects.toThrow();
187
+ } finally {
188
+ await state.manager.disconnect();
189
+ }
190
+ });
191
+
192
+ it("fills the panel while preserving mobile behavior and sharp capture density", async () => {
193
+ const state = await fixture();
194
+ try {
195
+ const mobile = await state.manager.applyDevicePreset({
196
+ ...state.context,
197
+ presetId: "pixel-7-sharp",
198
+ });
199
+ const context = {
200
+ ...state.context,
201
+ expected: {
202
+ ...state.context.expected,
203
+ viewportGeneration: mobile.state.viewportGeneration,
204
+ },
205
+ };
206
+ const viewport = { width: 1000, height: 700 };
207
+ const resized = await state.manager.resize({ ...context, viewport, preserveEmulation: true });
208
+ expect(resized.state.viewport).toEqual(viewport);
209
+ expect(resized.state.devicePresetId).toBe("pixel-7-sharp");
210
+ expect(resized.state.captureScale).toBe(2);
211
+ expect(resized.state.userAgent).toBe(mobile.state.userAgent);
212
+ expect(
213
+ state.calls.filter(({ operation }) => operation === "emulate").at(-1)?.input,
214
+ ).toMatchObject({
215
+ ...viewport,
216
+ captureScale: 2,
217
+ mobile: true,
218
+ touch: true,
219
+ userAgent: mobile.state.userAgent,
220
+ });
221
+
222
+ const resizedContext = {
223
+ ...context,
224
+ expected: { ...context.expected, viewportGeneration: resized.state.viewportGeneration },
225
+ };
226
+ await expect(
227
+ state.manager.resize({
228
+ ...resizedContext,
229
+ viewport: { width: 2000, height: 700 },
230
+ preserveEmulation: true,
231
+ }),
232
+ ).rejects.toThrow("capture density");
233
+ // Automatic following lowers density together with a larger CSS viewport.
234
+ const large = await state.manager.resize({
235
+ ...resizedContext,
236
+ viewport: { width: 2400, height: 2000 },
237
+ preserveEmulation: true,
238
+ captureDensity: 1,
239
+ });
240
+ expect(large.state.viewport).toEqual({ width: 2400, height: 2000 });
241
+ expect(large.state.captureScale).toBe(1);
242
+ expect(large.state.devicePresetId).toBe("pixel-7-sharp");
243
+ expect(large.state.userAgent).toBe(mobile.state.userAgent);
244
+ expect(
245
+ state.calls.filter(({ operation }) => operation === "emulate").at(-1)?.input,
246
+ ).toMatchObject({
247
+ width: 2400,
248
+ height: 2000,
249
+ captureScale: 1,
250
+ mobile: true,
251
+ touch: true,
252
+ });
253
+ const sharp = await state.manager.resize({
254
+ ...resizedContext,
255
+ expected: {
256
+ ...resizedContext.expected,
257
+ viewportGeneration: large.state.viewportGeneration,
258
+ },
259
+ viewport,
260
+ preserveEmulation: true,
261
+ captureDensity: 2,
262
+ });
263
+ expect(sharp.state.captureScale).toBe(2);
264
+ expect(sharp.state.devicePresetId).toBe("pixel-7-sharp");
265
+ // Explicit custom dimensions retain their established desktop reset behavior.
266
+ const custom = await state.manager.resize({
267
+ ...resizedContext,
268
+ expected: {
269
+ ...resizedContext.expected,
270
+ viewportGeneration: sharp.state.viewportGeneration,
271
+ },
272
+ viewport,
273
+ });
274
+ expect(custom.state.devicePresetId).toBeNull();
275
+ expect(custom.state.captureScale).toBe(1);
276
+ expect(
277
+ state.calls.filter(({ operation }) => operation === "emulate").at(-1)?.input,
278
+ ).toMatchObject({
279
+ mobile: false,
280
+ touch: false,
281
+ captureScale: 1,
282
+ });
283
+ } finally {
284
+ await state.manager.disconnect();
285
+ }
286
+ });
287
+
288
+ it("returns fresh state and closes the channel after an acknowledged mouse release navigates", async () => {
289
+ const state = await fixture();
290
+ try {
291
+ await state.begin();
292
+ await state.update({ kind: "down", button: "left", clickCount: 1, point: point() }, true);
293
+ state.intercept(async (operation) => {
294
+ if (operation === "mouse.up") state.changeUrl("https://fixture.invalid/after");
295
+ });
296
+ const reply = await state.update({
297
+ kind: "up",
298
+ button: "left",
299
+ clickCount: 1,
300
+ point: point(),
301
+ });
302
+ expect(reply.state.url).toBe("https://fixture.invalid/after");
303
+ expect(reply.state.navigationGeneration).toBe(
304
+ state.context.expected.navigationGeneration + 1,
305
+ );
306
+ expect(reply.cursor).toBeNull();
307
+ expect(reply.completion).toBe("navigation");
308
+ expect(state.calls.filter((call) => call.operation === "mouse.up")).toHaveLength(1);
309
+ await expect(state.update({ kind: "move", point: point() })).rejects.toThrow("unavailable");
310
+ } finally {
311
+ state.manager.disconnect();
312
+ }
313
+ });
314
+
315
+ it("acknowledges a form-submit key before same-URL document replacement without replay", async () => {
316
+ const state = await fixture();
317
+ try {
318
+ await state.begin();
319
+ state.intercept(async (operation) => {
320
+ if (operation === "input.key") state.reloadSameUrl();
321
+ });
322
+ const reply = await state.update({
323
+ kind: "key",
324
+ type: "down",
325
+ key: "Enter",
326
+ code: "Enter",
327
+ modifiers: 0,
328
+ repeat: false,
329
+ });
330
+ expect(reply.completion).toBe("navigation");
331
+ expect(reply.state.navigationGeneration).toBe(
332
+ state.context.expected.navigationGeneration + 1,
333
+ );
334
+ expect(state.calls.filter((call) => call.operation === "input.key")).toHaveLength(1);
335
+ expect(state.calls.filter((call) => call.operation === "input.end")).toHaveLength(1);
336
+ await expect(
337
+ state.update({
338
+ kind: "key",
339
+ type: "up",
340
+ key: "Enter",
341
+ code: "Enter",
342
+ modifiers: 0,
343
+ repeat: false,
344
+ }),
345
+ ).rejects.toThrow("unavailable");
346
+ } finally {
347
+ state.manager.disconnect();
348
+ }
349
+ });
350
+
351
+ it("keeps pre-publication document drift and uncertain publication failures rejected", async () => {
352
+ for (const scenario of ["before", "uncertain"] as const) {
353
+ const state = await fixture();
354
+ try {
355
+ await state.begin();
356
+ if (scenario === "before") state.reloadSameUrl();
357
+ else
358
+ state.intercept(async (operation) => {
359
+ if (operation !== "mouse.down") return;
360
+ state.changeUrl("https://fixture.invalid/after");
361
+ throw new Error("Mutation outcome is unknown");
362
+ });
363
+ await expect(
364
+ state.update({ kind: "down", button: "left", clickCount: 1, point: point() }, true),
365
+ ).rejects.toThrow(scenario === "before" ? "attachment" : "unknown");
366
+ expect(state.calls.filter((call) => call.operation === "mouse.down")).toHaveLength(
367
+ scenario === "before" ? 0 : 1,
368
+ );
369
+ } finally {
370
+ state.manager.disconnect();
371
+ }
372
+ }
373
+ });
374
+
375
+ it("does not treat a bridge fence after publication as document-only completion", async () => {
376
+ const state = await fixture();
377
+ try {
378
+ await state.begin();
379
+ state.intercept(async (operation) => {
380
+ if (operation !== "mouse.down") return;
381
+ state.changeUrl("https://fixture.invalid/after");
382
+ state.manager.setBridgeEpoch(2);
383
+ });
384
+ await expect(
385
+ state.update({ kind: "down", button: "left", clickCount: 1, point: point() }, true),
386
+ ).rejects.toThrow("bridge");
387
+ expect(state.calls.filter((call) => call.operation === "mouse.down")).toHaveLength(1);
388
+ } finally {
389
+ state.manager.disconnect();
390
+ }
391
+ });
392
+
393
+ it("refuses target replacement after a successful native input acknowledgment", async () => {
394
+ const state = await fixture();
395
+ try {
396
+ await state.begin();
397
+ let published = false;
398
+ state.intercept(async (operation) => {
399
+ if (operation === "mouse.down") published = true;
400
+ if (operation === "input.check" && published) state.replaceTarget();
401
+ });
402
+ await expect(
403
+ state.update({ kind: "down", button: "left", clickCount: 1, point: point() }, true),
404
+ ).rejects.toThrow("attachment");
405
+ expect(state.calls.filter((call) => call.operation === "mouse.down")).toHaveLength(1);
406
+ expect(state.calls.filter((call) => call.operation === "input.end")).toHaveLength(1);
407
+ } finally {
408
+ state.manager.disconnect();
409
+ }
410
+ });
411
+
412
+ it("retains a bounded decoded frame after hover, then streams every pressed drag move without requiring new frames", async () => {
413
+ const state = await fixture();
414
+ await state.begin();
415
+ expect((await state.update({ kind: "move", point: point() })).cursor).toBe("pointer");
416
+ await state.update({ kind: "down", button: "left", clickCount: 1, point: point() }, true);
417
+ await state.update({ kind: "move", point: point(70) });
418
+ await state.update({ kind: "move", point: point(90) });
419
+ await state.update({ kind: "up", button: "left", clickCount: 1, point: point(90) });
420
+ await state.end();
421
+ expect(
422
+ state.calls
423
+ .filter((call) => call.operation === "mouse.move")
424
+ .map((call) => (call.input as { x: number }).x),
425
+ ).toEqual([100, 140, 180]);
426
+ expect(state.calls.filter((call) => call.operation === "input.end")).toHaveLength(1);
427
+ state.manager.disconnect();
428
+ });
429
+
430
+ it("returns known non-admission for revoked frame before channel publication or old-channel cancellation", async () => {
431
+ const state = await fixture();
432
+ await state.begin();
433
+ await state.update({ kind: "scroll", point: point(), deltaX: 0, deltaY: 40 });
434
+ const before = state.calls.length;
435
+ const reply = await state.manager.beginGesture({
436
+ ...state.context,
437
+ target: { ...state.target, frameId: "z".repeat(32) },
438
+ pointerKind: "mouse",
439
+ });
440
+ expect(reply).toHaveProperty("admission", "stale-frame");
441
+ expect(reply).not.toHaveProperty("gestureId");
442
+ expect(state.calls.slice(before).map((call) => call.operation)).not.toContain("input.begin");
443
+ expect(state.calls.slice(before).map((call) => call.operation)).not.toContain("input.end");
444
+ await state.update({ kind: "scroll", point: point(), deltaX: 0, deltaY: 40 });
445
+ await expect(
446
+ state.manager.beginGesture({
447
+ ...state.context,
448
+ controlToken: "wrong",
449
+ target: state.target,
450
+ pointerKind: "mouse",
451
+ }),
452
+ ).rejects.toThrow();
453
+ state.manager.disconnect();
454
+ });
455
+
456
+ it("does not return stale admission if its final metadata read fails or discovers replacement navigation", async () => {
457
+ for (const failure of ["runtime", "navigation"] as const) {
458
+ const state = await fixture();
459
+ try {
460
+ await state.begin();
461
+ await state.update({ kind: "scroll", point: point(), deltaX: 0, deltaY: 40 });
462
+ let reads = 0;
463
+ state.intercept(async (operation) => {
464
+ if (operation !== "state" || ++reads !== 2) return;
465
+ if (failure === "runtime") throw new Error("Runtime read unavailable");
466
+ state.changeUrl("https://replacement.invalid/");
467
+ });
468
+ const before = state.calls.length;
469
+ await expect(
470
+ state.manager.beginGesture({
471
+ ...state.context,
472
+ target: { ...state.target, frameId: "z".repeat(32) },
473
+ pointerKind: "mouse",
474
+ }),
475
+ ).rejects.toThrow(failure === "runtime" ? "Runtime read unavailable" : "navigation");
476
+ expect(state.calls.slice(before).map((call) => call.operation)).not.toContain(
477
+ "input.begin",
478
+ );
479
+ } finally {
480
+ state.manager.disconnect();
481
+ }
482
+ }
483
+ });
484
+
485
+ it("leaves native hover through a coordinate-free owned event while retaining frame admission", async () => {
486
+ const state = await fixture();
487
+ await state.begin();
488
+ await state.update({ kind: "move", point: point() });
489
+ expect((await state.update({ kind: "leave" })).cursor).toBeNull();
490
+ expect(state.calls.find((call) => call.operation === "mouse.leave")?.input).not.toHaveProperty(
491
+ "x",
492
+ );
493
+ await state.update({ kind: "down", button: "left", clickCount: 1, point: point() }, true);
494
+ await expect(state.update({ kind: "leave" })).rejects.toThrow("Release held");
495
+ expect(state.calls.filter((call) => call.operation === "mouse.leave")).toHaveLength(1);
496
+ state.manager.disconnect();
497
+ });
498
+
499
+ it("every independent press needs a fresh decoded receipt; the receipt a press used is spent", async () => {
500
+ const state = await fixture();
501
+ try {
502
+ await state.begin();
503
+ await expect(
504
+ state.update({ kind: "down", button: "left", clickCount: 1, point: point() }),
505
+ ).rejects.toThrow("current frame");
506
+ expect(state.calls.some((call) => call.operation === "mouse.down")).toBe(false);
507
+ await state.begin();
508
+ await state.update({ kind: "down", button: "left", clickCount: 1, point: point() }, true);
509
+ await state.update({ kind: "up", button: "left", clickCount: 1, point: point() });
510
+ // Held release reuses its channel, but the same visible receipt cannot press again.
511
+ await expect(
512
+ state.update({ kind: "down", button: "left", clickCount: 1, point: point() }, true),
513
+ ).rejects.toThrow("frame is stale");
514
+ expect(state.calls.filter((call) => call.operation === "mouse.down")).toHaveLength(1);
515
+ // A discrete agent press stays strict as well.
516
+ await expect(
517
+ state.manager.sendInput({
518
+ ...state.context,
519
+ target: state.target,
520
+ event: { kind: "click", point: point(), button: "left", clickCount: 1 },
521
+ }),
522
+ ).rejects.toThrow("frame is stale");
523
+ expect(state.calls.filter((call) => call.operation === "mouse.down")).toHaveLength(1);
524
+ } finally {
525
+ await state.manager.disconnect();
526
+ }
527
+ });
528
+
529
+ it("a stalled presentation cannot admit a press after its receipt expires, however long control is held", async () => {
530
+ vi.useFakeTimers();
531
+ const state = await fixture();
532
+ try {
533
+ await state.begin();
534
+ // Hover keeps the channel and lease alive without revoking the receipt.
535
+ for (let elapsed = 0; elapsed < 6_000; elapsed += 2_000) {
536
+ await vi.advanceTimersByTimeAsync(2_000);
537
+ await state.update({ kind: "move", point: point() });
538
+ }
539
+ await expect(
540
+ state.update({ kind: "down", button: "left", clickCount: 1, point: point() }, true),
541
+ ).rejects.toThrow("frame is stale");
542
+ expect(state.calls.some((call) => call.operation === "mouse.down")).toBe(false);
543
+ } finally {
544
+ await state.manager.disconnect();
545
+ }
546
+ });
547
+
548
+ it("an expired or spent receipt cannot reopen a channel, with or without prior admission", async () => {
549
+ vi.useFakeTimers();
550
+ const state = await fixture();
551
+ try {
552
+ await state.begin();
553
+ await state.update({ kind: "down", button: "left", clickCount: 1, point: point() }, true);
554
+ await state.update({ kind: "up", button: "left", clickCount: 1, point: point() });
555
+ await state.end();
556
+ const beforeReopen = state.calls.length;
557
+ const spent = await state.manager.beginGesture({
558
+ ...state.context,
559
+ target: state.target,
560
+ pointerKind: "mouse",
561
+ });
562
+ expect(spent).toHaveProperty("admission", "stale-frame");
563
+ expect(state.calls.slice(beforeReopen).some((call) => call.operation === "input.begin")).toBe(
564
+ false,
565
+ );
566
+ const fresh = await state.manager.capture(state.context.viewerToken, "low");
567
+ const freshTarget = {
568
+ frameId: fresh.frame!.frameId,
569
+ navigationGeneration: fresh.frame!.navigationGeneration,
570
+ viewportGeneration: fresh.frame!.viewportGeneration,
571
+ };
572
+ await vi.advanceTimersByTimeAsync(5_001);
573
+ const expired = await state.manager.beginGesture({
574
+ ...state.context,
575
+ target: freshTarget,
576
+ pointerKind: "mouse",
577
+ });
578
+ expect(expired).toHaveProperty("admission", "stale-frame");
579
+ expect(state.calls.some((call) => call.operation === "mouse.down")).toBe(true);
580
+ expect(state.calls.filter((call) => call.operation === "mouse.down")).toHaveLength(1);
581
+ } finally {
582
+ await state.manager.disconnect();
583
+ }
584
+ });
585
+
586
+ it("maps native touch coordinates, preserves stable identifiers and accepts partial release via the active set", async () => {
587
+ const state = await fixture();
588
+ await state.begin("touch");
589
+ await state.update(
590
+ {
591
+ kind: "touch",
592
+ type: "start",
593
+ points: [
594
+ { ...point(), id: 31 },
595
+ { ...point(80), id: 99 },
596
+ ],
597
+ },
598
+ true,
599
+ );
600
+ await state.update({ kind: "touch", type: "move", points: [{ ...point(60), id: 31 }] });
601
+ await state.update({ kind: "touch", type: "end", points: [] });
602
+ await state.end();
603
+ const touches = state.calls
604
+ .filter((call) => call.operation === "touch")
605
+ .map((call) => call.input as { points: unknown[] });
606
+ expect(touches[0]!.points).toEqual([
607
+ { x: 100, y: 80, id: 31 },
608
+ { x: 160, y: 80, id: 99 },
609
+ ]);
610
+ expect(touches[1]!.points).toEqual([{ x: 120, y: 80, id: 31 }]);
611
+ expect(touches[2]!.points).toEqual([]);
612
+ state.manager.disconnect();
613
+ });
614
+
615
+ it("accepts a staggered second finger within one held gesture, but refuses a new independent start from an old frame", async () => {
616
+ const state = await fixture();
617
+ await state.begin("touch");
618
+ await state.update({ kind: "touch", type: "start", points: [{ ...point(), id: 1 }] }, true);
619
+ await state.update({
620
+ kind: "touch",
621
+ type: "start",
622
+ points: [
623
+ { ...point(), id: 1 },
624
+ { ...point(80), id: 2 },
625
+ ],
626
+ });
627
+ await state.update({ kind: "touch", type: "end", points: [] });
628
+ await expect(
629
+ state.update({ kind: "touch", type: "start", points: [{ ...point(), id: 3 }] }, true),
630
+ ).rejects.toThrow("frame");
631
+ expect(state.calls.filter((call) => call.operation === "touch")).toHaveLength(3);
632
+ state.manager.disconnect();
633
+ });
634
+
635
+ it("never replays an uncertain press and revokes its earlier discrete observation", async () => {
636
+ const state = await fixture();
637
+ try {
638
+ await state.begin();
639
+ state.intercept(async (operation) => {
640
+ if (operation === "mouse.down") throw new Error("Unknown publication outcome");
641
+ });
642
+ await expect(
643
+ state.update({ kind: "down", button: "left", clickCount: 1, point: point() }, true),
644
+ ).rejects.toThrow("Unknown publication");
645
+ await expect(
646
+ state.update({ kind: "down", button: "left", clickCount: 1, point: point() }, true),
647
+ ).rejects.toThrow("unavailable");
648
+ expect(state.calls.filter((call) => call.operation === "mouse.down")).toHaveLength(1);
649
+ expect(state.calls.filter((call) => call.operation === "input.end")).toHaveLength(1);
650
+ state.intercept(async () => {});
651
+ await expect(
652
+ state.manager.sendInput({
653
+ ...state.context,
654
+ target: state.target,
655
+ event: { kind: "click", point: point(), button: "left", clickCount: 1 },
656
+ }),
657
+ ).rejects.toThrow("frame is stale");
658
+ expect(state.calls.filter((call) => call.operation === "mouse.down")).toHaveLength(1);
659
+ } finally {
660
+ await state.manager.disconnect();
661
+ }
662
+ });
663
+
664
+ it("rejects document replacement between policy preflight and native binding without pressing the replacement", async () => {
665
+ const state = await fixture();
666
+ try {
667
+ state.intercept(async (operation) => {
668
+ if (operation === "input.begin") state.reloadSameUrl();
669
+ });
670
+ await expect(state.begin()).rejects.toThrow("attachment changed before binding");
671
+ expect(state.calls.some((call) => call.operation === "mouse.down")).toBe(false);
672
+ const beginCall = state.calls.find((call) => call.operation === "input.begin");
673
+ expect(beginCall?.input).toMatchObject({ expectedInputGeneration: "0:0" });
674
+ } finally {
675
+ await state.manager.disconnect();
676
+ }
677
+ });
678
+
679
+ it("discrete input refuses a document that navigated after capture before any native primitive", async () => {
680
+ const events: BrowserInputEvent[] = [
681
+ { kind: "type", text: "secret" },
682
+ { kind: "key", key: "Enter" },
683
+ { kind: "move", point: point() },
684
+ { kind: "scroll", point: point(), deltaX: 0, deltaY: 40 },
685
+ { kind: "click", point: point(), button: "left", clickCount: 1 },
686
+ ];
687
+ for (const event of events) {
688
+ const state = await fixture();
689
+ try {
690
+ state.changeUrl("https://fixture.invalid/autonomous-navigation");
691
+ state.reloadSameUrl();
692
+ const before = state.calls.length;
693
+ await expect(
694
+ state.manager.sendInput({ ...state.context, target: state.target, event }),
695
+ ).rejects.toThrow("stale");
696
+ expect(
697
+ state.calls
698
+ .slice(before)
699
+ .filter((call) => /^(text|key|mouse|input\.begin)/.test(call.operation)),
700
+ ).toEqual([]);
701
+ } finally {
702
+ await state.manager.disconnect();
703
+ }
704
+ }
705
+ });
706
+
707
+ it("pins the reconciled document at native begin, so a replacement racing the check publishes nothing", async () => {
708
+ const state = await fixture();
709
+ try {
710
+ state.intercept(async (operation) => {
711
+ if (operation === "input.begin") state.reloadSameUrl();
712
+ });
713
+ const before = state.calls.length;
714
+ await expect(
715
+ state.manager.sendInput({
716
+ ...state.context,
717
+ target: state.target,
718
+ event: { kind: "type", text: "secret" },
719
+ }),
720
+ ).rejects.toThrow("attachment changed before binding");
721
+ const calls = state.calls.slice(before);
722
+ expect(calls.find((call) => call.operation === "input.begin")?.input).toMatchObject({
723
+ expectedInputGeneration: "0:0",
724
+ });
725
+ expect(calls.some((call) => call.operation === "text.insert")).toBe(false);
726
+ expect(calls.some((call) => call.operation === "input.end")).toBe(true);
727
+ } finally {
728
+ await state.manager.disconnect();
729
+ }
730
+ });
731
+
732
+ it("spends the capture when admitted discrete input fails partway, so it cannot be reused", async () => {
733
+ const state = await fixture();
734
+ try {
735
+ state.intercept(async (operation) => {
736
+ if (operation === "mouse.up") throw new Error("Runtime failure after press");
737
+ });
738
+ await expect(
739
+ state.manager.sendInput({
740
+ ...state.context,
741
+ target: state.target,
742
+ event: { kind: "click", point: point(), button: "left", clickCount: 1 },
743
+ }),
744
+ ).rejects.toThrow("Runtime failure");
745
+ state.intercept(async () => {});
746
+ await expect(
747
+ state.manager.sendInput({
748
+ ...state.context,
749
+ target: state.target,
750
+ event: { kind: "type", text: "again" },
751
+ }),
752
+ ).rejects.toThrow("frame is stale");
753
+ expect(state.calls.filter((call) => call.operation === "text.insert")).toEqual([]);
754
+ } finally {
755
+ await state.manager.disconnect();
756
+ }
757
+ });
758
+
759
+ it("rejects dropped sequence/layout and never replays an uncertain published packet", async () => {
760
+ const state = await fixture();
761
+ const begin = await state.begin();
762
+ await expect(
763
+ state.manager.updateGesture({
764
+ ...state.context,
765
+ gestureId: begin.gestureId,
766
+ sequence: 2,
767
+ event: { kind: "move", point: point() },
768
+ }),
769
+ ).rejects.toThrow("sequence");
770
+ expect(state.calls.some((call) => call.operation === "mouse.move")).toBe(false);
771
+ await state.begin();
772
+ await state.update({ kind: "move", point: point() });
773
+ await expect(state.update({ kind: "move", point: { ...point(), width: 600 } })).rejects.toThrow(
774
+ "geometry",
775
+ );
776
+ await state.begin();
777
+ state.intercept(async (op) => {
778
+ if (op === "mouse.down") throw new Error("Unknown publication outcome");
779
+ });
780
+ await expect(
781
+ state.update({ kind: "down", button: "left", clickCount: 1, point: point() }, true),
782
+ ).rejects.toThrow("Unknown publication");
783
+ expect(state.calls.filter((call) => call.operation === "mouse.down")).toHaveLength(1);
784
+ expect(state.calls.filter((call) => call.operation === "input.end")).toHaveLength(3);
785
+ state.manager.disconnect();
786
+ });
787
+
788
+ it("same-URL reload, navigation and bridge epoch changes refuse and release the old channel", async () => {
789
+ for (const change of ["reload", "url", "bridge"] as const) {
790
+ const state = await fixture();
791
+ await state.begin();
792
+ if (change === "reload") state.reloadSameUrl();
793
+ if (change === "url") state.changeUrl("https://fixture.invalid/replacement");
794
+ if (change === "bridge") state.manager.setBridgeEpoch(2);
795
+ await expect(state.update({ kind: "move", point: point() })).rejects.toThrow();
796
+ expect(state.calls.filter((call) => call.operation === "input.end")).toHaveLength(1);
797
+ expect(state.calls.some((call) => call.operation === "mouse.move")).toBe(false);
798
+ state.manager.disconnect();
799
+ }
800
+ });
801
+
802
+ it("closes the old channel without denying an acknowledged navigation-causing press", async () => {
803
+ const state = await fixture();
804
+ await state.begin();
805
+ state.intercept(async (op) => {
806
+ if (op === "mouse.down") state.reloadSameUrl();
807
+ });
808
+ const reply = await state.update(
809
+ { kind: "down", button: "left", clickCount: 1, point: point() },
810
+ true,
811
+ );
812
+ expect(reply.state.navigationGeneration).toBe(state.context.expected.navigationGeneration + 1);
813
+ await expect(state.update({ kind: "move", point: point() })).rejects.toThrow("unavailable");
814
+ expect(state.calls.filter((call) => call.operation === "input.end")).toHaveLength(1);
815
+ state.manager.disconnect();
816
+ });
817
+
818
+ it("idle timeout fires without another request and takeover/detach release immediately", async () => {
819
+ vi.useFakeTimers();
820
+ const timed = await fixture();
821
+ await timed.begin();
822
+ await timed.update({ kind: "down", button: "left", clickCount: 1, point: point() }, true);
823
+ await vi.advanceTimersByTimeAsync(5_001);
824
+ expect(timed.calls.filter((call) => call.operation === "input.end")).toHaveLength(1);
825
+ timed.manager.disconnect();
826
+ for (const operation of ["takeover", "detach", "release"] as const) {
827
+ const state = await fixture();
828
+ await state.begin();
829
+ if (operation === "takeover") {
830
+ const other = await state.manager.attach("owned-workspace", "Other");
831
+ await state.manager.acquireControl(other.viewerToken, true);
832
+ } else if (operation === "detach") await state.manager.detach(state.context.viewerToken);
833
+ else
834
+ await state.manager.releaseControl(state.context.viewerToken, state.context.controlToken);
835
+ expect(state.calls.filter((call) => call.operation === "input.end")).toHaveLength(1);
836
+ state.manager.disconnect();
837
+ }
838
+ });
839
+
840
+ it("viewer or lease expiry releases the held gesture and rejects its late events", async () => {
841
+ for (const expiry of ["lease", "viewer"] as const) {
842
+ let now = 1_000;
843
+ const state = await fixture({
844
+ now: () => now,
845
+ ...(expiry === "lease" ? { controlLeaseMs: 1_000 } : { viewerTtlMs: 1_000 }),
846
+ });
847
+ await state.begin();
848
+ await state.update({ kind: "down", button: "left", clickCount: 1, point: point() }, true);
849
+ now += 1_001;
850
+ // Another viewer's request is what observes the expiry; no request of the holder is needed.
851
+ await state.manager.attach("owned-workspace", "Other");
852
+ await vi.waitFor(() =>
853
+ expect(state.calls.filter((call) => call.operation === "input.end")).toHaveLength(1),
854
+ );
855
+ const before = state.calls.length;
856
+ await expect(state.update({ kind: "move", point: point(60, 50) })).rejects.toThrow();
857
+ expect(
858
+ state.calls.slice(before).filter((call) => call.operation.startsWith("mouse.")),
859
+ ).toEqual([]);
860
+ state.manager.disconnect();
861
+ }
862
+ });
863
+
864
+ it("cursor metadata failure is optional while input/context failures remain visible", async () => {
865
+ const state = await fixture();
866
+ await state.begin();
867
+ state.intercept(async (op) => {
868
+ if (op === "cursor") throw new Error("Unsupported hit test");
869
+ });
870
+ expect((await state.update({ kind: "move", point: point() })).cursor).toBeNull();
871
+ await state.end();
872
+ state.manager.disconnect();
873
+ });
874
+ });
875
+
876
+ it("streams keyboard edges and committed text without a fresh frame per keystroke", async () => {
877
+ const state = await fixture();
878
+ await state.begin();
879
+ const key = {
880
+ kind: "key",
881
+ type: "down",
882
+ key: "a",
883
+ code: "KeyA",
884
+ modifiers: 0,
885
+ repeat: false,
886
+ } as const;
887
+ await state.update(key);
888
+ await state.update({ ...key, repeat: true });
889
+ await state.update({ ...key, type: "up" });
890
+ await state.update({ kind: "text", text: "漢字\n😀" });
891
+ await state.end();
892
+ expect(state.calls.filter((call) => call.operation === "input.key")).toHaveLength(3);
893
+ expect(state.calls.filter((call) => call.operation === "input.text")).toEqual([
894
+ { operation: "input.text", input: expect.objectContaining({ text: "漢字\n😀" }) },
895
+ ]);
896
+ });
897
+
898
+ it("cancels a held gesture on device mode changes without reloading or inventing navigation", async () => {
899
+ const state = await fixture();
900
+ await state.begin();
901
+ await state.update({
902
+ kind: "key",
903
+ type: "down",
904
+ key: "Control",
905
+ code: "ControlLeft",
906
+ modifiers: 2,
907
+ repeat: false,
908
+ });
909
+ const reply = await state.manager.applyDevicePreset({ ...state.context, presetId: "pixel-7" });
910
+ expect(reply.state.navigationGeneration).toBe(state.context.expected.navigationGeneration);
911
+ expect(reply.state.viewportGeneration).toBe(state.context.expected.viewportGeneration + 1);
912
+ expect(state.calls.filter((call) => call.operation === "input.end")).toHaveLength(1);
913
+ expect(
914
+ state.calls.filter((call) => call.operation === "reload" || call.operation === "navigate"),
915
+ ).toEqual([]);
916
+ expect(state.calls.filter((call) => call.operation === "emulate").at(-1)?.input).toMatchObject({
917
+ mobile: true,
918
+ touch: true,
919
+ });
920
+ });
921
+
922
+ it("threads actual click modifier snapshots through the owned gesture without key synthesis", async () => {
923
+ const f = await fixture();
924
+ try {
925
+ await f.begin();
926
+ await f.update(
927
+ { kind: "down", point: point(), button: "left", clickCount: 1, modifiers: 10 },
928
+ true,
929
+ );
930
+ await f.update({ kind: "up", point: point(), button: "left", clickCount: 1, modifiers: 0 });
931
+ const pointer = f.calls.filter(
932
+ (call) => call.operation === "mouse.down" || call.operation === "mouse.up",
933
+ );
934
+ expect(pointer.map((call) => call.input)).toEqual([
935
+ expect.objectContaining({ modifiers: 10 }),
936
+ expect.objectContaining({ modifiers: 0 }),
937
+ ]);
938
+ expect(f.calls.some((call) => call.operation === "input.key")).toBe(false);
939
+ await f.end();
940
+ } finally {
941
+ f.manager.disconnect();
942
+ }
943
+ });
944
+
945
+ it("requests JPEG100 and JPEG85 independently from the default-quality stream", async () => {
946
+ const f = await fixture();
947
+ try {
948
+ await f.manager.capture(f.context.viewerToken, "maximum");
949
+ await f.manager.capture(f.context.viewerToken, "high");
950
+ expect(f.calls.filter((call) => call.operation === "frame").map((call) => call.input)).toEqual([
951
+ expect.objectContaining({ quality: JPEG_QUALITY.medium }),
952
+ expect.objectContaining({ quality: JPEG_QUALITY.maximum }),
953
+ expect.objectContaining({ quality: JPEG_QUALITY.high }),
954
+ ]);
955
+ } finally {
956
+ f.manager.disconnect();
957
+ }
958
+ });