@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,732 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { MAX_HELD_BROWSER_KEYS } from "../shared/browser";
3
+ import { AgentBrowserRuntime } from "./agent-browser-runtime";
4
+ import { CdpUnknownOutcomeError } from "./cdp";
5
+ import { createRuntimeOwner } from "./runtime-owner";
6
+
7
+ /** Exercise native publication/cleanup without starting Chromium or touching any profile. */
8
+ function fixture() {
9
+ const runtime = new AgentBrowserRuntime({
10
+ binaryPath: "/tmp/unlaunched-browser",
11
+ executablePath: "/tmp/unlaunched-chromium",
12
+ profilePath: "/tmp/uncreated-profile",
13
+ ipcDirectory: "/tmp/uncreated-ipc",
14
+ session: "test",
15
+ });
16
+ const calls: {
17
+ method: string;
18
+ params: Record<string, unknown>;
19
+ options: { timeoutMs?: number } | undefined;
20
+ }[] = [];
21
+ let intercept: (method: string) => Promise<void> = async () => {};
22
+ const page = {
23
+ send: async (
24
+ method: string,
25
+ params: Record<string, unknown> = {},
26
+ options?: { timeoutMs?: number },
27
+ ) => {
28
+ calls.push({ method, params, options });
29
+ await intercept(method);
30
+ return method === "Runtime.evaluate" ? { result: { value: "pointer" } } : {};
31
+ },
32
+ };
33
+ const control = runtime as unknown as {
34
+ page: typeof page;
35
+ requirePage: () => Promise<typeof page>;
36
+ documentGeneration: number;
37
+ attachmentGeneration: number;
38
+ };
39
+ Object.assign(runtime, {
40
+ page,
41
+ emulationAppliedPage: page,
42
+ viewport: { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false, touch: false },
43
+ });
44
+ control.requirePage = async () => control.page;
45
+ return {
46
+ runtime,
47
+ control,
48
+ page,
49
+ calls,
50
+ begin: (id: string) =>
51
+ runtime.beginLiveInput(id, `${control.attachmentGeneration}:${control.documentGeneration}`),
52
+ intercept: (next: typeof intercept) => {
53
+ intercept = next;
54
+ },
55
+ };
56
+ }
57
+
58
+ afterEach(() => {
59
+ vi.useRealTimers();
60
+ vi.restoreAllMocks();
61
+ });
62
+ describe("runtime-owned live input", () => {
63
+ it("releases a discrete unknown-down only on its captured original page", async () => {
64
+ const state = fixture();
65
+ await state.begin("internal-discrete");
66
+ const unknown = new CdpUnknownOutcomeError("Down acknowledgement lost");
67
+ state.intercept(async (method) => {
68
+ if (method === "Input.dispatchKeyEvent") throw unknown;
69
+ });
70
+ await expect(state.runtime.keyDown("Control", "Control", "internal-discrete")).rejects.toBe(
71
+ unknown,
72
+ );
73
+ state.intercept(async () => {});
74
+ const replacementCalls: string[] = [];
75
+ state.control.page = {
76
+ ...state.page,
77
+ send: async (method: string) => {
78
+ replacementCalls.push(method);
79
+ return {};
80
+ },
81
+ };
82
+ state.control.attachmentGeneration++;
83
+ await state.runtime.endLiveInput("internal-discrete");
84
+ expect(
85
+ state.calls
86
+ .filter((entry) => entry.method === "Input.dispatchKeyEvent")
87
+ .map((entry) => entry.params.type),
88
+ ).toEqual(["keyDown", "keyUp"]);
89
+ expect(replacementCalls).toEqual([]);
90
+ });
91
+
92
+ for (const counter of ["documentGeneration", "attachmentGeneration"] as const) {
93
+ it(`refuses ${counter} drift during page attachment before admitting input`, async () => {
94
+ const state = fixture();
95
+ const expected = `${state.control.attachmentGeneration}:${state.control.documentGeneration}`;
96
+ state.control.requirePage = async () => {
97
+ state.control[counter]++;
98
+ return state.page;
99
+ };
100
+
101
+ await expect(state.runtime.beginLiveInput("new", expected)).rejects.toThrow(
102
+ "before admission",
103
+ );
104
+ await expect(state.runtime.mouseDown(20, 30, "left", 1, "new")).rejects.toThrow("attachment");
105
+ expect(state.calls).toEqual([]);
106
+ });
107
+
108
+ it(`refuses ${counter} drift while releasing a previous held input channel`, async () => {
109
+ const state = fixture();
110
+ await state.begin("old");
111
+ await state.runtime.mouseDown(20, 30, "left", 1, "old");
112
+ const expected = `${state.control.attachmentGeneration}:${state.control.documentGeneration}`;
113
+ state.intercept(async (method) => {
114
+ if (method === "Input.dispatchMouseEvent") state.control[counter]++;
115
+ });
116
+
117
+ await expect(state.runtime.beginLiveInput("new", expected)).rejects.toThrow(
118
+ "before admission",
119
+ );
120
+ await expect(state.runtime.mouseDown(20, 30, "left", 1, "new")).rejects.toThrow("attachment");
121
+ expect(state.calls.filter((call) => call.params.type === "mousePressed")).toHaveLength(1);
122
+ expect(state.calls.filter((call) => call.params.type === "mouseReleased")).toHaveLength(1);
123
+ });
124
+ }
125
+
126
+ it("releases a cancelled held drag at its last native pointer position, not the origin", async () => {
127
+ const state = fixture();
128
+ await state.begin("owned");
129
+ await state.runtime.mouseDown(10, 20, "left", 1, "owned");
130
+ await state.runtime.mouseMove(300, 410, "owned");
131
+ await state.runtime.endLiveInput("owned");
132
+ const released = state.calls.filter((call) => call.params.type === "mouseReleased");
133
+ expect(released).toHaveLength(1);
134
+ expect(released[0]?.params).toMatchObject({ x: 300, y: 410, button: "left", buttons: 0 });
135
+ });
136
+
137
+ it("releases a held button where an unacknowledged press was sent", async () => {
138
+ const state = fixture();
139
+ await state.begin("owned");
140
+ state.intercept(async (method) => {
141
+ if (method === "Input.dispatchMouseEvent") throw new Error("Unknown publication outcome");
142
+ });
143
+ await expect(state.runtime.mouseDown(55, 66, "left", 1, "owned")).rejects.toThrow();
144
+ state.intercept(async () => {});
145
+ await state.runtime.endLiveInput("owned");
146
+ const released = state.calls.filter((call) => call.params.type === "mouseReleased");
147
+ expect(released[0]?.params).toMatchObject({ x: 55, y: 66 });
148
+ });
149
+
150
+ it("bounds optional cursor sampling without cancelling a held mouse or replaying its press", async () => {
151
+ const state = fixture();
152
+ await state.begin("owned");
153
+ await state.runtime.mouseDown(20, 30, "left", 1, "owned");
154
+ state.intercept(async (method) => {
155
+ if (method === "Runtime.evaluate") throw new Error("Runtime.evaluate timed out");
156
+ });
157
+
158
+ expect(await state.runtime.cursorAt(20, 30, "owned")).toBeNull();
159
+ expect(state.calls.find((call) => call.method === "Runtime.evaluate")?.options?.timeoutMs).toBe(
160
+ 250,
161
+ );
162
+ await state.runtime.mouseUp(20, 30, "left", 1, "owned");
163
+ expect(state.calls.filter((call) => call.params.type === "mousePressed")).toHaveLength(1);
164
+ expect(state.calls.filter((call) => call.params.type === "mouseReleased")).toHaveLength(1);
165
+ await state.runtime.endLiveInput("owned");
166
+ });
167
+
168
+ it("completes acknowledged input that navigates while closing original held-input cleanup", async () => {
169
+ const state = fixture();
170
+ await state.begin("owned");
171
+ state.intercept(async (method) => {
172
+ if (method !== "Input.dispatchKeyEvent" || state.calls.at(-1)?.params.type !== "keyDown")
173
+ return;
174
+ state.control.documentGeneration++;
175
+ await state.runtime.endLiveInput("owned");
176
+ });
177
+ await expect(
178
+ state.runtime.dispatchKey(
179
+ { kind: "key", type: "down", key: "Enter", code: "Enter", modifiers: 0, repeat: false },
180
+ "owned",
181
+ ),
182
+ ).resolves.toBeUndefined();
183
+ expect(state.calls.filter((call) => call.params.type === "keyDown")).toHaveLength(1);
184
+ expect(state.calls.filter((call) => call.params.type === "keyUp")).toHaveLength(1);
185
+ await expect(state.runtime.assertLiveInput("owned")).rejects.toThrow("attachment");
186
+ });
187
+
188
+ it("does not complete target replacement or an unacknowledged send as successful navigation", async () => {
189
+ for (const scenario of ["replacement", "uncertain"] as const) {
190
+ const state = fixture();
191
+ await state.begin("owned");
192
+ state.intercept(async (method) => {
193
+ if (method !== "Input.dispatchMouseEvent") return;
194
+ state.control.documentGeneration++;
195
+ if (scenario === "uncertain") throw new Error("Mutation outcome is unknown");
196
+ state.control.page = { send: async () => ({}) };
197
+ });
198
+ await expect(state.runtime.mouseDown(20, 30, "left", 1, "owned")).rejects.toThrow(
199
+ scenario === "replacement" ? "attachment" : "unknown",
200
+ );
201
+ state.intercept(async () => {});
202
+ await state.runtime.endLiveInput("owned");
203
+ }
204
+ });
205
+
206
+ it("does not restore held touches after an acknowledged touch start navigates", async () => {
207
+ const state = fixture();
208
+ await state.begin("owned");
209
+ state.intercept(async (method) => {
210
+ if (method !== "Input.dispatchTouchEvent" || state.calls.at(-1)?.params.type !== "touchStart")
211
+ return;
212
+ state.control.documentGeneration++;
213
+ await state.runtime.endLiveInput("owned");
214
+ });
215
+ await state.runtime.touch("touchStart", [{ x: 20, y: 30, id: 7 }], "owned");
216
+ await state.runtime.releaseHeldInput();
217
+ expect(state.calls.filter((call) => call.params.type === "touchCancel")).toHaveLength(1);
218
+ await expect(state.runtime.assertLiveInput("owned")).rejects.toThrow("attachment");
219
+ });
220
+
221
+ it("releases original held mouse after plugin/IPC disappearance without another policy request", async () => {
222
+ vi.useFakeTimers();
223
+ const state = fixture();
224
+ await state.begin("owned");
225
+ await state.runtime.mouseDown(10, 20, "left", 1, "owned");
226
+ await vi.advanceTimersByTimeAsync(4_000);
227
+ await state.runtime.assertLiveInput("owned");
228
+ await state.runtime.cursorAt(10, 20, "owned");
229
+ await vi.advanceTimersByTimeAsync(1_001);
230
+ expect(state.calls.filter((call) => call.params.type === "mouseReleased")).toHaveLength(1);
231
+ await expect(state.runtime.mouseMove(20, 30, "owned")).rejects.toThrow("attachment");
232
+ });
233
+
234
+ it.each(["move", "up"] as const)(
235
+ "an expired %s rejected before publication does not change the release point",
236
+ async (operation) => {
237
+ vi.useFakeTimers();
238
+ const state = fixture();
239
+ await state.begin("owned");
240
+ await state.runtime.mouseDown(10, 20, "left", 1, "owned");
241
+ // Move the clock without firing the timer so assertLiveInput owns the expiry.
242
+ vi.setSystemTime(Date.now() + 5_001);
243
+ const rejected =
244
+ operation === "move"
245
+ ? state.runtime.mouseMove(500, 600, "owned")
246
+ : state.runtime.mouseUp(500, 600, "left", 1, "owned");
247
+ await expect(rejected).rejects.toThrow("attachment");
248
+ expect(state.calls.filter((call) => call.params.type === "mouseReleased")).toMatchObject([
249
+ { params: { x: 10, y: 20 } },
250
+ ]);
251
+ },
252
+ );
253
+
254
+ it("a move rejected after replacement keeps the original attachment's last published point", async () => {
255
+ const state = fixture();
256
+ const replacement: unknown[] = [];
257
+ await state.begin("owned");
258
+ await state.runtime.mouseDown(10, 20, "left", 1, "owned");
259
+ await state.runtime.mouseMove(30, 40, "owned");
260
+ state.control.page = { send: async (...args: unknown[]) => replacement.push(args) } as never;
261
+ await expect(state.runtime.mouseMove(500, 600, "owned")).rejects.toThrow("attachment");
262
+ await state.runtime.endLiveInput("owned");
263
+ expect(replacement).toEqual([]);
264
+ expect(state.calls.filter((call) => call.params.type === "mouseReleased")).toMatchObject([
265
+ { params: { x: 30, y: 40 } },
266
+ ]);
267
+ });
268
+
269
+ it("discrete text and key input pinned to a channel never reach a navigated document", async () => {
270
+ const state = fixture();
271
+ await state.begin("discrete");
272
+ state.control.documentGeneration++;
273
+ await expect(state.runtime.insertText("late", "discrete")).rejects.toThrow("attachment");
274
+ await expect(state.runtime.keyDown("Enter", "Enter", "discrete")).rejects.toThrow("attachment");
275
+ expect(
276
+ state.calls.filter((call) => /^Input\.(insertText|dispatchKeyEvent)$/.test(call.method)),
277
+ ).toEqual([]);
278
+ });
279
+
280
+ it("acknowledged moves renew idle expiry but never the absolute five-minute limit", async () => {
281
+ vi.useFakeTimers();
282
+ const state = fixture();
283
+ await state.begin("owned");
284
+ await state.runtime.mouseDown(10, 20, "left", 1, "owned");
285
+ for (let index = 0; index < 74; index++) {
286
+ await vi.advanceTimersByTimeAsync(4_000);
287
+ await state.runtime.mouseMove(20, 30, "owned");
288
+ }
289
+ await vi.advanceTimersByTimeAsync(4_001);
290
+ expect(state.calls.filter((call) => call.params.type === "mouseReleased")).toHaveLength(1);
291
+ });
292
+
293
+ it("cancels possibly published touch after lost acknowledgment and keeps original attachment on replacement", async () => {
294
+ const state = fixture();
295
+ await state.begin("owned");
296
+ state.intercept(async (method) => {
297
+ if (method === "Input.dispatchTouchEvent") throw new Error("Unknown publication outcome");
298
+ });
299
+ await expect(
300
+ state.runtime.touch("touchStart", [{ x: 20, y: 30, id: 7 }], "owned"),
301
+ ).rejects.toThrow("Unknown publication");
302
+ const replacementCalls: string[] = [];
303
+ state.control.page = {
304
+ send: async (method) => {
305
+ replacementCalls.push(method);
306
+ return {};
307
+ },
308
+ };
309
+ await state.runtime.endLiveInput("owned");
310
+ expect(state.calls.filter((call) => call.params.type === "touchCancel")).toHaveLength(1);
311
+ expect(replacementCalls).toEqual([]);
312
+ });
313
+
314
+ it("ends removed touch contacts before moving survivors and cancels remaining held contacts", async () => {
315
+ const state = fixture();
316
+ await state.begin("owned");
317
+ await state.runtime.touch(
318
+ "touchStart",
319
+ [
320
+ { x: 20, y: 30, id: 7 },
321
+ { x: 60, y: 30, id: 9 },
322
+ ],
323
+ "owned",
324
+ );
325
+ await state.runtime.touch("touchMove", [{ x: 25, y: 35, id: 7 }], "owned");
326
+ expect(
327
+ state.calls
328
+ .filter((call) => call.method === "Input.dispatchTouchEvent")
329
+ .map((call) => call.params),
330
+ ).toEqual([
331
+ {
332
+ type: "touchStart",
333
+ touchPoints: [
334
+ { x: 20, y: 30, id: 7 },
335
+ { x: 60, y: 30, id: 9 },
336
+ ],
337
+ },
338
+ { type: "touchEnd", touchPoints: [{ x: 60, y: 30, id: 9 }] },
339
+ { type: "touchMove", touchPoints: [{ x: 25, y: 35, id: 7 }] },
340
+ ]);
341
+ await state.runtime.endLiveInput("owned");
342
+ expect(state.calls.filter((call) => call.params.type === "touchCancel")).toHaveLength(1);
343
+ });
344
+
345
+ it("same-URL document replacement refuses publication and optional cursor rejects CSS URL values", async () => {
346
+ const state = fixture();
347
+ await state.begin("owned");
348
+ state.control.documentGeneration++;
349
+ await expect(state.runtime.mouseMove(20, 30, "owned")).rejects.toThrow("attachment");
350
+ expect(state.calls).toEqual([]);
351
+ await state.runtime.endLiveInput("owned");
352
+ await state.begin("next");
353
+ state.control.page.send = async () => ({
354
+ result: { value: "url(https://secret.invalid/cursor), pointer" },
355
+ });
356
+ expect(await state.runtime.cursorAt(20, 30, "next")).toBeNull();
357
+ await state.runtime.endLiveInput("next");
358
+ });
359
+
360
+ it("native leave uses only the fixed outside point and refuses departure while a button is held", async () => {
361
+ const state = fixture();
362
+ await state.begin("owned");
363
+ await state.runtime.mouseLeave("owned");
364
+ expect(state.calls[0]?.params).toEqual({
365
+ type: "mouseMoved",
366
+ x: -1,
367
+ y: -1,
368
+ button: "none",
369
+ buttons: 0,
370
+ });
371
+ await state.runtime.mouseDown(20, 30, "left", 1, "owned");
372
+ await expect(state.runtime.mouseLeave("owned")).rejects.toThrow("Release held");
373
+ await state.runtime.endLiveInput("owned");
374
+ await expect(state.runtime.mouseLeave("owned")).rejects.toThrow("attachment");
375
+ });
376
+
377
+ it("private runtime routing preserves gesture identity and refuses malformed native contact sets", async () => {
378
+ const owner = await createRuntimeOwner();
379
+ const runtime = {
380
+ beginLiveInput: vi.fn(),
381
+ assertLiveInput: vi.fn(),
382
+ endLiveInput: vi.fn(),
383
+ touch: vi.fn(),
384
+ mouseMove: vi.fn(),
385
+ dispatchKey: vi.fn(),
386
+ insertText: vi.fn(),
387
+ };
388
+ const owned = { runtimeId: "owned", runtime } as unknown as Parameters<typeof owner.request>[0];
389
+ await owner.request(owned, "input.begin", {
390
+ gestureId: "gesture",
391
+ expectedInputGeneration: "0:0",
392
+ });
393
+ await owner.request(owned, "mouse.move", { gestureId: "gesture", x: 20, y: 30 });
394
+ await owner.request(owned, "touch", {
395
+ gestureId: "gesture",
396
+ type: "start",
397
+ points: [{ id: 7, x: 20, y: 30 }],
398
+ });
399
+ expect(runtime.beginLiveInput).toHaveBeenCalledWith("gesture", "0:0");
400
+ await expect(owner.request(owned, "input.begin", { gestureId: "gesture" })).rejects.toThrow(
401
+ "generation is required",
402
+ );
403
+ expect(runtime.mouseMove).toHaveBeenCalledWith(20, 30, "gesture");
404
+ expect(runtime.touch).toHaveBeenCalledWith("touchStart", [{ id: 7, x: 20, y: 30 }], "gesture");
405
+ for (const points of [
406
+ [
407
+ { id: 7, x: 20, y: 30 },
408
+ { id: 7, x: 30, y: 40 },
409
+ ],
410
+ [],
411
+ [{ id: true, x: 20, y: 30 }],
412
+ ]) {
413
+ await expect(
414
+ owner.request(owned, "touch", { gestureId: "gesture", type: "start", points }),
415
+ ).rejects.toThrow();
416
+ }
417
+ expect(runtime.touch).toHaveBeenCalledTimes(1);
418
+ const event = {
419
+ kind: "key",
420
+ type: "down",
421
+ key: "Enter",
422
+ code: "Enter",
423
+ modifiers: 0,
424
+ repeat: false,
425
+ };
426
+ await owner.request(owned, "input.key", { gestureId: "gesture", event });
427
+ await owner.request(owned, "input.text", { gestureId: "gesture", text: "漢字😀" });
428
+ expect(runtime.dispatchKey).toHaveBeenCalledWith(event, "gesture");
429
+ expect(runtime.insertText).toHaveBeenCalledWith("漢字😀", "gesture");
430
+ await expect(owner.request(owned, "input.key", { event })).rejects.toThrow();
431
+ await expect(
432
+ owner.request(owned, "input.key", {
433
+ gestureId: "gesture",
434
+ event: { ...event, modifiers: 99 },
435
+ }),
436
+ ).rejects.toThrow();
437
+ await expect(
438
+ owner.request(owned, "input.text", { gestureId: "gesture", text: "x".repeat(16001) }),
439
+ ).rejects.toThrow();
440
+ expect(runtime.dispatchKey).toHaveBeenCalledTimes(1);
441
+ expect(runtime.insertText).toHaveBeenCalledTimes(1);
442
+ });
443
+
444
+ it("unpressed movement retains the ordinary no-button packet", async () => {
445
+ const state = fixture();
446
+ await state.begin("owned");
447
+ await state.runtime.mouseMove(20, 30, "owned");
448
+ expect(state.calls[0]?.params).toEqual({
449
+ type: "mouseMoved",
450
+ x: 20,
451
+ y: 30,
452
+ button: "none",
453
+ buttons: 0,
454
+ });
455
+ await state.runtime.endLiveInput("owned");
456
+ });
457
+
458
+ for (const [button, mask] of [
459
+ ["left", 1],
460
+ ["right", 2],
461
+ ["middle", 4],
462
+ ] as const) {
463
+ it(`held ${button} movement carries its native button and mask until release`, async () => {
464
+ const state = fixture();
465
+ await state.begin("owned");
466
+ await state.runtime.mouseDown(10, 20, button, 1, "owned");
467
+ await state.runtime.mouseMove(20, 30, "owned");
468
+ await state.runtime.mouseUp(20, 30, button, 1, "owned");
469
+ await state.runtime.mouseMove(30, 40, "owned");
470
+ expect(
471
+ state.calls.filter((call) => call.params.type === "mouseMoved").map((call) => call.params),
472
+ ).toEqual([
473
+ { type: "mouseMoved", x: 20, y: 30, button, buttons: mask },
474
+ { type: "mouseMoved", x: 30, y: 40, button: "none", buttons: 0 },
475
+ ]);
476
+ await state.runtime.endLiveInput("owned");
477
+ });
478
+ }
479
+
480
+ it("multiple held buttons use canonical priority, and cleanup leaves the next gesture unpressed", async () => {
481
+ const state = fixture();
482
+ await state.begin("owned");
483
+ await state.runtime.mouseDown(10, 20, "middle", 1, "owned");
484
+ await state.runtime.mouseDown(10, 20, "right", 1, "owned");
485
+ await state.runtime.mouseMove(20, 30, "owned");
486
+ await state.runtime.mouseDown(10, 20, "left", 1, "owned");
487
+ await state.runtime.mouseMove(20, 30, "owned");
488
+ await state.runtime.mouseUp(10, 20, "left", 1, "owned");
489
+ await state.runtime.mouseMove(20, 30, "owned");
490
+ await state.runtime.mouseUp(10, 20, "right", 1, "owned");
491
+ await state.runtime.mouseMove(20, 30, "owned");
492
+ await state.runtime.endLiveInput("owned");
493
+ await state.begin("next");
494
+ await state.runtime.mouseMove(20, 30, "next");
495
+ expect(
496
+ state.calls
497
+ .filter((call) => call.params.type === "mouseMoved")
498
+ .map((call) => [call.params.button, call.params.buttons]),
499
+ ).toEqual([
500
+ ["right", 6],
501
+ ["left", 7],
502
+ ["right", 6],
503
+ ["middle", 4],
504
+ ["none", 0],
505
+ ]);
506
+ expect(
507
+ state.calls.filter((call) => call.params.type === "mouseReleased").at(-1)?.params.button,
508
+ ).toBe("middle");
509
+ await state.runtime.endLiveInput("next");
510
+ });
511
+ });
512
+
513
+ it("publishes ordered native Unicode/repeat/shortcut keys and committed text on the pinned attachment", async () => {
514
+ const state = fixture();
515
+ await state.begin("keys");
516
+ await state.runtime.dispatchKey(
517
+ { kind: "key", type: "down", key: "é", code: "KeyE", modifiers: 0, repeat: false },
518
+ "keys",
519
+ );
520
+ await state.runtime.dispatchKey(
521
+ { kind: "key", type: "down", key: "é", code: "KeyE", modifiers: 0, repeat: true },
522
+ "keys",
523
+ );
524
+ await state.runtime.dispatchKey(
525
+ { kind: "key", type: "up", key: "é", code: "KeyE", modifiers: 0, repeat: false },
526
+ "keys",
527
+ );
528
+ await state.runtime.dispatchKey(
529
+ { kind: "key", type: "down", key: "Control", code: "ControlLeft", modifiers: 2, repeat: false },
530
+ "keys",
531
+ );
532
+ await state.runtime.mouseDown(10, 20, "left", 1, "keys");
533
+ await state.runtime.insertText("漢字\n😀", "keys");
534
+ expect(
535
+ state.calls
536
+ .filter((call) => call.method === "Input.dispatchKeyEvent")
537
+ .map((call) => call.params),
538
+ ).toMatchObject([
539
+ { type: "keyDown", text: "é", code: "KeyE", autoRepeat: false },
540
+ { type: "keyDown", text: "é", autoRepeat: true },
541
+ { type: "keyUp", code: "KeyE" },
542
+ { type: "rawKeyDown", code: "ControlLeft", modifiers: 2 },
543
+ ]);
544
+ expect(state.calls.find((call) => call.params.type === "mousePressed")?.params.modifiers).toBe(2);
545
+ expect(state.calls.find((call) => call.method === "Input.insertText")?.params).toEqual({
546
+ text: "漢字\n😀",
547
+ });
548
+ await state.runtime.endLiveInput("keys");
549
+ expect(state.calls.at(-1)?.params).toMatchObject({
550
+ type: "keyUp",
551
+ key: "Control",
552
+ code: "ControlLeft",
553
+ });
554
+ });
555
+
556
+ it("cleans possibly published keys on the original page and rejects unmatched repeat/up", async () => {
557
+ const state = fixture();
558
+ await state.begin("keys");
559
+ const down = {
560
+ kind: "key",
561
+ type: "down",
562
+ key: "Shift",
563
+ code: "ShiftRight",
564
+ modifiers: 8,
565
+ repeat: false,
566
+ } as const;
567
+ await expect(state.runtime.dispatchKey({ ...down, repeat: true }, "keys")).rejects.toThrow();
568
+ await expect(state.runtime.dispatchKey({ ...down, type: "up" }, "keys")).rejects.toThrow();
569
+ state.intercept(async (method) => {
570
+ if (method === "Input.dispatchKeyEvent") throw new Error("Lost key acknowledgment");
571
+ });
572
+ await expect(state.runtime.dispatchKey(down, "keys")).rejects.toThrow("Lost key");
573
+ state.control.page = { send: async () => ({}) };
574
+ await state.runtime.endLiveInput("keys");
575
+ expect(state.calls.filter((call) => call.params.type === "keyUp")).toHaveLength(1);
576
+ });
577
+
578
+ it("expires held keyboard modifiers without another client request", async () => {
579
+ vi.useFakeTimers();
580
+ const state = fixture();
581
+ await state.begin("keys");
582
+ await state.runtime.dispatchKey(
583
+ { kind: "key", type: "down", key: "Alt", code: "AltLeft", modifiers: 1, repeat: false },
584
+ "keys",
585
+ );
586
+ await vi.advanceTimersByTimeAsync(5001);
587
+ expect(state.calls.filter((call) => call.params.type === "keyUp")).toHaveLength(1);
588
+ await expect(state.runtime.insertText("late", "keys")).rejects.toThrow("attachment");
589
+ });
590
+
591
+ it("forwards first-click physical modifiers on all mouse packets without fabricated key presses", async () => {
592
+ const state = fixture();
593
+ await state.begin("mouse");
594
+ await state.runtime.mouseDown(10, 20, "left", 1, "mouse", 2);
595
+ await state.runtime.mouseMove(20, 30, "mouse", 10);
596
+ await state.runtime.wheel(20, 30, 0, 40, "mouse", 1);
597
+ await state.runtime.mouseUp(20, 30, "left", 1, "mouse", 0);
598
+ expect(
599
+ state.calls
600
+ .filter((call) => call.method === "Input.dispatchMouseEvent")
601
+ .map((call) => call.params.modifiers),
602
+ ).toEqual([2, 10, 1, 0]);
603
+ expect(state.calls.some((call) => call.method === "Input.dispatchKeyEvent")).toBe(false);
604
+ await state.runtime.endLiveInput("mouse");
605
+ });
606
+
607
+ it("explicit zero overrides held keyboard inference while omitted mouse masks retain it", async () => {
608
+ const state = fixture();
609
+ await state.begin("mouse");
610
+ await state.runtime.dispatchKey(
611
+ { kind: "key", type: "down", key: "Control", code: "ControlLeft", modifiers: 2, repeat: false },
612
+ "mouse",
613
+ );
614
+ await state.runtime.mouseMove(20, 30, "mouse", 0);
615
+ await state.runtime.mouseMove(20, 30, "mouse");
616
+ expect(
617
+ state.calls
618
+ .filter((call) => call.method === "Input.dispatchMouseEvent")
619
+ .map((call) => call.params.modifiers),
620
+ ).toEqual([0, 2]);
621
+ await state.runtime.endLiveInput("mouse");
622
+ });
623
+
624
+ it("refuses invalid mouse masks before publication or held-button bookkeeping", async () => {
625
+ const state = fixture();
626
+ await state.begin("mouse");
627
+ for (const modifiers of [-1, 16, 1.5, Number.NaN]) {
628
+ await expect(state.runtime.mouseDown(10, 20, "left", 1, "mouse", modifiers)).rejects.toThrow(
629
+ "Mouse modifiers",
630
+ );
631
+ await expect(state.runtime.mouseMove(10, 20, "mouse", modifiers)).rejects.toThrow(
632
+ "Mouse modifiers",
633
+ );
634
+ await expect(state.runtime.wheel(10, 20, 0, 40, "mouse", modifiers)).rejects.toThrow(
635
+ "Mouse modifiers",
636
+ );
637
+ await expect(state.runtime.mouseUp(10, 20, "left", 1, "mouse", modifiers)).rejects.toThrow(
638
+ "Mouse modifiers",
639
+ );
640
+ }
641
+ await state.runtime.endLiveInput("mouse");
642
+ expect(state.calls).toEqual([]);
643
+ });
644
+
645
+ it("runtime-owner validates physical modifiers and preserves omitted legacy arguments", async () => {
646
+ const owner = await createRuntimeOwner();
647
+ const runtime = { mouseDown: vi.fn(), mouseMove: vi.fn(), mouseUp: vi.fn(), wheel: vi.fn() };
648
+ const owned = { runtimeId: "owned", runtime } as unknown as Parameters<typeof owner.request>[0];
649
+ const point = {
650
+ x: 10,
651
+ y: 20,
652
+ gestureId: "mouse",
653
+ button: "left",
654
+ clickCount: 1,
655
+ deltaX: 0,
656
+ deltaY: 40,
657
+ };
658
+ await owner.request(owned, "mouse.down", { ...point, modifiers: 2 });
659
+ await owner.request(owned, "mouse.move", { ...point, modifiers: 0 });
660
+ await owner.request(owned, "mouse.up", { ...point, modifiers: 8 });
661
+ await owner.request(owned, "mouse.wheel", { ...point, modifiers: 4 });
662
+ expect(runtime.mouseDown).toHaveBeenCalledWith(10, 20, "left", 1, "mouse", 2);
663
+ expect(runtime.mouseMove).toHaveBeenCalledWith(10, 20, "mouse", 0);
664
+ expect(runtime.mouseUp).toHaveBeenCalledWith(10, 20, "left", 1, "mouse", 8);
665
+ expect(runtime.wheel).toHaveBeenCalledWith(10, 20, 0, 40, "mouse", 4);
666
+ for (const modifiers of [null, "2", 16, -1, 1.5]) {
667
+ await expect(owner.request(owned, "mouse.down", { ...point, modifiers })).rejects.toThrow();
668
+ }
669
+ expect(runtime.mouseDown).toHaveBeenCalledTimes(1);
670
+ await owner.request(owned, "mouse.move", point);
671
+ expect(runtime.mouseMove).toHaveBeenLastCalledWith(10, 20, "mouse");
672
+ });
673
+
674
+ it("bounds live held keys before native publication and still permits repeat, release and cancel", async () => {
675
+ const state = fixture();
676
+ await state.begin("keys");
677
+ const key = (code: string, type: "down" | "up" = "down", repeat = false) => ({
678
+ kind: "key" as const,
679
+ type,
680
+ key: "Unidentified",
681
+ code,
682
+ modifiers: 0,
683
+ repeat,
684
+ });
685
+ for (let index = 0; index < MAX_HELD_BROWSER_KEYS; index++) {
686
+ await state.runtime.dispatchKey(key(`Unknown${index}`), "keys");
687
+ }
688
+ await expect(state.runtime.dispatchKey(key("UnknownExtra"), "keys")).rejects.toThrow(
689
+ "Too many simultaneously held",
690
+ );
691
+ expect(state.calls.filter((call) => call.params.type === "rawKeyDown")).toHaveLength(
692
+ MAX_HELD_BROWSER_KEYS,
693
+ );
694
+ await state.runtime.dispatchKey(key("Unknown0", "down", true), "keys");
695
+ await state.runtime.dispatchKey(key("Unknown0", "up"), "keys");
696
+ await state.runtime.dispatchKey(key("UnknownReplacement"), "keys");
697
+ await state.runtime.endLiveInput("keys");
698
+ const releases = state.calls.filter((call) => call.params.type === "keyUp");
699
+ expect(releases).toHaveLength(MAX_HELD_BROWSER_KEYS + 1);
700
+ expect(releases.some((call) => call.params.code === "UnknownExtra")).toBe(false);
701
+ });
702
+
703
+ it("applies the same held-key bound to discrete native key-down commands", async () => {
704
+ const state = fixture();
705
+ for (let index = 0; index < MAX_HELD_BROWSER_KEYS; index++) {
706
+ await state.runtime.keyDown("Unidentified", `Unknown${index}`);
707
+ }
708
+ await expect(state.runtime.keyDown("Unidentified", "UnknownExtra")).rejects.toThrow(
709
+ "Too many simultaneously held",
710
+ );
711
+ await state.runtime.keyUp("Unidentified", "Unknown0");
712
+ await state.runtime.keyDown("Unidentified", "UnknownReplacement");
713
+ expect(state.calls.some((call) => call.params.code === "UnknownExtra")).toBe(false);
714
+ });
715
+
716
+ it("runtime-owner validates and forwards encoder controls without changing legacy omission", async () => {
717
+ const owner = await createRuntimeOwner();
718
+ const runtime = {
719
+ readVideo: vi.fn(async (_input: unknown) => ({ status: "waiting", packets: [] })),
720
+ };
721
+ const owned = { runtimeId: "owned", runtime } as unknown as Parameters<typeof owner.request>[0];
722
+ await owner.request(owned, "video.read", { bitrate: 24_000_000, fps: 60 });
723
+ expect(runtime.readVideo).toHaveBeenLastCalledWith(
724
+ expect.objectContaining({ bitrate: 24_000_000, fps: 60 }),
725
+ );
726
+ await owner.request(owned, "video.read", {});
727
+ expect(runtime.readVideo.mock.calls[1]?.[0]).not.toHaveProperty("bitrate");
728
+ expect(runtime.readVideo.mock.calls[1]?.[0]).not.toHaveProperty("fps");
729
+ await expect(owner.request(owned, "video.read", { bitrate: 1 })).rejects.toThrow();
730
+ await expect(owner.request(owned, "video.read", { fps: 120 })).rejects.toThrow();
731
+ expect(runtime.readVideo).toHaveBeenCalledTimes(2);
732
+ });