@omercnet/paseo-shared-browser 1.2.2-next.232.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.
- package/README.md +312 -25
- package/REMOTE_BROWSER_RESEARCH.md +395 -0
- package/STREAMING_DESIGN.md +191 -0
- package/client/browser-auto-capture-density.test.ts +102 -0
- package/client/browser-auto-capture-density.ts +30 -0
- package/client/browser-canvas-input.test.ts +198 -0
- package/client/browser-canvas-input.ts +202 -0
- package/client/browser-canvas-keyboard-web.test.ts +113 -0
- package/client/browser-canvas-keyboard-web.ts +230 -0
- package/client/browser-canvas-keyboard.test.ts +123 -0
- package/client/browser-canvas-keyboard.ts +126 -0
- package/client/browser-canvas-layout.test.ts +109 -0
- package/client/browser-canvas-layout.ts +51 -0
- package/client/browser-canvas-viewport-web.test.ts +77 -0
- package/client/browser-canvas-viewport-web.ts +63 -0
- package/client/browser-canvas-viewport.tsx +152 -0
- package/client/browser-capture-cadence.test.ts +9 -0
- package/client/browser-capture-cadence.ts +6 -0
- package/client/browser-capture-density-controls.tsx +75 -0
- package/client/browser-chrome.tsx +234 -0
- package/client/browser-client-presence.ts +184 -0
- package/client/browser-compose-controls.test.tsx +275 -0
- package/client/browser-compose-controls.tsx +174 -0
- package/client/browser-control-button.tsx +109 -0
- package/client/browser-control-dialog.tsx +71 -0
- package/client/browser-control-request.test.ts +27 -0
- package/client/browser-control-request.ts +57 -0
- package/client/browser-control-tooltip-web.ts +10 -0
- package/client/browser-dialog-body.tsx +22 -0
- package/client/browser-emulation-mode.test.ts +75 -0
- package/client/browser-emulation-mode.ts +98 -0
- package/client/browser-fill-viewport.test.ts +45 -0
- package/client/browser-fill-viewport.ts +28 -0
- package/client/browser-frame-buffer.test.ts +157 -0
- package/client/browser-frame-buffer.ts +148 -0
- package/client/browser-frame-image.tsx +62 -0
- package/client/browser-image-load.test.ts +23 -0
- package/client/browser-image-load.ts +26 -0
- package/client/browser-input-queue.test.ts +1201 -0
- package/client/browser-input-queue.ts +505 -0
- package/client/browser-menu-placement.test.ts +90 -0
- package/client/browser-menu-placement.ts +78 -0
- package/client/browser-panel-styles.ts +384 -0
- package/client/browser-quality-controls.test.ts +26 -0
- package/client/browser-quality-controls.tsx +139 -0
- package/client/browser-resolution-picker.tsx +189 -0
- package/client/browser-tabs.tsx +117 -0
- package/client/browser-toolbar-menu-web.test.ts +185 -0
- package/client/browser-toolbar-menu-web.ts +159 -0
- package/client/browser-toolbar-menu.tsx +255 -0
- package/client/browser-toolbar-submenu.tsx +93 -0
- package/client/browser-video-bytes.test.ts +44 -0
- package/client/browser-video-decoder.test.ts +473 -0
- package/client/browser-video-decoder.ts +311 -0
- package/client/browser-video-surface.tsx +21 -0
- package/client/browser-video-visibility.test.ts +45 -0
- package/client/browser-viewer-recovery.test.ts +111 -0
- package/client/browser-viewer-recovery.ts +78 -0
- package/client/browser.tsx +1642 -1569
- package/client/favorite-star.tsx +23 -0
- package/client/frame-lifecycle.ts +62 -59
- package/client/use-browser-auto-capture-density.test.ts +104 -0
- package/client/use-browser-auto-capture-density.ts +43 -0
- package/client/use-browser-canvas-input.ts +370 -0
- package/client/use-browser-capture-density.ts +87 -0
- package/client/use-browser-emulation-mode.ts +56 -0
- package/client/use-browser-fill-viewport.test.ts +108 -0
- package/client/use-browser-fill-viewport.ts +62 -0
- package/client/use-browser-frame-buffer.ts +66 -0
- package/client/use-browser-image-capture.ts +139 -0
- package/client/use-browser-scoped-mutation.test.ts +118 -0
- package/client/use-browser-scoped-mutation.ts +59 -0
- package/client/use-browser-video.test.ts +991 -0
- package/client/use-browser-video.ts +474 -0
- package/client/use-browser-viewer-recovery.ts +26 -0
- package/client/use-resolution-favorites.ts +90 -0
- package/client/web.test.ts +125 -0
- package/client/web.ts +405 -0
- package/index.client.tsx +0 -10
- package/index.server.ts +32 -0
- package/package.json +9 -5
- package/paseo-plugin.json +3 -1
- package/scripts/prepare-runtime.mjs +1 -1
- package/server/agent-browser-runtime.ts +1598 -314
- package/server/browser-cursor.test.ts +64 -0
- package/server/browser-cursor.ts +69 -0
- package/server/browser-frame-context.ts +22 -0
- package/server/browser-frame-epoch.test.ts +95 -0
- package/server/browser-frame-rejection.ts +17 -0
- package/server/browser-gesture.ts +120 -0
- package/server/browser-operation-diagnostics.test.ts +79 -0
- package/server/browser-operation-diagnostics.ts +103 -0
- package/server/browser-policy.ts +1313 -151
- package/server/browser-video-input-authority.test.ts +208 -0
- package/server/browser-video-policy.test.ts +564 -0
- package/server/browser-video-socket.test.ts +368 -0
- package/server/browser.ts +151 -16
- package/server/capture-transport-policy.test.ts +105 -0
- package/server/capture-transport-policy.ts +154 -0
- package/server/capture-transport-runtime.test.ts +213 -0
- package/server/daemon-launch-chain.test.ts +70 -0
- package/server/daemon-shutdown.test.ts +289 -0
- package/server/discrete-input-freshness.test.ts +364 -0
- package/server/discrete-input-native-lifetime.test.ts +150 -0
- package/server/display-loss-recovery.test.ts +294 -0
- package/server/emulation-restoration.test.ts +262 -0
- package/server/input-cleanup.ts +23 -0
- package/server/input-generation.test.ts +31 -0
- package/server/input-generation.ts +36 -0
- package/server/jpeg-capture-demand.test.ts +76 -0
- package/server/jpeg-capture-demand.ts +98 -0
- package/server/jpeg-frame.ts +29 -0
- package/server/keyboard-input.test.ts +61 -0
- package/server/keyboard-input.ts +104 -0
- package/server/live-input-policy.test.ts +958 -0
- package/server/live-input-runtime.test.ts +732 -0
- package/server/live-input-supervisor.test.ts +147 -0
- package/server/mcp-entry.ts +44 -2
- package/server/native-video-backpressure.test.ts +228 -0
- package/server/native-video-buffer.test.ts +141 -0
- package/server/native-video-buffer.ts +114 -0
- package/server/native-video-capture.test.ts +599 -0
- package/server/native-video-capture.ts +497 -0
- package/server/native-video-extension-source.test.ts +201 -0
- package/server/native-video-extension-source.ts +364 -0
- package/server/native-video-extension.test.ts +41 -0
- package/server/native-video-extension.ts +83 -0
- package/server/native-video-packet.ts +9 -0
- package/server/native-video-settings.test.ts +245 -0
- package/server/navigation-budget.ts +8 -0
- package/server/navigation-commit.ts +125 -0
- package/server/navigation-policy.test.ts +153 -0
- package/server/process-identity.ts +58 -0
- package/server/reattach-held-input.test.ts +143 -0
- package/server/runtime-frame-reuse.test.ts +295 -0
- package/server/runtime-jpeg-demand.test.ts +200 -0
- package/server/runtime-jpeg-shutdown.test.ts +210 -0
- package/server/runtime-jpeg-source-order.test.ts +201 -0
- package/server/runtime-launch-environment.test.ts +111 -0
- package/server/runtime-navigation.test.ts +327 -0
- package/server/runtime-owner-display.test.ts +152 -0
- package/server/runtime-owner.ts +305 -31
- package/server/runtime-page-state.test.ts +249 -0
- package/server/runtime-protocol.ts +31 -1
- package/server/runtime-target-retention.test.ts +181 -0
- package/server/runtime-video-opt-in.test.ts +82 -0
- package/server/runtime-video.test.ts +444 -0
- package/server/screencast-source-time.ts +46 -0
- package/server/stale-wheel-admission.test.ts +136 -0
- package/server/supervisor-client.ts +28 -4
- package/server/supervisor-heartbeat.test.ts +80 -0
- package/server/supervisor.ts +553 -55
- package/server/video-source-recovery.test.ts +20 -0
- package/server/video-source-recovery.ts +64 -0
- package/server/viewer-capture-lifetime.ts +51 -0
- package/server/virtual-display.test.ts +184 -0
- package/server/virtual-display.ts +236 -0
- package/shared/browser-display-preferences.ts +46 -0
- package/shared/browser-video.ts +80 -0
- package/shared/browser.ts +286 -65
- package/shared/capture-density.ts +16 -0
- package/shared/capture-settings.ts +17 -0
- package/shared/device-presets.ts +90 -0
- package/shared/resolution-menu.test.ts +120 -0
- package/shared/resolution-menu.ts +72 -0
- package/shared/video-settings.test.ts +38 -0
- package/shared/video-settings.ts +34 -0
- package/shared/viewport-limits.ts +5 -0
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import { performance } from "node:perf_hooks";
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
const fixtures = vi.hoisted(() => ({ helper: null as unknown }));
|
|
6
|
+
vi.mock("./cdp", async () => {
|
|
7
|
+
const actual = await vi.importActual<typeof import("./cdp")>("./cdp");
|
|
8
|
+
return { ...actual, attachToTarget: vi.fn(async () => fixtures.helper) };
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
import type { CdpConnection } from "./cdp";
|
|
12
|
+
import { NativeVideoCapture } from "./native-video-capture";
|
|
13
|
+
|
|
14
|
+
class Helper extends EventEmitter {
|
|
15
|
+
expressions: string[] = [];
|
|
16
|
+
detached = 0;
|
|
17
|
+
async send(method: string, params: Record<string, unknown> = {}) {
|
|
18
|
+
if (method === "Performance.getMetrics")
|
|
19
|
+
return { metrics: [{ name: "Timestamp", value: performance.now() / 1000 }] };
|
|
20
|
+
if (method === "Runtime.evaluate") this.expressions.push(String(params.expression));
|
|
21
|
+
return { result: { value: { codec: "vp8" } } };
|
|
22
|
+
}
|
|
23
|
+
async detach() {
|
|
24
|
+
this.detached++;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function setup(now?: () => number) {
|
|
28
|
+
const helper = new Helper();
|
|
29
|
+
fixtures.helper = helper;
|
|
30
|
+
const commands: { method: string; params: Record<string, unknown> }[] = [];
|
|
31
|
+
const connection = {
|
|
32
|
+
isOpen: true,
|
|
33
|
+
send: vi.fn(async (method: string, params: Record<string, unknown>) => {
|
|
34
|
+
commands.push({ method, params });
|
|
35
|
+
return { targetId: "owned-hidden" };
|
|
36
|
+
}),
|
|
37
|
+
};
|
|
38
|
+
let current = true;
|
|
39
|
+
const capture = new NativeVideoCapture({
|
|
40
|
+
connection: connection as unknown as CdpConnection,
|
|
41
|
+
targetId: "exact-source",
|
|
42
|
+
width: 1280,
|
|
43
|
+
height: 800,
|
|
44
|
+
isCurrent: () => current,
|
|
45
|
+
...(now ? { now } : {}),
|
|
46
|
+
});
|
|
47
|
+
return {
|
|
48
|
+
capture,
|
|
49
|
+
helper,
|
|
50
|
+
commands,
|
|
51
|
+
replace: () => {
|
|
52
|
+
current = false;
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
const sourcePacket = (streamId: string, sequence = 1, generation = 1, ageMs = 0) => ({
|
|
57
|
+
streamId,
|
|
58
|
+
sequence,
|
|
59
|
+
captureGeneration: generation,
|
|
60
|
+
timestampUs: Math.floor((performance.now() - ageMs) * 1000),
|
|
61
|
+
type: "key",
|
|
62
|
+
codec: "vp8",
|
|
63
|
+
width: 1280,
|
|
64
|
+
height: 800,
|
|
65
|
+
dataBase64: Buffer.from("native").toString("base64"),
|
|
66
|
+
});
|
|
67
|
+
describe("trusted exact-tab capture", () => {
|
|
68
|
+
beforeEach(() => {
|
|
69
|
+
fixtures.helper = null;
|
|
70
|
+
});
|
|
71
|
+
it("classifies stopped and exact-size source failures without trusting remote error text", async () => {
|
|
72
|
+
const { capture, helper } = setup();
|
|
73
|
+
try {
|
|
74
|
+
await capture.read({ quality: "high", waitMs: 0 });
|
|
75
|
+
helper.emit("Runtime.bindingCalled", {
|
|
76
|
+
name: "nativeVideoPacket",
|
|
77
|
+
payload: JSON.stringify({ error: "Private remote data", reasonCode: "unknown" }),
|
|
78
|
+
});
|
|
79
|
+
expect(capture.sourceFailure).toBe("source-stopped");
|
|
80
|
+
helper.emit("Runtime.bindingCalled", {
|
|
81
|
+
name: "nativeVideoPacket",
|
|
82
|
+
payload: JSON.stringify({ error: "Private remote data", reasonCode: "source-dimensions" }),
|
|
83
|
+
});
|
|
84
|
+
expect(capture.sourceFailure).toBe("source-dimensions");
|
|
85
|
+
helper.emit("Runtime.bindingCalled", {
|
|
86
|
+
name: "nativeVideoPacket",
|
|
87
|
+
payload: JSON.stringify({ error: "Later generic source fault" }),
|
|
88
|
+
});
|
|
89
|
+
expect(capture.sourceFailure).toBe("source-dimensions");
|
|
90
|
+
expect(await capture.read({ quality: "high", waitMs: 0 })).toMatchObject({
|
|
91
|
+
status: "unsupported",
|
|
92
|
+
reason: "Native video dimensions do not match the configured viewport",
|
|
93
|
+
});
|
|
94
|
+
} finally {
|
|
95
|
+
await capture.stop();
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
it("retains permanent dimensions and its safe reason through a later reset fault", async () => {
|
|
99
|
+
const { capture, helper } = setup();
|
|
100
|
+
try {
|
|
101
|
+
await capture.read({ quality: "high", waitMs: 0 });
|
|
102
|
+
helper.emit("Runtime.bindingCalled", {
|
|
103
|
+
name: "nativeVideoPacket",
|
|
104
|
+
payload: JSON.stringify({ error: "Private remote data", reasonCode: "source-dimensions" }),
|
|
105
|
+
});
|
|
106
|
+
const send = helper.send.bind(helper);
|
|
107
|
+
const resetFailed = Promise.withResolvers<void>();
|
|
108
|
+
helper.send = async (method, params = {}) => {
|
|
109
|
+
if (
|
|
110
|
+
method === "Runtime.evaluate" &&
|
|
111
|
+
String(params.expression).startsWith("resetCapture(")
|
|
112
|
+
) {
|
|
113
|
+
resetFailed.resolve();
|
|
114
|
+
throw new Error("Private reset error");
|
|
115
|
+
}
|
|
116
|
+
return send(method, params);
|
|
117
|
+
};
|
|
118
|
+
capture.invalidateQueuedFrames();
|
|
119
|
+
await resetFailed.promise;
|
|
120
|
+
for (let i = 0; i < 10; i++) await Promise.resolve();
|
|
121
|
+
expect(capture.sourceFailure).toBe("source-dimensions");
|
|
122
|
+
expect(await capture.read({ quality: "high", waitMs: 0 })).toMatchObject({
|
|
123
|
+
status: "unsupported",
|
|
124
|
+
reason: "Native video dimensions do not match the configured viewport",
|
|
125
|
+
});
|
|
126
|
+
expect(capture.sourceFailure).toBe("source-dimensions");
|
|
127
|
+
} finally {
|
|
128
|
+
await capture.stop();
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
it("keeps a dimension notification permanent when the pending startup later fails", async () => {
|
|
132
|
+
const { capture, helper } = setup();
|
|
133
|
+
const send = helper.send.bind(helper);
|
|
134
|
+
helper.send = async (method, params = {}) => {
|
|
135
|
+
if (method === "Runtime.evaluate" && String(params.expression).startsWith("startCapture(")) {
|
|
136
|
+
helper.emit("Runtime.bindingCalled", {
|
|
137
|
+
name: "nativeVideoPacket",
|
|
138
|
+
payload: JSON.stringify({
|
|
139
|
+
error: "Private remote data",
|
|
140
|
+
reasonCode: "source-dimensions",
|
|
141
|
+
}),
|
|
142
|
+
});
|
|
143
|
+
throw new Error("Startup response was lost");
|
|
144
|
+
}
|
|
145
|
+
return send(method, params);
|
|
146
|
+
};
|
|
147
|
+
try {
|
|
148
|
+
expect((await capture.read({ quality: "high", waitMs: 0 })).status).toBe("unsupported");
|
|
149
|
+
expect(capture.sourceFailure).toBe("source-dimensions");
|
|
150
|
+
expect(helper.detached).toBe(1);
|
|
151
|
+
} finally {
|
|
152
|
+
await capture.stop();
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
it("classifies reset failure as source-wide so the runtime can retire it", async () => {
|
|
156
|
+
const { capture, helper } = setup();
|
|
157
|
+
try {
|
|
158
|
+
await capture.read({ quality: "high", waitMs: 0 });
|
|
159
|
+
const send = helper.send.bind(helper);
|
|
160
|
+
helper.send = async (method, params = {}) => {
|
|
161
|
+
if (
|
|
162
|
+
method === "Runtime.evaluate" &&
|
|
163
|
+
String(params.expression).startsWith("resetCapture(")
|
|
164
|
+
) {
|
|
165
|
+
throw new Error("Private CDP diagnostic");
|
|
166
|
+
}
|
|
167
|
+
return send(method, params);
|
|
168
|
+
};
|
|
169
|
+
capture.invalidateQueuedFrames();
|
|
170
|
+
await vi.waitFor(() => expect(capture.sourceFailure).toBe("source-reset"));
|
|
171
|
+
expect(await capture.read({ quality: "high", waitMs: 0 })).toMatchObject({
|
|
172
|
+
status: "unsupported",
|
|
173
|
+
reason: "Native tab video reset failed",
|
|
174
|
+
});
|
|
175
|
+
} finally {
|
|
176
|
+
await capture.stop();
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
it("creates an owned hidden helper and maps only the exact target", async () => {
|
|
180
|
+
const { capture, helper, commands } = setup();
|
|
181
|
+
await capture.read({ quality: "high", waitMs: 0 });
|
|
182
|
+
expect(commands[0]?.params).toMatchObject({ hidden: true, background: true });
|
|
183
|
+
expect(
|
|
184
|
+
helper.expressions.some((expression) =>
|
|
185
|
+
expression.startsWith('startCapture("exact-source",1280,800,'),
|
|
186
|
+
),
|
|
187
|
+
).toBe(true);
|
|
188
|
+
await capture.stop();
|
|
189
|
+
expect(helper.detached).toBe(1);
|
|
190
|
+
expect(commands.at(-1)?.params).toEqual({ targetId: "owned-hidden" });
|
|
191
|
+
});
|
|
192
|
+
it("late, wrong-size and wrong-generation receipts do not become fresh packets", async () => {
|
|
193
|
+
const { capture, helper } = setup();
|
|
194
|
+
const initial = await capture.read({ quality: "high", waitMs: 0 });
|
|
195
|
+
const id = initial.streamId!;
|
|
196
|
+
const publish = (packet: unknown) =>
|
|
197
|
+
helper.emit("Runtime.bindingCalled", {
|
|
198
|
+
name: "nativeVideoPacket",
|
|
199
|
+
payload: JSON.stringify(packet),
|
|
200
|
+
});
|
|
201
|
+
publish(sourcePacket(id, 1, 1, 1200));
|
|
202
|
+
publish({ ...sourcePacket(id, 2), width: 640 });
|
|
203
|
+
publish(sourcePacket(id, 3, 0));
|
|
204
|
+
expect((await capture.read({ quality: "high", waitMs: 0 })).packets).toEqual([]);
|
|
205
|
+
publish(sourcePacket(id, 4));
|
|
206
|
+
expect((await capture.read({ quality: "high", waitMs: 0 })).packets[0]?.sequence).toBe(4);
|
|
207
|
+
await capture.stop();
|
|
208
|
+
});
|
|
209
|
+
it("quality encoders remain independent and a source barrier revokes both queues", async () => {
|
|
210
|
+
const { capture, helper } = setup();
|
|
211
|
+
const high = await capture.read({ quality: "high", waitMs: 0 });
|
|
212
|
+
const low = await capture.read({ quality: "low", waitMs: 0 });
|
|
213
|
+
expect(high.streamId).not.toBe(low.streamId);
|
|
214
|
+
expect(helper.expressions.filter((x) => x.startsWith("startCapture(")).length).toBe(1);
|
|
215
|
+
helper.emit("Runtime.bindingCalled", {
|
|
216
|
+
name: "nativeVideoPacket",
|
|
217
|
+
payload: JSON.stringify(sourcePacket(high.streamId!)),
|
|
218
|
+
});
|
|
219
|
+
capture.invalidateQueuedFrames();
|
|
220
|
+
expect((await capture.read({ quality: "high", waitMs: 0 })).packets).toEqual([]);
|
|
221
|
+
expect(helper.expressions.some((x) => x.startsWith("resetCapture(2,"))).toBe(true);
|
|
222
|
+
await capture.stop();
|
|
223
|
+
});
|
|
224
|
+
it("joining decoder requests a key only on its quality encoder", async () => {
|
|
225
|
+
const { capture, helper } = setup();
|
|
226
|
+
const high = await capture.read({ quality: "high", waitMs: 0 });
|
|
227
|
+
await capture.read({ quality: "low", waitMs: 0, requestKeyFrame: true });
|
|
228
|
+
expect(helper.expressions).toContain('requestKeyFrame("2000000:30")');
|
|
229
|
+
expect(helper.expressions).not.toContain('requestKeyFrame("12000000:30")');
|
|
230
|
+
expect(helper.expressions.some((x) => x.startsWith("resetCapture("))).toBe(false);
|
|
231
|
+
expect((await capture.read({ quality: "high", waitMs: 0 })).streamId).toBe(high.streamId);
|
|
232
|
+
await capture.stop();
|
|
233
|
+
});
|
|
234
|
+
it("requests recovery on only the broken cohort, coalesces demand and retries after the wait bound", async () => {
|
|
235
|
+
let now = 0;
|
|
236
|
+
const { capture, helper } = setup(() => now);
|
|
237
|
+
try {
|
|
238
|
+
const high = await capture.read({ quality: "high", waitMs: 0 });
|
|
239
|
+
await capture.read({ quality: "low", waitMs: 0 });
|
|
240
|
+
const publish = (packet: unknown) =>
|
|
241
|
+
helper.emit("Runtime.bindingCalled", {
|
|
242
|
+
name: "nativeVideoPacket",
|
|
243
|
+
payload: JSON.stringify(packet),
|
|
244
|
+
});
|
|
245
|
+
publish(sourcePacket(high.streamId!, 1));
|
|
246
|
+
publish({ ...sourcePacket(high.streamId!, 3), type: "delta" });
|
|
247
|
+
expect(
|
|
248
|
+
(await capture.read({ quality: "high", afterSequence: 1, waitMs: 0 })).packets,
|
|
249
|
+
).toEqual([]);
|
|
250
|
+
await capture.read({ quality: "high", afterSequence: 1, waitMs: 0 });
|
|
251
|
+
const requests = () => helper.expressions.filter((x) => x.startsWith("requestKeyFrame("));
|
|
252
|
+
expect(requests()).toEqual(['requestKeyFrame("12000000:30")']);
|
|
253
|
+
|
|
254
|
+
now += 500;
|
|
255
|
+
await capture.read({ quality: "high", afterSequence: 1, waitMs: 0 });
|
|
256
|
+
expect(requests()).toHaveLength(2);
|
|
257
|
+
publish(sourcePacket(high.streamId!, 1)); // Duplicate old key cannot clear pending recovery.
|
|
258
|
+
await capture.read({ quality: "high", afterSequence: 1, waitMs: 0 });
|
|
259
|
+
expect(requests()).toHaveLength(2);
|
|
260
|
+
|
|
261
|
+
publish(sourcePacket(high.streamId!, 4));
|
|
262
|
+
expect(
|
|
263
|
+
(await capture.read({ quality: "high", afterSequence: 1, waitMs: 0 })).packets[0]?.sequence,
|
|
264
|
+
).toBe(4);
|
|
265
|
+
await capture.read({ quality: "high", afterSequence: 4, waitMs: 0 });
|
|
266
|
+
expect(requests()).toHaveLength(2);
|
|
267
|
+
expect(helper.expressions.filter((x) => x.startsWith("startCapture("))).toHaveLength(1);
|
|
268
|
+
expect(helper.expressions.some((x) => x.startsWith("resetCapture("))).toBe(false);
|
|
269
|
+
} finally {
|
|
270
|
+
await capture.stop();
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
it("asks for a fresh key when a gap appears during its bounded wait", async () => {
|
|
274
|
+
const { capture, helper } = setup();
|
|
275
|
+
try {
|
|
276
|
+
const initial = await capture.read({ quality: "high", waitMs: 0 });
|
|
277
|
+
helper.emit("Runtime.bindingCalled", {
|
|
278
|
+
name: "nativeVideoPacket",
|
|
279
|
+
payload: JSON.stringify(sourcePacket(initial.streamId!, 1)),
|
|
280
|
+
});
|
|
281
|
+
const pending = capture.read({ quality: "high", afterSequence: 1, waitMs: 500 });
|
|
282
|
+
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
283
|
+
helper.emit("Runtime.bindingCalled", {
|
|
284
|
+
name: "nativeVideoPacket",
|
|
285
|
+
payload: JSON.stringify({ ...sourcePacket(initial.streamId!, 3), type: "delta" }),
|
|
286
|
+
});
|
|
287
|
+
expect((await pending).packets).toEqual([]);
|
|
288
|
+
expect(helper.expressions).toContain('requestKeyFrame("12000000:30")');
|
|
289
|
+
} finally {
|
|
290
|
+
await capture.stop();
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
it("one encoder failure refuses only that quality, retaining another exact source stream", async () => {
|
|
294
|
+
const { capture, helper } = setup();
|
|
295
|
+
const high = await capture.read({ quality: "high", waitMs: 0 });
|
|
296
|
+
const low = await capture.read({ quality: "low", waitMs: 0 });
|
|
297
|
+
helper.emit("Runtime.bindingCalled", {
|
|
298
|
+
name: "nativeVideoPacket",
|
|
299
|
+
payload: JSON.stringify({ error: "bounded", streamId: high.streamId }),
|
|
300
|
+
});
|
|
301
|
+
helper.emit("Runtime.bindingCalled", {
|
|
302
|
+
name: "nativeVideoPacket",
|
|
303
|
+
payload: JSON.stringify(sourcePacket(low.streamId!)),
|
|
304
|
+
});
|
|
305
|
+
expect((await capture.read({ quality: "high", waitMs: 0 })).status).toBe("unsupported");
|
|
306
|
+
expect(capture.sourceFailure).toBeNull();
|
|
307
|
+
const surviving = await capture.read({ quality: "low", waitMs: 0 });
|
|
308
|
+
expect(surviving.status).toBe("ready");
|
|
309
|
+
expect(surviving.packets[0]?.streamId).toBe(low.streamId);
|
|
310
|
+
expect(helper.expressions.filter((x) => x.startsWith("startCapture(")).length).toBe(1);
|
|
311
|
+
await capture.stop();
|
|
312
|
+
});
|
|
313
|
+
it("attachment replacement during a waiting read releases it without old pixels", async () => {
|
|
314
|
+
const { capture, replace } = setup();
|
|
315
|
+
await capture.read({ quality: "high", waitMs: 0 });
|
|
316
|
+
const waiting = capture.read({ quality: "high", waitMs: 500 });
|
|
317
|
+
replace();
|
|
318
|
+
await capture.stop();
|
|
319
|
+
expect((await waiting).packets).toEqual([]);
|
|
320
|
+
});
|
|
321
|
+
it("waits for the helper document to load before invoking its capture entry point", async () => {
|
|
322
|
+
const { capture, helper } = setup();
|
|
323
|
+
await capture.read({ quality: "high", waitMs: 0 });
|
|
324
|
+
const ready = helper.expressions.findIndex(
|
|
325
|
+
(value) => value.includes("startCapture") && value.includes("load"),
|
|
326
|
+
);
|
|
327
|
+
const start = helper.expressions.findIndex((value) => value.startsWith("startCapture("));
|
|
328
|
+
expect(ready).toBeGreaterThanOrEqual(0);
|
|
329
|
+
expect(start).toBeGreaterThan(ready);
|
|
330
|
+
await capture.stop();
|
|
331
|
+
});
|
|
332
|
+
it("does not call a capture entry point the helper document never defined", async () => {
|
|
333
|
+
const { capture, helper } = setup();
|
|
334
|
+
const original = helper.send.bind(helper);
|
|
335
|
+
helper.send = async (method, params = {}) => {
|
|
336
|
+
if (method === "Runtime.evaluate" && String(params.expression).includes("addEventListener"))
|
|
337
|
+
return { result: { value: false } } as never;
|
|
338
|
+
return original(method, params);
|
|
339
|
+
};
|
|
340
|
+
expect((await capture.read({ quality: "high", waitMs: 0 })).status).toBe("unsupported");
|
|
341
|
+
expect(capture.sourceFailure).toBe("startup");
|
|
342
|
+
expect(helper.expressions.some((value) => value.startsWith("startCapture("))).toBe(false);
|
|
343
|
+
await capture.stop();
|
|
344
|
+
});
|
|
345
|
+
it("cleanup still closes the owned target when acquisition fails", async () => {
|
|
346
|
+
const { capture, helper, commands } = setup();
|
|
347
|
+
helper.send = async (method) => {
|
|
348
|
+
if (method === "Performance.getMetrics") return { metrics: [] } as never;
|
|
349
|
+
return {} as never;
|
|
350
|
+
};
|
|
351
|
+
expect((await capture.read({ quality: "high", waitMs: 0 })).status).toBe("unsupported");
|
|
352
|
+
expect(capture.sourceFailure).toBe("startup");
|
|
353
|
+
expect(commands.some((command) => command.method === "Target.closeTarget")).toBe(true);
|
|
354
|
+
await capture.stop();
|
|
355
|
+
});
|
|
356
|
+
it("a stop during awaited helper initialization closes the late target without starting a track", async () => {
|
|
357
|
+
const helper = new Helper();
|
|
358
|
+
fixtures.helper = helper;
|
|
359
|
+
const creation = Promise.withResolvers<{ targetId: string }>();
|
|
360
|
+
const send = vi.fn(async (method: string) => {
|
|
361
|
+
if (method === "Target.createTarget") return creation.promise;
|
|
362
|
+
return {};
|
|
363
|
+
});
|
|
364
|
+
const capture = new NativeVideoCapture({
|
|
365
|
+
connection: { isOpen: true, send } as unknown as CdpConnection,
|
|
366
|
+
targetId: "exact-source",
|
|
367
|
+
width: 1280,
|
|
368
|
+
height: 800,
|
|
369
|
+
isCurrent: () => true,
|
|
370
|
+
});
|
|
371
|
+
const stopping = capture.stop();
|
|
372
|
+
creation.resolve({ targetId: "late-owned-target" });
|
|
373
|
+
await stopping;
|
|
374
|
+
expect(send).toHaveBeenCalledWith(
|
|
375
|
+
"Target.closeTarget",
|
|
376
|
+
{ targetId: "late-owned-target" },
|
|
377
|
+
{ mutation: true },
|
|
378
|
+
);
|
|
379
|
+
expect(helper.expressions.some((expression) => expression.startsWith("startCapture("))).toBe(
|
|
380
|
+
false,
|
|
381
|
+
);
|
|
382
|
+
expect((await capture.read({ quality: "high", waitMs: 0 })).status).toBe("unsupported");
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("refuses a fourth active settings cohort without disrupting any existing stream", async () => {
|
|
387
|
+
const { capture, helper } = setup();
|
|
388
|
+
try {
|
|
389
|
+
const first = await capture.read({ bitrate: 12_000_000, fps: 30, waitMs: 0 });
|
|
390
|
+
const second = await capture.read({ bitrate: 24_000_000, fps: 60, waitMs: 0 });
|
|
391
|
+
const third = await capture.read({ bitrate: 2_000_000, fps: 15, waitMs: 0 });
|
|
392
|
+
const fourth = await capture.read({ bitrate: 5_000_000, fps: 30, waitMs: 0 });
|
|
393
|
+
expect(fourth.status).toBe("unsupported");
|
|
394
|
+
expect(fourth.reason).toContain("Three video profiles");
|
|
395
|
+
expect(fourth.reasonCode).toBe("encoder-capacity");
|
|
396
|
+
expect(new Set([first.streamId, second.streamId, third.streamId]).size).toBe(3);
|
|
397
|
+
expect(helper.expressions.filter((value) => value.startsWith("addEncoder("))).toHaveLength(3);
|
|
398
|
+
expect(helper.expressions.filter((value) => value.startsWith("removeEncoder("))).toHaveLength(
|
|
399
|
+
0,
|
|
400
|
+
);
|
|
401
|
+
expect((await capture.read({ bitrate: 12_000_000, fps: 30, waitMs: 0 })).streamId).toBe(
|
|
402
|
+
first.streamId,
|
|
403
|
+
);
|
|
404
|
+
} finally {
|
|
405
|
+
await capture.stop();
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
it("evicts only idle cohorts, creates a fresh stream, and releases retained bytes", async () => {
|
|
409
|
+
let now = 0;
|
|
410
|
+
const { capture, helper } = setup(() => now);
|
|
411
|
+
try {
|
|
412
|
+
const old = await capture.read({ bitrate: 12_000_000, fps: 30, waitMs: 0 });
|
|
413
|
+
await capture.read({ bitrate: 24_000_000, fps: 60, waitMs: 0 });
|
|
414
|
+
now = 1500;
|
|
415
|
+
const active = await capture.read({ bitrate: 2_000_000, fps: 15, waitMs: 0 });
|
|
416
|
+
now = 2500;
|
|
417
|
+
const replacement = await capture.read({ bitrate: 5_000_000, fps: 30, waitMs: 0 });
|
|
418
|
+
expect(replacement.status).not.toBe("unsupported");
|
|
419
|
+
expect(helper.expressions).toContain('removeEncoder("12000000:30")');
|
|
420
|
+
expect(helper.expressions).toContain('removeEncoder("24000000:60")');
|
|
421
|
+
expect(helper.expressions).not.toContain('removeEncoder("2000000:15")');
|
|
422
|
+
expect((await capture.read({ bitrate: 2_000_000, fps: 15, waitMs: 0 })).streamId).toBe(
|
|
423
|
+
active.streamId,
|
|
424
|
+
);
|
|
425
|
+
const returned = await capture.read({ bitrate: 12_000_000, fps: 30, waitMs: 0 });
|
|
426
|
+
expect(returned.streamId).not.toBe(old.streamId);
|
|
427
|
+
expect(helper.expressions.filter((value) => value.startsWith("startCapture("))).toHaveLength(1);
|
|
428
|
+
} finally {
|
|
429
|
+
await capture.stop();
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
it("parallel distinct admissions never allocate more than three encoders", async () => {
|
|
433
|
+
const { capture, helper } = setup();
|
|
434
|
+
try {
|
|
435
|
+
const results = await Promise.all(
|
|
436
|
+
[15, 30, 60].map((fps) => capture.read({ bitrate: 12_000_000, fps, waitMs: 0 })),
|
|
437
|
+
);
|
|
438
|
+
const extra = await capture.read({ bitrate: 24_000_000, fps: 60, waitMs: 0 });
|
|
439
|
+
expect(results.every((value) => value.status !== "unsupported")).toBe(true);
|
|
440
|
+
expect(extra.status).toBe("unsupported");
|
|
441
|
+
expect(helper.expressions.filter((value) => value.startsWith("addEncoder("))).toHaveLength(3);
|
|
442
|
+
} finally {
|
|
443
|
+
await capture.stop();
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it("cleans up an idle encoder without requiring a later viewer or restarting the source", async () => {
|
|
448
|
+
vi.useFakeTimers();
|
|
449
|
+
let now = 0;
|
|
450
|
+
const { capture, helper } = setup(() => now);
|
|
451
|
+
try {
|
|
452
|
+
await capture.read({ bitrate: 12_000_000, fps: 30, waitMs: 0 });
|
|
453
|
+
now = 3000;
|
|
454
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
455
|
+
expect(helper.expressions).toContain('removeEncoder("12000000:30")');
|
|
456
|
+
expect(helper.expressions).toContain("stopCapture()");
|
|
457
|
+
expect(capture.idleExpired).toBe(true);
|
|
458
|
+
expect(helper.detached).toBe(1);
|
|
459
|
+
expect(helper.expressions.filter((value) => value.startsWith("startCapture("))).toHaveLength(1);
|
|
460
|
+
} finally {
|
|
461
|
+
await capture.stop();
|
|
462
|
+
vi.useRealTimers();
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
it("an active awaiting read protects its cohort while unused profiles expire", async () => {
|
|
466
|
+
vi.useFakeTimers();
|
|
467
|
+
let now = 0;
|
|
468
|
+
const { capture, helper } = setup(() => now);
|
|
469
|
+
const blocked = Promise.withResolvers<void>();
|
|
470
|
+
try {
|
|
471
|
+
await capture.read({ bitrate: 12_000_000, fps: 30, waitMs: 0 });
|
|
472
|
+
await capture.read({ bitrate: 2_000_000, fps: 15, waitMs: 0 });
|
|
473
|
+
const original = helper.send.bind(helper);
|
|
474
|
+
helper.send = async (method, params = {}) => {
|
|
475
|
+
if (method === "Runtime.evaluate" && params.expression === 'touchEncoder("12000000:30")')
|
|
476
|
+
await blocked.promise;
|
|
477
|
+
return original(method, params);
|
|
478
|
+
};
|
|
479
|
+
const pending = capture.read({ bitrate: 12_000_000, fps: 30, waitMs: 0 });
|
|
480
|
+
for (let i = 0; i < 20; i++) await Promise.resolve();
|
|
481
|
+
now = 5000;
|
|
482
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
483
|
+
expect(helper.expressions).not.toContain('removeEncoder("12000000:30")');
|
|
484
|
+
expect(helper.expressions).toContain('removeEncoder("2000000:15")');
|
|
485
|
+
blocked.resolve();
|
|
486
|
+
await pending;
|
|
487
|
+
} finally {
|
|
488
|
+
blocked.resolve();
|
|
489
|
+
await capture.stop();
|
|
490
|
+
vi.useRealTimers();
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it("pins a delayed newly allocated cohort before releasing admission to queued idle cleanup", async () => {
|
|
495
|
+
vi.useFakeTimers();
|
|
496
|
+
let now = 0;
|
|
497
|
+
const { capture, helper } = setup(() => now);
|
|
498
|
+
const allocation = Promise.withResolvers<void>();
|
|
499
|
+
const published = Promise.withResolvers<void>();
|
|
500
|
+
const reading = Promise.withResolvers<void>();
|
|
501
|
+
const original = helper.send.bind(helper);
|
|
502
|
+
helper.send = async (method, params = {}) => {
|
|
503
|
+
if (method === "Runtime.evaluate" && String(params.expression).startsWith("addEncoder(")) {
|
|
504
|
+
published.resolve();
|
|
505
|
+
await allocation.promise;
|
|
506
|
+
}
|
|
507
|
+
if (method === "Runtime.evaluate" && String(params.expression).startsWith("touchEncoder("))
|
|
508
|
+
await reading.promise;
|
|
509
|
+
return original(method, params);
|
|
510
|
+
};
|
|
511
|
+
try {
|
|
512
|
+
const pending = capture.read({ bitrate: 24_000_000, fps: 60, waitMs: 0 });
|
|
513
|
+
await published.promise;
|
|
514
|
+
now = 5000;
|
|
515
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
516
|
+
allocation.resolve();
|
|
517
|
+
for (let i = 0; i < 30; i++) await Promise.resolve();
|
|
518
|
+
expect(helper.expressions).not.toContain('removeEncoder("24000000:60")');
|
|
519
|
+
expect(capture.idleExpired).toBe(false);
|
|
520
|
+
now = 10000;
|
|
521
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
522
|
+
expect(helper.expressions).not.toContain('removeEncoder("24000000:60")');
|
|
523
|
+
expect(capture.idleExpired).toBe(false);
|
|
524
|
+
reading.resolve();
|
|
525
|
+
await pending;
|
|
526
|
+
} finally {
|
|
527
|
+
allocation.resolve();
|
|
528
|
+
reading.resolve();
|
|
529
|
+
await capture.stop();
|
|
530
|
+
vi.useRealTimers();
|
|
531
|
+
}
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
it("pins source demand before a slow helper startup exceeds the idle interval", async () => {
|
|
535
|
+
vi.useFakeTimers();
|
|
536
|
+
let now = 0;
|
|
537
|
+
const { capture, helper } = setup(() => now);
|
|
538
|
+
const started = Promise.withResolvers<void>();
|
|
539
|
+
const release = Promise.withResolvers<void>();
|
|
540
|
+
const original = helper.send.bind(helper);
|
|
541
|
+
helper.send = async (method, params = {}) => {
|
|
542
|
+
if (method === "Performance.getMetrics") {
|
|
543
|
+
started.resolve();
|
|
544
|
+
await release.promise;
|
|
545
|
+
}
|
|
546
|
+
return original(method, params);
|
|
547
|
+
};
|
|
548
|
+
try {
|
|
549
|
+
const pending = capture.read({ bitrate: 24_000_000, fps: 60, waitMs: 0 });
|
|
550
|
+
await started.promise;
|
|
551
|
+
now = 5000;
|
|
552
|
+
await vi.advanceTimersByTimeAsync(3000);
|
|
553
|
+
expect(capture.idleExpired).toBe(false);
|
|
554
|
+
expect(helper.detached).toBe(0);
|
|
555
|
+
release.resolve();
|
|
556
|
+
expect((await pending).status).not.toBe("unsupported");
|
|
557
|
+
expect(helper.expressions.filter((value) => value.startsWith("addEncoder("))).toHaveLength(1);
|
|
558
|
+
} finally {
|
|
559
|
+
release.resolve();
|
|
560
|
+
await capture.stop();
|
|
561
|
+
vi.useRealTimers();
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
it("keeps idle teardown single-flight while native cleanup is slow", async () => {
|
|
566
|
+
vi.useFakeTimers();
|
|
567
|
+
let now = 0;
|
|
568
|
+
const { capture, helper } = setup(() => now);
|
|
569
|
+
const blocked = Promise.withResolvers<void>();
|
|
570
|
+
const started = Promise.withResolvers<void>();
|
|
571
|
+
const original = helper.send.bind(helper);
|
|
572
|
+
let removalCalls = 0;
|
|
573
|
+
helper.send = async (method, params = {}) => {
|
|
574
|
+
if (method === "Runtime.evaluate" && String(params.expression).startsWith("removeEncoder(")) {
|
|
575
|
+
removalCalls += 1;
|
|
576
|
+
started.resolve();
|
|
577
|
+
await blocked.promise;
|
|
578
|
+
}
|
|
579
|
+
return original(method, params);
|
|
580
|
+
};
|
|
581
|
+
try {
|
|
582
|
+
await capture.read({ bitrate: 12_000_000, fps: 30, waitMs: 0 });
|
|
583
|
+
now = 3000;
|
|
584
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
585
|
+
await started.promise;
|
|
586
|
+
await vi.advanceTimersByTimeAsync(10_000);
|
|
587
|
+
expect(removalCalls).toBe(1);
|
|
588
|
+
expect(helper.detached).toBe(0);
|
|
589
|
+
blocked.resolve();
|
|
590
|
+
for (let i = 0; i < 40; i++) await Promise.resolve();
|
|
591
|
+
expect(capture.idleExpired).toBe(true);
|
|
592
|
+
expect(helper.detached).toBe(1);
|
|
593
|
+
expect(helper.expressions.filter((value) => value === "stopCapture()")).toHaveLength(1);
|
|
594
|
+
} finally {
|
|
595
|
+
blocked.resolve();
|
|
596
|
+
await capture.stop();
|
|
597
|
+
vi.useRealTimers();
|
|
598
|
+
}
|
|
599
|
+
});
|