@omercnet/paseo-shared-browser 1.2.2 → 2.0.0-next.249.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 (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 +32 -2
  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,105 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { RuntimeFrame } from "./agent-browser-runtime";
3
+ import { createCaptureTransportPolicy } from "./capture-transport-policy";
4
+
5
+ const frame: RuntimeFrame = {
6
+ dataBase64: "Zml4dHVyZQ==",
7
+ byteLength: 7,
8
+ width: 1280,
9
+ height: 800,
10
+ transport: "screenshot",
11
+ capturedAt: "2026-10-02T12:00:00.000Z",
12
+ };
13
+
14
+ describe("capture transport policy", () => {
15
+ it("keeps the true screenshot and original capture time only through1000ms at exact quality/budget", () => {
16
+ const policy = createCaptureTransportPolicy();
17
+ const token = policy.beginScreenshot();
18
+ expect(policy.rememberScreenshot(token, frame, 100, 100, 1000)).toBe(true);
19
+ policy.endScreenshot(token, 100);
20
+ expect(policy.readScreenshot(1100, 100, 1000)).toBe(frame);
21
+ expect(policy.readScreenshot(1101, 100, 1000)).toBeNull();
22
+ expect(policy.readScreenshot(500, 90, 1000)).toBeNull();
23
+ expect(policy.readScreenshot(500, 100, 1001)).toBeNull();
24
+ });
25
+
26
+ it("revokes old asynchronous completions on input/session invalidation", () => {
27
+ const policy = createCaptureTransportPolicy();
28
+ const old = policy.beginScreenshot();
29
+ policy.invalidate();
30
+ const fresh = policy.beginScreenshot();
31
+ expect(policy.rememberScreenshot(old, frame, 100, 100, 1000)).toBe(false);
32
+ policy.endScreenshot(old, 100);
33
+ expect(policy.readScreenshot(100, 100, 1000)).toBeNull();
34
+ expect(policy.rememberScreenshot(fresh, frame, 200, 100, 1000)).toBe(true);
35
+ policy.invalidate();
36
+ expect(policy.readScreenshot(200, 100, 1000)).toBeNull();
37
+ });
38
+
39
+ it("cannot recover from screenshot-induced frames or repeated sparse capture cycles", () => {
40
+ const policy = createCaptureTransportPolicy();
41
+ policy.enterFallback(0);
42
+ for (let time = 0; time < 6000; time += 1000) {
43
+ const token = policy.beginScreenshot();
44
+ policy.observeStream(`inside-${time}`, time + 10);
45
+ policy.endScreenshot(token, time + 20);
46
+ policy.observeStream(`queued-${time}`, time + 100);
47
+ expect(policy.canUseStream(time + 500)).toBe(false);
48
+ }
49
+ });
50
+
51
+ it("requires3 distinct events over500ms with bounded gaps and retains the original dwell", () => {
52
+ const policy = createCaptureTransportPolicy();
53
+ policy.enterFallback(0);
54
+ policy.observeStream("one", 1100);
55
+ policy.enterFallback(1200);
56
+ policy.observeStream("two", 1350);
57
+ expect(policy.canUseStream(1500)).toBe(false);
58
+ policy.observeStream("three", 1600);
59
+ expect(policy.canUseStream(1600)).toBe(true);
60
+ });
61
+
62
+ it("rejects duplicate events, burst-only evidence and stale recovery", () => {
63
+ const duplicate = createCaptureTransportPolicy();
64
+ duplicate.enterFallback(0);
65
+ duplicate.observeStream("same", 1000);
66
+ duplicate.observeStream("same", 1250);
67
+ duplicate.observeStream("same", 1500);
68
+ expect(duplicate.canUseStream(1500)).toBe(false);
69
+
70
+ const burst = createCaptureTransportPolicy();
71
+ burst.enterFallback(0);
72
+ burst.observeStream("one", 1400);
73
+ burst.observeStream("two", 1450);
74
+ burst.observeStream("three", 1500);
75
+ expect(burst.canUseStream(1500)).toBe(false);
76
+ expect(burst.canUseStream(2100)).toBe(false);
77
+ });
78
+
79
+ it("a long inter-event gap resets evidence and invalidation clears all recovery", () => {
80
+ const policy = createCaptureTransportPolicy();
81
+ policy.enterFallback(0);
82
+ policy.observeStream("one", 1000);
83
+ policy.observeStream("two", 1250);
84
+ policy.observeStream("three", 1800);
85
+ expect(policy.canUseStream(1800)).toBe(false);
86
+ policy.observeStream("four", 2050);
87
+ policy.invalidate();
88
+ policy.observeStream("five", 2300);
89
+ expect(policy.canUseStream(2300)).toBe(false);
90
+ });
91
+ });
92
+
93
+ it("quality-only captures preserve independent recovery and still exclude capture-induced events", () => {
94
+ const policy = createCaptureTransportPolicy();
95
+ policy.enterFallback(0);
96
+ policy.observeStream("one", 1100);
97
+ const token = policy.beginScreenshot({ preserveRecovery: true });
98
+ policy.observeStream("induced", 1120);
99
+ policy.endScreenshot(token, 1130);
100
+ policy.observeStream("queued", 1250);
101
+ expect(policy.canUseStream(1500)).toBe(false);
102
+ policy.observeStream("two", 1500);
103
+ policy.observeStream("three", 1750);
104
+ expect(policy.canUseStream(1750)).toBe(true);
105
+ });
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Owns transport hysteresis and bounded screenshot reuse, independently of JPEG
3
+ * decoding. A quiet CDP stream can emit one frame because captureScreenshot ran;
4
+ * those frames are still acknowledged/cached, but cannot establish recovery.
5
+ * The 250ms post-capture exclusion is conservative because CDP provides no
6
+ * causal request ID on screencast events. Cached receipt age is monotonic.
7
+ */
8
+ import type { RuntimeFrame } from "./agent-browser-runtime";
9
+
10
+ export const CAPTURE_MAX_AGE_MS = 1_000;
11
+ const FALLBACK_MIN_DWELL_MS = 1_500;
12
+ const RECOVERY_MIN_EVENTS = 3;
13
+ const RECOVERY_MIN_SPAN_MS = 500;
14
+ const RECOVERY_MAX_GAP_MS = 500;
15
+ const SCREENSHOT_EVENT_GUARD_MS = 250;
16
+ const RECOVERY_ID_MEMORY = 32;
17
+
18
+ type ScreenshotCache = {
19
+ frame: RuntimeFrame;
20
+ receivedAt: number;
21
+ quality: number;
22
+ maxBytes: number;
23
+ };
24
+
25
+ /** Create one policy per runtime. Invalidation revokes outstanding screenshot completion tokens. */
26
+ export function createCaptureTransportPolicy() {
27
+ let generation = 0;
28
+ let fallbackSince: number | null = null;
29
+ let screenshot: ScreenshotCache | null = null;
30
+ let screenshotsInFlight = 0;
31
+ let ignoreStreamUntil = 0;
32
+ let recoveryFirstAt: number | null = null;
33
+ let recoveryLastAt: number | null = null;
34
+ let recoveryCount = 0;
35
+ const recoveryIds = new Set<string>();
36
+
37
+ function clearRecovery(): void {
38
+ recoveryFirstAt = null;
39
+ recoveryLastAt = null;
40
+ recoveryCount = 0;
41
+ recoveryIds.clear();
42
+ }
43
+
44
+ /**
45
+ * Clear cached screenshots/evidence and revoke old async completions. Keep an
46
+ * existing fallback dwell: input/session changes require fresh recovery evidence.
47
+ */
48
+ function invalidate(): void {
49
+ generation += 1;
50
+ screenshot = null;
51
+ screenshotsInFlight = 0;
52
+ ignoreStreamUntil = 0;
53
+ clearRecovery();
54
+ }
55
+
56
+ /** Enter fallback once; recapture/cache expiry must not extend the minimum dwell. */
57
+ function enterFallback(now: number): void {
58
+ if (fallbackSince !== null) return;
59
+ fallbackSince = now;
60
+ clearRecovery();
61
+ }
62
+
63
+ /** Feed only valid current-session CDP events, with distinct provider timestamps when available. */
64
+ function observeStream(identity: string, now: number): void {
65
+ if (fallbackSince === null) return;
66
+ if (screenshotsInFlight > 0 || now <= ignoreStreamUntil) return;
67
+ if (recoveryLastAt !== null && now - recoveryLastAt > RECOVERY_MAX_GAP_MS) {
68
+ clearRecovery();
69
+ }
70
+ if (recoveryIds.has(identity)) return;
71
+ if (recoveryFirstAt === null) recoveryFirstAt = now;
72
+ recoveryLastAt = now;
73
+ recoveryCount = Math.min(RECOVERY_MIN_EVENTS, recoveryCount + 1);
74
+ recoveryIds.add(identity);
75
+ if (recoveryIds.size > RECOVERY_ID_MEMORY) {
76
+ const oldest = recoveryIds.values().next().value;
77
+ if (oldest !== undefined) recoveryIds.delete(oldest);
78
+ }
79
+ }
80
+
81
+ /** Require sustained independent activity before allowing a fresh stream to replace fallback. */
82
+ function canUseStream(now: number): boolean {
83
+ if (fallbackSince === null) return true;
84
+ if (
85
+ recoveryFirstAt === null ||
86
+ recoveryLastAt === null ||
87
+ now - fallbackSince < FALLBACK_MIN_DWELL_MS ||
88
+ recoveryCount < RECOVERY_MIN_EVENTS ||
89
+ recoveryLastAt - recoveryFirstAt < RECOVERY_MIN_SPAN_MS ||
90
+ now - recoveryLastAt > RECOVERY_MAX_GAP_MS
91
+ )
92
+ return false;
93
+ fallbackSince = null;
94
+ clearRecovery();
95
+ return true;
96
+ }
97
+
98
+ /** Reuse only identical quality/budget requests; larger budgets deserve a new quality decision. */
99
+ function readScreenshot(now: number, quality: number, maxBytes: number): RuntimeFrame | null {
100
+ const cached = screenshot;
101
+ if (
102
+ !cached ||
103
+ now - cached.receivedAt > CAPTURE_MAX_AGE_MS ||
104
+ cached.quality !== quality ||
105
+ cached.maxBytes !== maxBytes
106
+ )
107
+ return null;
108
+ return cached.frame;
109
+ }
110
+
111
+ /**
112
+ * Begin capture and return its mutation/session epoch. A quality-only viewer
113
+ * capture keeps independent stream recovery evidence, while still excluding
114
+ * capture-induced events until the same causal guard expires.
115
+ */
116
+ function beginScreenshot(options: { preserveRecovery?: boolean } = {}): number {
117
+ screenshotsInFlight += 1;
118
+ if (!options.preserveRecovery) clearRecovery();
119
+ return generation;
120
+ }
121
+
122
+ /** End causal exclusion only for this still-current capture generation. */
123
+ function endScreenshot(token: number, now: number): void {
124
+ if (token !== generation) return;
125
+ screenshotsInFlight = Math.max(0, screenshotsInFlight - 1);
126
+ ignoreStreamUntil = Math.max(ignoreStreamUntil, now + SCREENSHOT_EVENT_GUARD_MS);
127
+ }
128
+
129
+ /** Never install a pre-input/session screenshot after its asynchronous RPC resolves. */
130
+ function rememberScreenshot(
131
+ token: number,
132
+ frame: RuntimeFrame,
133
+ now: number,
134
+ quality: number,
135
+ maxBytes: number,
136
+ ): boolean {
137
+ if (token !== generation) return false;
138
+ screenshot = { frame, receivedAt: now, quality, maxBytes };
139
+ return true;
140
+ }
141
+
142
+ return {
143
+ invalidate,
144
+ enterFallback,
145
+ observeStream,
146
+ canUseStream,
147
+ readScreenshot,
148
+ beginScreenshot,
149
+ endScreenshot,
150
+ rememberScreenshot,
151
+ currentGeneration: () => generation,
152
+ isCurrent: (token: number) => token === generation,
153
+ };
154
+ }
@@ -0,0 +1,213 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { DEFAULT_JPEG_QUALITY } from "../shared/capture-settings";
3
+ import { AgentBrowserRuntime } from "./agent-browser-runtime";
4
+
5
+ /** Header fixture exercises actual bounded JPEG validation without launching Chromium. */
6
+ function jpeg(tag = 1): string {
7
+ return Buffer.from([
8
+ 255,
9
+ 216,
10
+ 255,
11
+ 224,
12
+ 0,
13
+ 3,
14
+ tag,
15
+ 255,
16
+ 192,
17
+ 0,
18
+ 11,
19
+ 8,
20
+ 3,
21
+ 32,
22
+ 5,
23
+ 0,
24
+ 1,
25
+ 1,
26
+ 17,
27
+ 0,
28
+ 255,
29
+ 217,
30
+ ]).toString("base64");
31
+ }
32
+ let clock = 10_000;
33
+ beforeEach(() => {
34
+ clock = 10_000;
35
+ vi.spyOn(performance, "now").mockImplementation(() => clock);
36
+ vi.spyOn(Date, "now").mockImplementation(() => 1_800_000_000_000 + clock);
37
+ });
38
+ afterEach(() => vi.restoreAllMocks());
39
+
40
+ function fixture() {
41
+ const runtime = new AgentBrowserRuntime({
42
+ nativeVideo: true,
43
+ binaryPath: "/tmp/unlaunched-browser",
44
+ executablePath: "/tmp/unlaunched-chromium",
45
+ profilePath: "/tmp/uncreated-profile",
46
+ ipcDirectory: "/tmp/uncreated-ipc",
47
+ session: "test",
48
+ });
49
+ const calls: string[] = [];
50
+ let capture: (params: Record<string, unknown>) => Promise<{ data: string }> = async () => ({
51
+ data: jpeg(),
52
+ });
53
+ const page = {
54
+ send: async (method: string, params: Record<string, unknown> = {}) => {
55
+ calls.push(method);
56
+ if (method === "Page.captureScreenshot") return capture(params);
57
+ if (method === "Page.getLayoutMetrics") return { cssVisualViewport: { pageX: 0, pageY: 0 } };
58
+ return {};
59
+ },
60
+ };
61
+ const control = runtime as unknown as {
62
+ requirePage: () => Promise<typeof page>;
63
+ page: typeof page;
64
+ invalidateScreencastFrame: () => void;
65
+ onScreencastFrame: (source: typeof page, event: object) => void;
66
+ };
67
+ Object.assign(runtime, {
68
+ page,
69
+ emulationAppliedPage: page,
70
+ screencastActive: true,
71
+ viewport: { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false, touch: false },
72
+ });
73
+ control.requirePage = async () => control.page;
74
+ const emit = (timestamp: number, source = page) =>
75
+ control.onScreencastFrame(source, {
76
+ data: jpeg(2),
77
+ metadata: {
78
+ deviceWidth: 1280,
79
+ deviceHeight: 800,
80
+ timestamp: (Date.now() + timestamp / 1000) / 1000,
81
+ },
82
+ sessionId: 17,
83
+ });
84
+ return {
85
+ runtime,
86
+ control,
87
+ page,
88
+ calls,
89
+ emit,
90
+ setCapture: (next: typeof capture) => {
91
+ capture = next;
92
+ },
93
+ };
94
+ }
95
+
96
+ describe("runtime capture hysteresis", () => {
97
+ it("captures only the visible viewport without Chromium's touch-resetting beyond-viewport mode", async () => {
98
+ const { runtime, setCapture } = fixture();
99
+ setCapture(async (params) => {
100
+ expect(params.captureBeyondViewport).toBe(false);
101
+ expect(params.clip).toEqual({ x: 0, y: 0, width: 1280, height: 800, scale: 1 });
102
+ return { data: jpeg() };
103
+ });
104
+ expect((await runtime.frame(100, DEFAULT_JPEG_QUALITY, 0)).transport).toBe("screenshot");
105
+ });
106
+
107
+ it("does not bounce to CDP after a screenshot-induced event; reuses only fresh exact-request pixels", async () => {
108
+ const { runtime, calls, emit, setCapture } = fixture();
109
+ setCapture(async () => {
110
+ emit(clock);
111
+ return { data: jpeg() };
112
+ });
113
+ const first = await runtime.frame(100, DEFAULT_JPEG_QUALITY, 0);
114
+ expect(first.transport).toBe("screenshot");
115
+ clock += 250;
116
+ expect(await runtime.frame(100, DEFAULT_JPEG_QUALITY, 0)).toBe(first);
117
+ expect(calls.filter((method) => method === "Page.captureScreenshot")).toHaveLength(1);
118
+ clock += 751;
119
+ expect(await runtime.frame(100, DEFAULT_JPEG_QUALITY, 0)).not.toBe(first);
120
+ expect(calls.filter((method) => method === "Page.captureScreenshot")).toHaveLength(2);
121
+ await runtime.frame(100, 90, 0);
122
+ await runtime.frame(101, 90, 0);
123
+ expect(calls.filter((method) => method === "Page.captureScreenshot")).toHaveLength(4);
124
+ });
125
+
126
+ it("only sustained valid current-session events recover CDP after dwell", async () => {
127
+ const { runtime, page, emit } = fixture();
128
+ await runtime.frame(100, DEFAULT_JPEG_QUALITY, 0);
129
+ clock += 1100;
130
+ emit(1);
131
+ clock += 250;
132
+ emit(2);
133
+ clock += 250;
134
+ emit(3, { ...page });
135
+ expect((await runtime.frame(100, DEFAULT_JPEG_QUALITY, 0)).transport).toBe("screenshot");
136
+ clock += 300;
137
+ emit(3);
138
+ clock += 250;
139
+ emit(4);
140
+ clock += 250;
141
+ emit(5);
142
+ expect((await runtime.frame(100, DEFAULT_JPEG_QUALITY, 0)).transport).toBe("cdp-screencast");
143
+ });
144
+
145
+ it("late screenshot completion after input cannot return or restore old cached pixels", async () => {
146
+ const { runtime, control, setCapture } = fixture();
147
+ const shot = Promise.withResolvers<{ data: string }>();
148
+ const started = Promise.withResolvers<void>();
149
+ setCapture(async () => {
150
+ started.resolve();
151
+ return shot.promise;
152
+ });
153
+ const pending = runtime.frame(100, DEFAULT_JPEG_QUALITY, 0);
154
+ await started.promise;
155
+ await runtime.insertText("owned-fixture");
156
+ shot.resolve({ data: jpeg() });
157
+ await expect(pending).rejects.toThrow("capture was invalidated");
158
+ setCapture(async () => ({ data: jpeg(3) }));
159
+ const fresh = await runtime.frame(100, DEFAULT_JPEG_QUALITY, 0);
160
+ expect(fresh.dataBase64).toBe(jpeg(3));
161
+ control.invalidateScreencastFrame();
162
+ setCapture(async () => ({ data: jpeg(4) }));
163
+ expect((await runtime.frame(100, DEFAULT_JPEG_QUALITY, 0)).dataBase64).toBe(jpeg(4));
164
+ });
165
+
166
+ it("late screenshot completion from a replaced page cannot authorize a frame", async () => {
167
+ const { runtime, control, page, setCapture } = fixture();
168
+ const shot = Promise.withResolvers<{ data: string }>();
169
+ const started = Promise.withResolvers<void>();
170
+ setCapture(async () => {
171
+ started.resolve();
172
+ return shot.promise;
173
+ });
174
+ const pending = runtime.frame(100, DEFAULT_JPEG_QUALITY, 0);
175
+ await started.promise;
176
+ control.page = { ...page };
177
+ control.invalidateScreencastFrame();
178
+ shot.resolve({ data: jpeg() });
179
+ await expect(pending).rejects.toThrow("capture was invalidated");
180
+ });
181
+ });
182
+
183
+ it("honors low/medium quality independently while high viewers keep the shared stream", async () => {
184
+ const { runtime, calls, emit, setCapture } = fixture();
185
+ const qualities: unknown[] = [];
186
+ setCapture(async (params) => {
187
+ qualities.push(params.quality);
188
+ return { data: jpeg() };
189
+ });
190
+ emit(1);
191
+ expect((await runtime.frame(100, DEFAULT_JPEG_QUALITY, 0)).transport).toBe("cdp-screencast");
192
+ const low = await runtime.frame(100, 70, 0);
193
+ expect(low.transport).toBe("screenshot");
194
+ expect(await runtime.frame(100, 70, 0)).toBe(low);
195
+ expect((await runtime.frame(100, DEFAULT_JPEG_QUALITY, 0)).transport).toBe("cdp-screencast");
196
+ expect((await runtime.frame(100, 90, 0)).transport).toBe("screenshot");
197
+ expect((await runtime.frame(100, DEFAULT_JPEG_QUALITY, 0)).transport).toBe("cdp-screencast");
198
+ expect(qualities).toEqual([70, 90]);
199
+ expect(calls).not.toContain("Page.startScreencast");
200
+ expect(calls).not.toContain("Page.stopScreencast");
201
+ });
202
+
203
+ it("reduces quality only to satisfy the byte cap and never substitutes a cached high-quality frame", async () => {
204
+ const { runtime, setCapture } = fixture();
205
+ const qualities: unknown[] = [];
206
+ setCapture(async (params) => {
207
+ qualities.push(params.quality);
208
+ return { data: params.quality === 70 ? jpeg() + "AAAA".repeat(50) : jpeg() };
209
+ });
210
+ await runtime.frame(100, DEFAULT_JPEG_QUALITY, 0);
211
+ expect((await runtime.frame(100, 70, 0)).transport).toBe("screenshot");
212
+ expect(qualities).toEqual([DEFAULT_JPEG_QUALITY, 70, 65]);
213
+ });
@@ -0,0 +1,70 @@
1
+ /** Ownership must survive the shipped JS-launcher -> detached daemon chain, not just a native binary. */
2
+ import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { afterEach, expect, it, vi } from "vitest";
6
+ import { AgentBrowserRuntime } from "./agent-browser-runtime";
7
+
8
+ const directories: string[] = [];
9
+ afterEach(async () => {
10
+ vi.restoreAllMocks();
11
+ await Promise.all(
12
+ directories.splice(0).map((path) => rm(path, { recursive: true, force: true })),
13
+ );
14
+ });
15
+
16
+ const launcher = `#!${process.execPath}
17
+ const { spawn } = require("node:child_process");
18
+ const { writeFileSync } = require("node:fs");
19
+ const { join } = require("node:path");
20
+ const args = process.argv.slice(2);
21
+ const dir = process.env.AGENT_BROWSER_SOCKET_DIR;
22
+ const session = args[args.indexOf("--session") + 1];
23
+ if (args.includes("--version")) console.log("0.37.1");
24
+ else if (args.includes("open")) {
25
+ // Like the shipped launcher: the daemon is detached but inherits this process's environment.
26
+ const daemon = spawn(process.execPath, ["-e", "setTimeout(() => {}, 30000)"], { detached: true, stdio: "ignore" });
27
+ daemon.unref();
28
+ writeFileSync(join(dir, session + ".pid"), String(daemon.pid));
29
+ console.log("{}");
30
+ } else if (args.includes("close")) {
31
+ process.kill(Number(require("node:fs").readFileSync(join(dir, session + ".pid"), "utf8")), "SIGTERM");
32
+ console.log("{}");
33
+ }
34
+ `;
35
+
36
+ it.skipIf(process.platform !== "linux")(
37
+ "verifies the detached daemon through a script launcher and observes its exit without a signal",
38
+ async () => {
39
+ const directory = await mkdtemp(join(tmpdir(), "shared-browser-chain-"));
40
+ directories.push(directory);
41
+ const binary = join(directory, "agent-browser");
42
+ await writeFile(binary, launcher);
43
+ await chmod(binary, 0o700);
44
+ const runtime = new AgentBrowserRuntime({
45
+ binaryPath: binary,
46
+ executablePath: join(directory, "unlaunched-chromium"),
47
+ profilePath: join(directory, "profile"),
48
+ ipcDirectory: join(directory, "ipc"),
49
+ session: "chain",
50
+ daemonExitWaitMs: 3_000,
51
+ });
52
+ const native = runtime as unknown as {
53
+ invoke(args: string[]): Promise<unknown>;
54
+ captureDaemon(): Promise<void>;
55
+ daemonLaunched: boolean;
56
+ daemon: { pid: number; identity: unknown } | null;
57
+ };
58
+ const { mkdir } = await import("node:fs/promises");
59
+ await mkdir(join(directory, "ipc"), { recursive: true });
60
+ await native.invoke(["--session", "chain", "--json", "open", "about:blank"]);
61
+ native.daemonLaunched = true;
62
+ await native.captureDaemon();
63
+ expect(native.daemon?.identity).toBeTruthy();
64
+ const pid = native.daemon?.pid as number;
65
+ const kill = vi.spyOn(process, "kill");
66
+ await runtime.shutdown();
67
+ expect(kill).not.toHaveBeenCalledWith(pid, "SIGKILL");
68
+ expect(() => process.kill(pid, 0)).toThrow();
69
+ },
70
+ );