@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,991 @@
|
|
|
1
|
+
/** Deliberately defer React commits to exercise canvas paint and RPC replacement windows. */
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import type {
|
|
4
|
+
BrowserVideoPacket,
|
|
5
|
+
BrowserVideoReadInput,
|
|
6
|
+
BrowserVideoReadReply,
|
|
7
|
+
} from "../shared/browser-video";
|
|
8
|
+
import type { BrowserVideoDecodeEnvironment } from "./browser-video-decoder";
|
|
9
|
+
|
|
10
|
+
interface HookSlot {
|
|
11
|
+
value?: unknown;
|
|
12
|
+
current?: unknown;
|
|
13
|
+
dependencies?: unknown[];
|
|
14
|
+
cleanup?: (() => void) | undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const harness = vi.hoisted(() => ({
|
|
18
|
+
slots: [] as HookSlot[],
|
|
19
|
+
index: 0,
|
|
20
|
+
effects: [] as (() => void)[],
|
|
21
|
+
layouts: [] as (() => void)[],
|
|
22
|
+
environment: null as unknown,
|
|
23
|
+
reads: [] as {
|
|
24
|
+
input: BrowserVideoReadInput;
|
|
25
|
+
resolve(value: BrowserVideoReadReply): void;
|
|
26
|
+
reject(error: unknown): void;
|
|
27
|
+
}[],
|
|
28
|
+
read: null as unknown,
|
|
29
|
+
documentActive: true,
|
|
30
|
+
appState: "active",
|
|
31
|
+
visibility: null as null | ((active: boolean) => void),
|
|
32
|
+
application: null as null | ((state: string) => void),
|
|
33
|
+
disposals: 0,
|
|
34
|
+
decoderCloses: 0,
|
|
35
|
+
}));
|
|
36
|
+
const imageQuery = vi.hoisted(() => ({
|
|
37
|
+
options: null as null | { enabled: boolean; queryFn(): Promise<unknown> },
|
|
38
|
+
refetch: vi.fn(() => Promise.resolve()),
|
|
39
|
+
}));
|
|
40
|
+
vi.mock("@tanstack/react-query", () => ({
|
|
41
|
+
CancelledError: class extends Error {},
|
|
42
|
+
useQuery(options: { enabled: boolean; queryFn(): Promise<unknown> }) {
|
|
43
|
+
imageQuery.options = options;
|
|
44
|
+
return { refetch: imageQuery.refetch };
|
|
45
|
+
},
|
|
46
|
+
}));
|
|
47
|
+
vi.mock("react", () => {
|
|
48
|
+
const effect = (
|
|
49
|
+
operation: () => (() => void) | void,
|
|
50
|
+
dependencies: unknown[],
|
|
51
|
+
layout: boolean,
|
|
52
|
+
) => {
|
|
53
|
+
const index = harness.index++;
|
|
54
|
+
const previous = harness.slots[index];
|
|
55
|
+
if (previous && dependencies.every((value, i) => value === previous.dependencies?.[i])) return;
|
|
56
|
+
const slot: HookSlot = { dependencies, cleanup: previous?.cleanup };
|
|
57
|
+
harness.slots[index] = slot;
|
|
58
|
+
(layout ? harness.layouts : harness.effects).push(() => {
|
|
59
|
+
slot.cleanup?.();
|
|
60
|
+
slot.cleanup = operation() ?? undefined;
|
|
61
|
+
});
|
|
62
|
+
};
|
|
63
|
+
return {
|
|
64
|
+
useState(initial: unknown) {
|
|
65
|
+
const index = harness.index++;
|
|
66
|
+
if (!harness.slots[index]) {
|
|
67
|
+
harness.slots[index] = {
|
|
68
|
+
value: typeof initial === "function" ? (initial as () => unknown)() : initial,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const slot = harness.slots[index]!;
|
|
72
|
+
return [
|
|
73
|
+
slot.value,
|
|
74
|
+
(value: unknown) => {
|
|
75
|
+
slot.value = value;
|
|
76
|
+
},
|
|
77
|
+
];
|
|
78
|
+
},
|
|
79
|
+
useRef(initial: unknown) {
|
|
80
|
+
const index = harness.index++;
|
|
81
|
+
if (!harness.slots[index]) harness.slots[index] = { current: initial };
|
|
82
|
+
return harness.slots[index];
|
|
83
|
+
},
|
|
84
|
+
useCallback(value: unknown) {
|
|
85
|
+
harness.index++;
|
|
86
|
+
return value;
|
|
87
|
+
},
|
|
88
|
+
useEffect: (operation: () => void, dependencies: unknown[]) =>
|
|
89
|
+
effect(operation, dependencies, false),
|
|
90
|
+
useLayoutEffect: (operation: () => void, dependencies: unknown[]) =>
|
|
91
|
+
effect(operation, dependencies, true),
|
|
92
|
+
};
|
|
93
|
+
});
|
|
94
|
+
vi.mock("@getpaseo/plugin/client", () => ({ useRpc: () => harness.read }));
|
|
95
|
+
vi.mock("react-native", () => ({
|
|
96
|
+
AppState: {
|
|
97
|
+
get currentState() {
|
|
98
|
+
return harness.appState;
|
|
99
|
+
},
|
|
100
|
+
addEventListener(_kind: string, callback: (state: string) => void) {
|
|
101
|
+
harness.application = callback;
|
|
102
|
+
return {
|
|
103
|
+
remove() {
|
|
104
|
+
harness.application = null;
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
}));
|
|
110
|
+
vi.mock("./browser-video-surface", () => ({
|
|
111
|
+
bindBrowserVideoVisibility(_node: unknown, callback: (active: boolean) => void) {
|
|
112
|
+
harness.visibility = callback;
|
|
113
|
+
callback(harness.documentActive);
|
|
114
|
+
return () => {
|
|
115
|
+
harness.visibility = null;
|
|
116
|
+
};
|
|
117
|
+
},
|
|
118
|
+
supportsBrowserVideo: () => true,
|
|
119
|
+
createBrowserVideoEnvironment: () => ({
|
|
120
|
+
environment: harness.environment,
|
|
121
|
+
dispose() {
|
|
122
|
+
harness.disposals += 1;
|
|
123
|
+
},
|
|
124
|
+
}),
|
|
125
|
+
}));
|
|
126
|
+
|
|
127
|
+
import { useBrowserVideo } from "./use-browser-video";
|
|
128
|
+
|
|
129
|
+
const packet = (sequence: number): BrowserVideoPacket => ({
|
|
130
|
+
streamId: "s".repeat(32),
|
|
131
|
+
captureGeneration: 1,
|
|
132
|
+
sequence,
|
|
133
|
+
timestampUs: sequence * 1000,
|
|
134
|
+
type: sequence === 1 ? "key" : "delta",
|
|
135
|
+
codec: "vp8",
|
|
136
|
+
width: 1280,
|
|
137
|
+
height: 800,
|
|
138
|
+
dataBase64: "AA==",
|
|
139
|
+
capturedAt: new Date().toISOString(),
|
|
140
|
+
frame: {
|
|
141
|
+
frameId: String(sequence).padStart(32, "x"),
|
|
142
|
+
sessionId: "a".repeat(32),
|
|
143
|
+
width: 1280,
|
|
144
|
+
height: 800,
|
|
145
|
+
capturedAt: new Date().toISOString(),
|
|
146
|
+
runtimeId: "r".repeat(32),
|
|
147
|
+
captureEpoch: 1,
|
|
148
|
+
navigationGeneration: 1,
|
|
149
|
+
viewportGeneration: 1,
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
const drain = async () => {
|
|
153
|
+
for (let i = 0; i < 15; i++) await Promise.resolve();
|
|
154
|
+
};
|
|
155
|
+
function setup(retainDocumentDisplay = false) {
|
|
156
|
+
let draw: (() => void) | null = null;
|
|
157
|
+
const presented: number[] = [];
|
|
158
|
+
const drawn: number[] = [];
|
|
159
|
+
let quality: "high" | "low" = "high";
|
|
160
|
+
let viewerToken = "v".repeat(32);
|
|
161
|
+
let current = true;
|
|
162
|
+
let mutationEpoch = 1;
|
|
163
|
+
const identity = {
|
|
164
|
+
sessionId: "a".repeat(32),
|
|
165
|
+
runtimeId: "r".repeat(32),
|
|
166
|
+
captureEpoch: 1,
|
|
167
|
+
navigationGeneration: 1,
|
|
168
|
+
viewportGeneration: 1,
|
|
169
|
+
};
|
|
170
|
+
let active = true;
|
|
171
|
+
let bitrate: 2_000_000 | 5_000_000 | 12_000_000 | 24_000_000 = 12_000_000;
|
|
172
|
+
let fps: 15 | 30 | 60 = 30;
|
|
173
|
+
const environment: BrowserVideoDecodeEnvironment = {
|
|
174
|
+
createDecoder(callbacks) {
|
|
175
|
+
return {
|
|
176
|
+
decodeQueueSize: 0,
|
|
177
|
+
configure() {},
|
|
178
|
+
close() {
|
|
179
|
+
harness.decoderCloses += 1;
|
|
180
|
+
},
|
|
181
|
+
decode(value) {
|
|
182
|
+
const timestamp = (value as { timestamp: number }).timestamp;
|
|
183
|
+
callbacks.output({ timestamp, displayWidth: 1280, displayHeight: 800, close() {} });
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
},
|
|
187
|
+
createChunk: (value) => value,
|
|
188
|
+
decodeBase64: () => new Uint8Array([0]),
|
|
189
|
+
scheduleDraw(operation) {
|
|
190
|
+
draw = operation;
|
|
191
|
+
return () => {
|
|
192
|
+
draw = null;
|
|
193
|
+
};
|
|
194
|
+
},
|
|
195
|
+
draw(frame) {
|
|
196
|
+
drawn.push(frame.timestamp);
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
harness.environment = environment;
|
|
200
|
+
harness.read = (input: BrowserVideoReadInput) =>
|
|
201
|
+
new Promise<BrowserVideoReadReply>((resolve, reject) =>
|
|
202
|
+
harness.reads.push({ input, resolve, reject }),
|
|
203
|
+
);
|
|
204
|
+
const render = () => {
|
|
205
|
+
harness.index = 0;
|
|
206
|
+
return useBrowserVideo({
|
|
207
|
+
viewerToken,
|
|
208
|
+
quality,
|
|
209
|
+
active,
|
|
210
|
+
bitrate,
|
|
211
|
+
fps,
|
|
212
|
+
epoch: () => mutationEpoch,
|
|
213
|
+
viewport: () => ({ width: 1280, height: 800 }),
|
|
214
|
+
isCurrent: (value, epoch) =>
|
|
215
|
+
current &&
|
|
216
|
+
epoch === mutationEpoch &&
|
|
217
|
+
Object.entries(identity).every(
|
|
218
|
+
([key, expected]) => value.frame[key as keyof typeof identity] === expected,
|
|
219
|
+
),
|
|
220
|
+
...(retainDocumentDisplay
|
|
221
|
+
? {
|
|
222
|
+
isDisplayCurrent: (value: BrowserVideoPacket) =>
|
|
223
|
+
current &&
|
|
224
|
+
Object.entries(identity).every(
|
|
225
|
+
([key, expected]) =>
|
|
226
|
+
key === "navigationGeneration" ||
|
|
227
|
+
value.frame[key as keyof typeof identity] === expected,
|
|
228
|
+
),
|
|
229
|
+
}
|
|
230
|
+
: {}),
|
|
231
|
+
acceptState: () => true,
|
|
232
|
+
onPresented(value) {
|
|
233
|
+
presented.push(value.sequence);
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
};
|
|
237
|
+
const commit = () => {
|
|
238
|
+
for (const run of harness.layouts.splice(0)) run();
|
|
239
|
+
for (const run of harness.effects.splice(0)) run();
|
|
240
|
+
};
|
|
241
|
+
let result = render();
|
|
242
|
+
commit();
|
|
243
|
+
result.canvasRef({});
|
|
244
|
+
result = render();
|
|
245
|
+
commit();
|
|
246
|
+
return {
|
|
247
|
+
render,
|
|
248
|
+
commit,
|
|
249
|
+
drawn,
|
|
250
|
+
presented,
|
|
251
|
+
encoder(nextBitrate: typeof bitrate, nextFps: typeof fps) {
|
|
252
|
+
bitrate = nextBitrate;
|
|
253
|
+
fps = nextFps;
|
|
254
|
+
},
|
|
255
|
+
active(value: boolean) {
|
|
256
|
+
active = value;
|
|
257
|
+
},
|
|
258
|
+
token(value: string) {
|
|
259
|
+
viewerToken = value;
|
|
260
|
+
},
|
|
261
|
+
advanceEpoch() {
|
|
262
|
+
mutationEpoch++;
|
|
263
|
+
},
|
|
264
|
+
identity(field: keyof typeof identity, value: string | number) {
|
|
265
|
+
Object.assign(identity, { [field]: value });
|
|
266
|
+
},
|
|
267
|
+
obsolete() {
|
|
268
|
+
current = false;
|
|
269
|
+
},
|
|
270
|
+
quality(value: "high" | "low") {
|
|
271
|
+
quality = value;
|
|
272
|
+
},
|
|
273
|
+
reply(index: number, sequence: number, keyFrame = sequence === 1) {
|
|
274
|
+
harness.reads[index]!.resolve({
|
|
275
|
+
status: "ready",
|
|
276
|
+
streamId: "s".repeat(32),
|
|
277
|
+
state: {},
|
|
278
|
+
packets: [{ ...packet(sequence), type: keyFrame ? "key" : "delta" }],
|
|
279
|
+
} as BrowserVideoReadReply);
|
|
280
|
+
},
|
|
281
|
+
disabled(index: number) {
|
|
282
|
+
harness.reads[index]!.resolve({
|
|
283
|
+
status: "unsupported",
|
|
284
|
+
state: {},
|
|
285
|
+
streamId: null,
|
|
286
|
+
packets: [],
|
|
287
|
+
reasonCode: "video-disabled",
|
|
288
|
+
reason: "Encoded video is not enabled on this host",
|
|
289
|
+
} as unknown as BrowserVideoReadReply);
|
|
290
|
+
},
|
|
291
|
+
capacity(index: number) {
|
|
292
|
+
harness.reads[index]!.resolve({
|
|
293
|
+
status: "unsupported",
|
|
294
|
+
state: {},
|
|
295
|
+
streamId: null,
|
|
296
|
+
packets: [],
|
|
297
|
+
reasonCode: "encoder-capacity",
|
|
298
|
+
reason: "Three video profiles are active",
|
|
299
|
+
} as unknown as BrowserVideoReadReply);
|
|
300
|
+
},
|
|
301
|
+
paint() {
|
|
302
|
+
const operation = draw;
|
|
303
|
+
draw = null;
|
|
304
|
+
operation?.();
|
|
305
|
+
},
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
beforeEach(() => {
|
|
309
|
+
vi.useFakeTimers();
|
|
310
|
+
harness.slots = [];
|
|
311
|
+
harness.index = 0;
|
|
312
|
+
harness.effects = [];
|
|
313
|
+
harness.layouts = [];
|
|
314
|
+
harness.reads = [];
|
|
315
|
+
harness.documentActive = true;
|
|
316
|
+
harness.appState = "active";
|
|
317
|
+
harness.visibility = null;
|
|
318
|
+
harness.application = null;
|
|
319
|
+
harness.disposals = 0;
|
|
320
|
+
harness.decoderCloses = 0;
|
|
321
|
+
});
|
|
322
|
+
afterEach(() => {
|
|
323
|
+
for (const slot of harness.slots) slot?.cleanup?.();
|
|
324
|
+
vi.useRealTimers();
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
describe("video presentation lifecycle", () => {
|
|
328
|
+
it("waits for first visibility commit, then publishes each actual canvas draw synchronously", async () => {
|
|
329
|
+
const f = setup();
|
|
330
|
+
f.reply(0, 1);
|
|
331
|
+
await drain();
|
|
332
|
+
f.paint();
|
|
333
|
+
expect(f.drawn).toEqual([1000]);
|
|
334
|
+
expect(f.presented).toEqual([]);
|
|
335
|
+
const result = f.render();
|
|
336
|
+
f.commit();
|
|
337
|
+
expect(result.frontRef.current?.sequence).toBe(1);
|
|
338
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
339
|
+
f.reply(1, 2);
|
|
340
|
+
await drain();
|
|
341
|
+
f.paint();
|
|
342
|
+
// No render/commit occurs here. Pixels and their input authority still agree.
|
|
343
|
+
expect(result.frontRef.current?.sequence).toBe(2);
|
|
344
|
+
expect(f.presented).toContain(2);
|
|
345
|
+
});
|
|
346
|
+
it("exposes fallback if the first painted frame becomes obsolete before visibility commit", async () => {
|
|
347
|
+
const f = setup();
|
|
348
|
+
f.reply(0, 1);
|
|
349
|
+
await drain();
|
|
350
|
+
f.paint();
|
|
351
|
+
f.obsolete();
|
|
352
|
+
f.render();
|
|
353
|
+
f.commit();
|
|
354
|
+
const result = f.render();
|
|
355
|
+
f.commit();
|
|
356
|
+
expect(result.front).toBeNull();
|
|
357
|
+
expect(result.frontRef.current).toBeNull();
|
|
358
|
+
expect(f.presented).toEqual([]);
|
|
359
|
+
});
|
|
360
|
+
it("waits for an uncancelable old RPC before replacing quality", async () => {
|
|
361
|
+
const f = setup();
|
|
362
|
+
expect(harness.reads).toHaveLength(1);
|
|
363
|
+
f.quality("low");
|
|
364
|
+
f.render();
|
|
365
|
+
f.commit();
|
|
366
|
+
await drain();
|
|
367
|
+
expect(harness.reads).toHaveLength(1);
|
|
368
|
+
f.reply(0, 1);
|
|
369
|
+
await drain();
|
|
370
|
+
expect(harness.reads).toHaveLength(2);
|
|
371
|
+
expect(harness.reads[1]!.input.quality).toBe("low");
|
|
372
|
+
expect(f.presented).toEqual([]);
|
|
373
|
+
});
|
|
374
|
+
it("revokes stale authority and requests fallback without erasing painted video", async () => {
|
|
375
|
+
const f = setup();
|
|
376
|
+
f.reply(0, 1);
|
|
377
|
+
await drain();
|
|
378
|
+
f.paint();
|
|
379
|
+
let result = f.render();
|
|
380
|
+
f.commit();
|
|
381
|
+
expect(result.frontRef.current).not.toBeNull();
|
|
382
|
+
await vi.advanceTimersByTimeAsync(3000);
|
|
383
|
+
expect(result.frontRef.current).toBeNull();
|
|
384
|
+
result = f.render();
|
|
385
|
+
f.commit();
|
|
386
|
+
expect(result.front?.sequence).toBe(1);
|
|
387
|
+
expect(result.fallbackRevision).toBeGreaterThan(0);
|
|
388
|
+
});
|
|
389
|
+
it("does not start a codec or request while the document is already hidden", async () => {
|
|
390
|
+
harness.documentActive = false;
|
|
391
|
+
const f = setup();
|
|
392
|
+
await drain();
|
|
393
|
+
expect(harness.reads).toHaveLength(0);
|
|
394
|
+
harness.visibility?.(true);
|
|
395
|
+
await drain();
|
|
396
|
+
expect(harness.reads).toHaveLength(1);
|
|
397
|
+
expect(harness.reads[0]!.input.requestKeyFrame).toBe(true);
|
|
398
|
+
f.reply(0, 1);
|
|
399
|
+
});
|
|
400
|
+
it("releases decoding and stops background reads; resume waits for old RPC and requests a fresh key", async () => {
|
|
401
|
+
const f = setup();
|
|
402
|
+
f.reply(0, 1);
|
|
403
|
+
await drain();
|
|
404
|
+
f.paint();
|
|
405
|
+
const result = f.render();
|
|
406
|
+
f.commit();
|
|
407
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
408
|
+
expect(harness.reads).toHaveLength(2);
|
|
409
|
+
harness.visibility?.(false);
|
|
410
|
+
expect(result.frontRef.current).toBeNull();
|
|
411
|
+
expect(harness.disposals).toBe(1);
|
|
412
|
+
expect(harness.decoderCloses).toBeGreaterThan(0);
|
|
413
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
414
|
+
expect(harness.reads).toHaveLength(2);
|
|
415
|
+
harness.visibility?.(true);
|
|
416
|
+
await drain();
|
|
417
|
+
expect(harness.reads).toHaveLength(2);
|
|
418
|
+
f.reply(1, 2);
|
|
419
|
+
await drain();
|
|
420
|
+
expect(harness.reads).toHaveLength(3);
|
|
421
|
+
expect(harness.reads[2]!.input).toMatchObject({
|
|
422
|
+
streamId: null,
|
|
423
|
+
afterSequence: 0,
|
|
424
|
+
requestKeyFrame: true,
|
|
425
|
+
});
|
|
426
|
+
f.paint();
|
|
427
|
+
expect(f.presented).not.toContain(2);
|
|
428
|
+
});
|
|
429
|
+
it("cancels pending draws on application background and refuses a late visibility commit", async () => {
|
|
430
|
+
const f = setup();
|
|
431
|
+
f.reply(0, 1);
|
|
432
|
+
await drain();
|
|
433
|
+
f.paint();
|
|
434
|
+
f.render(); // deliberately leave the visibility layout commit pending
|
|
435
|
+
harness.application?.("background");
|
|
436
|
+
f.commit();
|
|
437
|
+
expect(f.presented).toEqual([]);
|
|
438
|
+
expect(f.render().frontRef.current).toBeNull();
|
|
439
|
+
harness.visibility?.(true); // document active alone cannot override AppState
|
|
440
|
+
await drain();
|
|
441
|
+
expect(harness.reads).toHaveLength(1);
|
|
442
|
+
harness.application?.("active");
|
|
443
|
+
await drain();
|
|
444
|
+
expect(harness.reads).toHaveLength(2);
|
|
445
|
+
for (const slot of harness.slots) slot?.cleanup?.();
|
|
446
|
+
expect(harness.visibility).toBeNull();
|
|
447
|
+
expect(harness.application).toBeNull();
|
|
448
|
+
});
|
|
449
|
+
it("honors explicit panel suspension and unmount without restoring previous frame authority", async () => {
|
|
450
|
+
const f = setup();
|
|
451
|
+
f.reply(0, 1);
|
|
452
|
+
await drain();
|
|
453
|
+
f.paint();
|
|
454
|
+
let result = f.render();
|
|
455
|
+
f.commit();
|
|
456
|
+
expect(result.frontRef.current).not.toBeNull();
|
|
457
|
+
f.active(false);
|
|
458
|
+
result = f.render();
|
|
459
|
+
f.commit();
|
|
460
|
+
expect(result.frontRef.current).toBeNull();
|
|
461
|
+
expect(harness.disposals).toBe(1);
|
|
462
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
463
|
+
expect(harness.reads).toHaveLength(1);
|
|
464
|
+
f.active(true);
|
|
465
|
+
f.render();
|
|
466
|
+
f.commit();
|
|
467
|
+
await drain();
|
|
468
|
+
expect(harness.reads).toHaveLength(2);
|
|
469
|
+
expect(harness.reads[1]!.input.requestKeyFrame).toBe(true);
|
|
470
|
+
for (const slot of harness.slots) slot?.cleanup?.();
|
|
471
|
+
f.reply(1, 2);
|
|
472
|
+
await drain();
|
|
473
|
+
f.paint();
|
|
474
|
+
expect(f.presented).not.toContain(2);
|
|
475
|
+
expect(harness.disposals).toBe(2);
|
|
476
|
+
});
|
|
477
|
+
it("changes encoder settings through a fresh keyframe without overlapping the old read", async () => {
|
|
478
|
+
const f = setup();
|
|
479
|
+
expect(harness.reads[0]!.input).toMatchObject({ bitrate: 12_000_000, fps: 30 });
|
|
480
|
+
f.encoder(24_000_000, 60);
|
|
481
|
+
f.render();
|
|
482
|
+
f.commit();
|
|
483
|
+
await drain();
|
|
484
|
+
expect(harness.reads).toHaveLength(1);
|
|
485
|
+
f.reply(0, 1);
|
|
486
|
+
await drain();
|
|
487
|
+
expect(harness.reads).toHaveLength(2);
|
|
488
|
+
expect(harness.reads[1]!.input).toMatchObject({
|
|
489
|
+
bitrate: 24_000_000,
|
|
490
|
+
fps: 60,
|
|
491
|
+
streamId: null,
|
|
492
|
+
afterSequence: 0,
|
|
493
|
+
requestKeyFrame: true,
|
|
494
|
+
});
|
|
495
|
+
f.paint();
|
|
496
|
+
expect(f.presented).toEqual([]);
|
|
497
|
+
});
|
|
498
|
+
it("retries typed capacity only after2.5seconds while fallback stays visible, and clears soft error after paint", async () => {
|
|
499
|
+
const f = setup();
|
|
500
|
+
f.capacity(0);
|
|
501
|
+
await drain();
|
|
502
|
+
let result = f.render();
|
|
503
|
+
f.commit();
|
|
504
|
+
expect(result.front).toBeNull();
|
|
505
|
+
expect(result.error).not.toBeNull();
|
|
506
|
+
await vi.advanceTimersByTimeAsync(2499);
|
|
507
|
+
expect(harness.reads).toHaveLength(1);
|
|
508
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
509
|
+
expect(harness.reads).toHaveLength(2);
|
|
510
|
+
expect(harness.reads[1]!.input).toMatchObject({
|
|
511
|
+
streamId: null,
|
|
512
|
+
afterSequence: 0,
|
|
513
|
+
requestKeyFrame: true,
|
|
514
|
+
});
|
|
515
|
+
f.reply(1, 1);
|
|
516
|
+
await drain();
|
|
517
|
+
expect(f.render().error).not.toBeNull();
|
|
518
|
+
f.paint();
|
|
519
|
+
result = f.render();
|
|
520
|
+
f.commit();
|
|
521
|
+
expect(result.error).toBeNull();
|
|
522
|
+
expect(result.frontRef.current).not.toBeNull();
|
|
523
|
+
});
|
|
524
|
+
it("a host that disabled video stops reads and releases the decoder without an error", async () => {
|
|
525
|
+
const f = setup();
|
|
526
|
+
f.disabled(0);
|
|
527
|
+
await drain();
|
|
528
|
+
const result = f.render();
|
|
529
|
+
f.commit();
|
|
530
|
+
expect(result.front).toBeNull();
|
|
531
|
+
expect(result.error).toBeNull();
|
|
532
|
+
expect(harness.disposals).toBe(1);
|
|
533
|
+
await vi.advanceTimersByTimeAsync(10_000);
|
|
534
|
+
expect(harness.reads).toHaveLength(1);
|
|
535
|
+
});
|
|
536
|
+
it("hidden/unmounted viewers cancel the capacity retry timer and cannot restart background reads", async () => {
|
|
537
|
+
const f = setup();
|
|
538
|
+
f.capacity(0);
|
|
539
|
+
await drain();
|
|
540
|
+
harness.visibility?.(false);
|
|
541
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
542
|
+
expect(harness.reads).toHaveLength(1);
|
|
543
|
+
harness.visibility?.(true);
|
|
544
|
+
await drain();
|
|
545
|
+
expect(harness.reads).toHaveLength(2);
|
|
546
|
+
f.capacity(1);
|
|
547
|
+
await drain();
|
|
548
|
+
for (const slot of harness.slots) slot?.cleanup?.();
|
|
549
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
550
|
+
expect(harness.reads).toHaveLength(2);
|
|
551
|
+
});
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it("tags video viewing expiry with its exact token and ignores an obsolete late error", async () => {
|
|
555
|
+
const f = setup();
|
|
556
|
+
const expired = Object.assign(new Error("Request failed: Viewer token is invalid or expired"), {
|
|
557
|
+
code: "handler_error",
|
|
558
|
+
});
|
|
559
|
+
harness.reads[0]!.reject(expired);
|
|
560
|
+
await drain();
|
|
561
|
+
expect(f.render().error).toBe(expired);
|
|
562
|
+
expect(f.render().errorViewerToken).toBe("v".repeat(32));
|
|
563
|
+
f.token("replacement".repeat(3));
|
|
564
|
+
f.render();
|
|
565
|
+
f.commit();
|
|
566
|
+
await drain();
|
|
567
|
+
expect(f.render().error).toBeNull();
|
|
568
|
+
expect(f.render().errorViewerToken).toBeNull();
|
|
569
|
+
const pending = harness.reads.at(-1)!;
|
|
570
|
+
f.token("new".repeat(11));
|
|
571
|
+
f.render();
|
|
572
|
+
f.commit();
|
|
573
|
+
pending.reject(expired);
|
|
574
|
+
await drain();
|
|
575
|
+
expect(f.render().error).toBeNull();
|
|
576
|
+
expect(f.render().errorViewerToken).toBeNull();
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
it("a same-source empty reset retains displayed video while synchronously revoking input", async () => {
|
|
580
|
+
const f = setup();
|
|
581
|
+
f.reply(0, 1);
|
|
582
|
+
await drain();
|
|
583
|
+
f.paint();
|
|
584
|
+
const rendered = f.render();
|
|
585
|
+
f.commit();
|
|
586
|
+
expect(rendered.frontRef.current?.sequence).toBe(1);
|
|
587
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
588
|
+
harness.reads[1]!.resolve({
|
|
589
|
+
status: "reset",
|
|
590
|
+
state: {},
|
|
591
|
+
streamId: null,
|
|
592
|
+
packets: [],
|
|
593
|
+
} as unknown as BrowserVideoReadReply);
|
|
594
|
+
await drain();
|
|
595
|
+
// The old rendered canvas can still cover the image until React commits.
|
|
596
|
+
// Its mutable authority must already be gone at this point.
|
|
597
|
+
expect(rendered.frontRef.current).toBeNull();
|
|
598
|
+
expect(f.render().front?.sequence).toBe(1);
|
|
599
|
+
f.commit();
|
|
600
|
+
expect(rendered.frontRef.current).toBeNull();
|
|
601
|
+
await vi.advanceTimersByTimeAsync(40);
|
|
602
|
+
expect(harness.reads[2]!.input.afterSequence).toBe(0);
|
|
603
|
+
expect(harness.reads[2]!.input.streamId).toBeNull();
|
|
604
|
+
expect(harness.reads[2]!.input.requestKeyFrame).toBe(true);
|
|
605
|
+
f.reply(2, 3, true);
|
|
606
|
+
await drain();
|
|
607
|
+
expect(f.render().front?.sequence).toBe(1);
|
|
608
|
+
expect(rendered.frontRef.current).toBeNull();
|
|
609
|
+
f.paint();
|
|
610
|
+
const fresh = f.render();
|
|
611
|
+
f.commit();
|
|
612
|
+
expect(fresh.frontRef.current?.sequence).toBe(3);
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
describe("bounded viewing-read recovery", () => {
|
|
616
|
+
it("recovers a timed-out read through a fresh key without replaying refresh or hiding failure before paint", async () => {
|
|
617
|
+
const f = setup();
|
|
618
|
+
const failure = new Error("Request failed: Plugin RPC timed out: shared-browser.invoke");
|
|
619
|
+
harness.reads[0]!.reject(failure);
|
|
620
|
+
await drain();
|
|
621
|
+
expect(f.render().error).toBe(failure);
|
|
622
|
+
for (let index = 0; index < 20; index++) f.render().refresh();
|
|
623
|
+
await vi.advanceTimersByTimeAsync(499);
|
|
624
|
+
expect(harness.reads).toHaveLength(1);
|
|
625
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
626
|
+
expect(harness.reads).toHaveLength(2);
|
|
627
|
+
expect(harness.reads[1]!.input).toMatchObject({
|
|
628
|
+
afterSequence: 0,
|
|
629
|
+
streamId: null,
|
|
630
|
+
requestKeyFrame: true,
|
|
631
|
+
});
|
|
632
|
+
f.reply(1, 10, true);
|
|
633
|
+
await drain();
|
|
634
|
+
expect(f.render().error).toBe(failure);
|
|
635
|
+
f.paint();
|
|
636
|
+
const result = f.render();
|
|
637
|
+
f.commit();
|
|
638
|
+
expect(result.error).toBeNull();
|
|
639
|
+
expect(result.frontRef.current?.sequence).toBe(10);
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
it("exhausts retries despite ordinary refresh, then coalesces explicit viewing retry", async () => {
|
|
643
|
+
const f = setup();
|
|
644
|
+
for (const [index, delay] of [500, 1500, 3000].entries()) {
|
|
645
|
+
harness.reads[index]!.reject(new Error("Transient source read failed"));
|
|
646
|
+
await drain();
|
|
647
|
+
await vi.advanceTimersByTimeAsync(delay);
|
|
648
|
+
}
|
|
649
|
+
const failure = new Error("Source remains unavailable");
|
|
650
|
+
harness.reads[3]!.reject(failure);
|
|
651
|
+
await drain();
|
|
652
|
+
expect(f.render().error).toBe(failure);
|
|
653
|
+
for (let index = 0; index < 20; index++) f.render().refresh();
|
|
654
|
+
expect(harness.reads).toHaveLength(4);
|
|
655
|
+
await vi.advanceTimersByTimeAsync(60_000);
|
|
656
|
+
expect(harness.reads).toHaveLength(4);
|
|
657
|
+
const result = f.render();
|
|
658
|
+
for (let index = 0; index < 20; index++) result.refresh();
|
|
659
|
+
await drain();
|
|
660
|
+
expect(harness.reads).toHaveLength(4);
|
|
661
|
+
for (let index = 0; index < 20; index++) result.retry();
|
|
662
|
+
await drain();
|
|
663
|
+
expect(harness.reads).toHaveLength(5);
|
|
664
|
+
expect(f.render().error).toBe(failure);
|
|
665
|
+
f.reply(4, 20, true);
|
|
666
|
+
await drain();
|
|
667
|
+
f.paint();
|
|
668
|
+
expect(f.render().error).toBeNull();
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
it("leaves exact viewer expiry to the existing viewing-only reattachment policy", async () => {
|
|
672
|
+
const f = setup();
|
|
673
|
+
const expired = {
|
|
674
|
+
code: "handler_error",
|
|
675
|
+
message:
|
|
676
|
+
"Request failed: Viewer token is invalid or expired requestType=plugin.rpc.invoke.request code=handler_error",
|
|
677
|
+
};
|
|
678
|
+
harness.reads[0]!.reject(expired);
|
|
679
|
+
await drain();
|
|
680
|
+
await vi.advanceTimersByTimeAsync(60_000);
|
|
681
|
+
f.render().refresh();
|
|
682
|
+
f.render().retry();
|
|
683
|
+
await drain();
|
|
684
|
+
expect(harness.reads).toHaveLength(1);
|
|
685
|
+
expect(f.render().error).toBe(expired);
|
|
686
|
+
expect(f.render().errorViewerToken).toBe("v".repeat(32));
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
it("suspension cancels retry waits and resumes only a new single-flight key read", async () => {
|
|
690
|
+
setup();
|
|
691
|
+
harness.reads[0]!.reject(new Error("Transient source failure"));
|
|
692
|
+
await drain();
|
|
693
|
+
harness.visibility!(false);
|
|
694
|
+
await vi.advanceTimersByTimeAsync(10_000);
|
|
695
|
+
expect(harness.reads).toHaveLength(1);
|
|
696
|
+
harness.visibility!(true);
|
|
697
|
+
await drain();
|
|
698
|
+
expect(harness.reads).toHaveLength(2);
|
|
699
|
+
expect(harness.reads[1]!.input.requestKeyFrame).toBe(true);
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
it("discarded old-viewer failures cannot retry or replace the new viewer error state", async () => {
|
|
703
|
+
const f = setup();
|
|
704
|
+
f.token("n".repeat(32));
|
|
705
|
+
f.render();
|
|
706
|
+
f.commit();
|
|
707
|
+
harness.reads[0]!.reject(new Error("Old viewer read failed late"));
|
|
708
|
+
await drain();
|
|
709
|
+
expect(harness.reads).toHaveLength(2);
|
|
710
|
+
expect(harness.reads[1]!.input.viewerToken).toBe("n".repeat(32));
|
|
711
|
+
await vi.advanceTimersByTimeAsync(10_000);
|
|
712
|
+
expect(harness.reads).toHaveLength(2);
|
|
713
|
+
expect(f.render().error).toBeNull();
|
|
714
|
+
});
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
/** Exhaust one incarnation without painting, leaving no automatic read retry. */
|
|
718
|
+
async function exhaustViewingReader() {
|
|
719
|
+
for (const [index, delay] of [500, 1500, 3000].entries()) {
|
|
720
|
+
harness.reads[index]!.reject(new Error("Read remains unavailable"));
|
|
721
|
+
await drain();
|
|
722
|
+
await vi.advanceTimersByTimeAsync(delay);
|
|
723
|
+
}
|
|
724
|
+
harness.reads[3]!.reject(new Error("Read budget exhausted"));
|
|
725
|
+
await drain();
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
it("retains one explicit navigation retry until the remaining cooldown boundary", async () => {
|
|
729
|
+
const f = setup();
|
|
730
|
+
await exhaustViewingReader();
|
|
731
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
732
|
+
for (let index = 0; index < 20; index++) f.render().retry();
|
|
733
|
+
for (let index = 0; index < 20; index++) f.render().refresh();
|
|
734
|
+
expect(harness.reads).toHaveLength(4);
|
|
735
|
+
await vi.advanceTimersByTimeAsync(1999);
|
|
736
|
+
expect(harness.reads).toHaveLength(4);
|
|
737
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
738
|
+
expect(harness.reads).toHaveLength(5);
|
|
739
|
+
expect(harness.reads[4]!.input).toMatchObject({
|
|
740
|
+
afterSequence: 0,
|
|
741
|
+
streamId: null,
|
|
742
|
+
requestKeyFrame: true,
|
|
743
|
+
});
|
|
744
|
+
await vi.advanceTimersByTimeAsync(10_000);
|
|
745
|
+
expect(harness.reads).toHaveLength(5);
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
it.each(["suspend", "unmount", "viewer replacement"])(
|
|
749
|
+
"cancels a queued explicit cooldown retry on %s",
|
|
750
|
+
async (reason) => {
|
|
751
|
+
const f = setup();
|
|
752
|
+
await exhaustViewingReader();
|
|
753
|
+
const retained = f.render().retry;
|
|
754
|
+
retained();
|
|
755
|
+
if (reason === "suspend") {
|
|
756
|
+
harness.visibility!(false);
|
|
757
|
+
} else if (reason === "unmount") {
|
|
758
|
+
for (const slot of harness.slots) slot?.cleanup?.();
|
|
759
|
+
} else {
|
|
760
|
+
f.token("n".repeat(32));
|
|
761
|
+
f.render();
|
|
762
|
+
f.commit();
|
|
763
|
+
await drain();
|
|
764
|
+
}
|
|
765
|
+
await vi.advanceTimersByTimeAsync(10_000);
|
|
766
|
+
expect(harness.reads).toHaveLength(reason === "viewer replacement" ? 5 : 4);
|
|
767
|
+
if (reason === "viewer replacement") {
|
|
768
|
+
expect(harness.reads[4]!.input.viewerToken).toBe("n".repeat(32));
|
|
769
|
+
}
|
|
770
|
+
},
|
|
771
|
+
);
|
|
772
|
+
|
|
773
|
+
it("a local input epoch revokes video input without exposing the underlying JPEG", async () => {
|
|
774
|
+
const f = setup();
|
|
775
|
+
f.reply(0, 1);
|
|
776
|
+
await drain();
|
|
777
|
+
f.paint();
|
|
778
|
+
f.render();
|
|
779
|
+
f.commit();
|
|
780
|
+
f.advanceEpoch();
|
|
781
|
+
const held = f.render();
|
|
782
|
+
f.commit();
|
|
783
|
+
expect(held.front?.sequence).toBe(1);
|
|
784
|
+
expect(held.frontRef.current).toBeNull();
|
|
785
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
786
|
+
f.reply(1, 2, true);
|
|
787
|
+
await drain();
|
|
788
|
+
expect(held.frontRef.current).toBeNull();
|
|
789
|
+
f.paint();
|
|
790
|
+
const fresh = f.render();
|
|
791
|
+
f.commit();
|
|
792
|
+
expect(fresh.frontRef.current?.sequence).toBe(2);
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
it("an empty reset fences a video paint whose React visibility commit is still pending", async () => {
|
|
796
|
+
const f = setup();
|
|
797
|
+
f.reply(0, 1);
|
|
798
|
+
await drain();
|
|
799
|
+
f.paint();
|
|
800
|
+
f.render();
|
|
801
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
802
|
+
harness.reads[1]!.resolve({
|
|
803
|
+
status: "reset",
|
|
804
|
+
state: {},
|
|
805
|
+
streamId: null,
|
|
806
|
+
packets: [],
|
|
807
|
+
} as unknown as BrowserVideoReadReply);
|
|
808
|
+
await drain();
|
|
809
|
+
f.commit();
|
|
810
|
+
const held = f.render();
|
|
811
|
+
expect(held.front?.sequence).toBe(1);
|
|
812
|
+
expect(held.frontRef.current).toBeNull();
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
it.each([
|
|
816
|
+
["sessionId", "replacement"],
|
|
817
|
+
["runtimeId", "replacement"],
|
|
818
|
+
["captureEpoch", 2],
|
|
819
|
+
["navigationGeneration", 2],
|
|
820
|
+
["viewportGeneration", 2],
|
|
821
|
+
] as const)("does not retain old video across changed %s", async (field, value) => {
|
|
822
|
+
const f = setup();
|
|
823
|
+
f.reply(0, 1);
|
|
824
|
+
await drain();
|
|
825
|
+
f.paint();
|
|
826
|
+
f.render();
|
|
827
|
+
f.commit();
|
|
828
|
+
f.identity(field, value);
|
|
829
|
+
f.render();
|
|
830
|
+
f.commit();
|
|
831
|
+
expect(f.render().front).toBeNull();
|
|
832
|
+
expect(f.render().frontRef.current).toBeNull();
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
it("stall fallback retains painted video until the exact newly decoded image handoff", async () => {
|
|
836
|
+
const f = setup();
|
|
837
|
+
f.reply(0, 1);
|
|
838
|
+
await drain();
|
|
839
|
+
f.paint();
|
|
840
|
+
f.render();
|
|
841
|
+
f.commit();
|
|
842
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
843
|
+
harness.reads[1]!.resolve({
|
|
844
|
+
status: "reset",
|
|
845
|
+
state: {},
|
|
846
|
+
streamId: null,
|
|
847
|
+
packets: [],
|
|
848
|
+
} as unknown as BrowserVideoReadReply);
|
|
849
|
+
await drain();
|
|
850
|
+
expect(f.render().front?.sequence).toBe(1);
|
|
851
|
+
expect(f.render().frontRef.current).toBeNull();
|
|
852
|
+
await vi.advanceTimersByTimeAsync(3000);
|
|
853
|
+
const held = f.render();
|
|
854
|
+
f.commit();
|
|
855
|
+
expect(held.front?.sequence).toBe(1);
|
|
856
|
+
expect(held.frontRef.current).toBeNull();
|
|
857
|
+
expect(held.fallbackRevision).toBeGreaterThan(0);
|
|
858
|
+
held.completeFallback(held.fallbackRevision + 1);
|
|
859
|
+
expect(f.render().front?.sequence).toBe(1);
|
|
860
|
+
held.completeFallback(held.fallbackRevision);
|
|
861
|
+
expect(f.render().front).toBeNull();
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
it("holds navigation pixels without starting fallback or granting old document authority", async () => {
|
|
865
|
+
const f = setup(true);
|
|
866
|
+
f.reply(0, 1);
|
|
867
|
+
await drain();
|
|
868
|
+
f.paint();
|
|
869
|
+
f.render();
|
|
870
|
+
f.commit();
|
|
871
|
+
f.identity("navigationGeneration", 2);
|
|
872
|
+
let result = f.render();
|
|
873
|
+
f.commit();
|
|
874
|
+
result = f.render();
|
|
875
|
+
expect(result.front?.sequence).toBe(1);
|
|
876
|
+
expect(result.frontRef.current).toBeNull();
|
|
877
|
+
expect(result.fallbackRevision).toBe(0);
|
|
878
|
+
f.identity("runtimeId", "replacement");
|
|
879
|
+
f.render();
|
|
880
|
+
f.commit();
|
|
881
|
+
expect(f.render().front).toBeNull();
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
it("rapid local presses preserve one decoder and delta cursor while old-read paint cannot grant input", async () => {
|
|
885
|
+
const f = setup();
|
|
886
|
+
f.reply(0, 1);
|
|
887
|
+
await drain();
|
|
888
|
+
f.paint();
|
|
889
|
+
f.render();
|
|
890
|
+
f.commit();
|
|
891
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
892
|
+
const initialCloses = harness.decoderCloses;
|
|
893
|
+
for (let index = 0; index < 5; index++) {
|
|
894
|
+
f.advanceEpoch();
|
|
895
|
+
f.render();
|
|
896
|
+
f.commit();
|
|
897
|
+
f.render().refresh();
|
|
898
|
+
f.reply(index * 2 + 1, index * 2 + 2, false);
|
|
899
|
+
await drain();
|
|
900
|
+
f.paint();
|
|
901
|
+
let result = f.render();
|
|
902
|
+
f.commit();
|
|
903
|
+
expect(result.front?.sequence).toBe(index * 2 + 2);
|
|
904
|
+
expect(result.frontRef.current).toBeNull();
|
|
905
|
+
expect(f.presented).not.toContain(index * 2 + 2);
|
|
906
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
907
|
+
const nextRead = harness.reads[index * 2 + 2]!;
|
|
908
|
+
expect(nextRead.input.afterSequence).toBe(index * 2 + 2);
|
|
909
|
+
expect(nextRead.input.streamId).toBe("s".repeat(32));
|
|
910
|
+
expect(nextRead.input.requestKeyFrame).toBe(false);
|
|
911
|
+
f.reply(index * 2 + 2, index * 2 + 3, false);
|
|
912
|
+
await drain();
|
|
913
|
+
f.paint();
|
|
914
|
+
result = f.render();
|
|
915
|
+
f.commit();
|
|
916
|
+
expect(result.frontRef.current?.sequence).toBe(index * 2 + 3);
|
|
917
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
918
|
+
}
|
|
919
|
+
expect(harness.decoderCloses).toBe(initialCloses);
|
|
920
|
+
expect(f.drawn).toHaveLength(11);
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
it("stops hidden-panel JPEG requests, including a queued refresh follow-up", async () => {
|
|
924
|
+
const { useBrowserImageCapture } = await import("./use-browser-image-capture");
|
|
925
|
+
const capture = vi.fn();
|
|
926
|
+
let active = true;
|
|
927
|
+
const render = () => {
|
|
928
|
+
harness.index = 0;
|
|
929
|
+
return useBrowserImageCapture({
|
|
930
|
+
active,
|
|
931
|
+
viewerToken: "viewer",
|
|
932
|
+
quality: "high",
|
|
933
|
+
activeInput: false,
|
|
934
|
+
videoOwnsPresentation: false,
|
|
935
|
+
hasVideoPresentation: () => false,
|
|
936
|
+
refreshVideo: vi.fn(),
|
|
937
|
+
mutationEpoch: () => 0,
|
|
938
|
+
knownFrame: () => null,
|
|
939
|
+
capture,
|
|
940
|
+
});
|
|
941
|
+
};
|
|
942
|
+
const shown = render();
|
|
943
|
+
expect(imageQuery.options?.enabled).toBe(true);
|
|
944
|
+
const queuedCapture = imageQuery.options!.queryFn;
|
|
945
|
+
active = false;
|
|
946
|
+
const hidden = render();
|
|
947
|
+
expect(imageQuery.options?.enabled).toBe(false);
|
|
948
|
+
await expect(queuedCapture()).rejects.toBeDefined();
|
|
949
|
+
hidden.refreshCapture();
|
|
950
|
+
hidden.retryFrameCapture();
|
|
951
|
+
shown.refreshCapture();
|
|
952
|
+
expect(capture).not.toHaveBeenCalled();
|
|
953
|
+
expect(imageQuery.refetch).not.toHaveBeenCalled();
|
|
954
|
+
active = true;
|
|
955
|
+
render().refreshCapture();
|
|
956
|
+
expect(imageQuery.refetch).toHaveBeenCalledOnce();
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
it.each([false, true])("keeps a healthy quiet video after local input: %s", async (afterInput) => {
|
|
960
|
+
const f = setup();
|
|
961
|
+
f.reply(0, 1);
|
|
962
|
+
await drain();
|
|
963
|
+
f.paint();
|
|
964
|
+
f.render();
|
|
965
|
+
f.commit();
|
|
966
|
+
if (afterInput) {
|
|
967
|
+
f.advanceEpoch();
|
|
968
|
+
f.render();
|
|
969
|
+
f.commit();
|
|
970
|
+
}
|
|
971
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
972
|
+
for (let index = 1; index <= 8; index++) {
|
|
973
|
+
harness.reads[index]!.resolve({
|
|
974
|
+
status: "waiting",
|
|
975
|
+
streamId: "s".repeat(32),
|
|
976
|
+
state: {},
|
|
977
|
+
packets: [],
|
|
978
|
+
} as unknown as BrowserVideoReadReply);
|
|
979
|
+
await drain();
|
|
980
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
981
|
+
}
|
|
982
|
+
expect(f.render().fallbackRevision).toBe(0);
|
|
983
|
+
if (afterInput) {
|
|
984
|
+
expect(f.render().frontRef.current).toBeNull();
|
|
985
|
+
} else {
|
|
986
|
+
expect(f.render().frontRef.current?.sequence).toBe(1);
|
|
987
|
+
}
|
|
988
|
+
// A genuinely stuck next read still invokes the existing fallback watchdog.
|
|
989
|
+
await vi.advanceTimersByTimeAsync(3000);
|
|
990
|
+
expect(f.render().fallbackRevision).toBeGreaterThan(0);
|
|
991
|
+
});
|