@omercnet/paseo-shared-browser 1.2.2-next.232.1 → 1.2.2-next.239.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/npm-shrinkwrap.json +7 -7
- 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,497 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exact-tab native video acquisition, independently consumed from the input queue.
|
|
3
|
+
* The helper is a trusted hidden extension page, controlled only through its own
|
|
4
|
+
* CDP session. Receipts preserve native source age, never binding-arrival freshness.
|
|
5
|
+
* Stop/replacement revokes pending starts, callbacks, reads, and every encoder.
|
|
6
|
+
*/
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
import { performance } from "node:perf_hooks";
|
|
9
|
+
import {
|
|
10
|
+
type NativeVideoPacket,
|
|
11
|
+
VIDEO_MAX_WAIT_MS,
|
|
12
|
+
VIDEO_SOURCE_CLOCK_TOLERANCE_MS,
|
|
13
|
+
} from "../shared/browser-video";
|
|
14
|
+
import {
|
|
15
|
+
resolveVideoEncoderSettings,
|
|
16
|
+
VIDEO_ENCODER_IDLE_MS,
|
|
17
|
+
VIDEO_ENCODER_LIMIT,
|
|
18
|
+
type VideoEncoderSettings,
|
|
19
|
+
videoEncoderKey,
|
|
20
|
+
} from "../shared/video-settings";
|
|
21
|
+
import { attachToTarget, type CdpConnection, type CdpSession, CdpUnavailableError } from "./cdp";
|
|
22
|
+
import {
|
|
23
|
+
createNativeVideoBuffer,
|
|
24
|
+
type VideoSourceClock,
|
|
25
|
+
videoSourceTime,
|
|
26
|
+
} from "./native-video-buffer";
|
|
27
|
+
import { NATIVE_VIDEO_EXTENSION_ID } from "./native-video-extension";
|
|
28
|
+
import { type RuntimeVideoPacket, runtimeVideoPacketSchema } from "./native-video-packet";
|
|
29
|
+
import type { VideoSourceFailureCode } from "./video-source-recovery";
|
|
30
|
+
export type NativeVideoQuality = "low" | "medium" | "high";
|
|
31
|
+
export interface NativeVideoRead {
|
|
32
|
+
status: "ready" | "waiting" | "reset" | "unsupported";
|
|
33
|
+
streamId: string | null;
|
|
34
|
+
packets: RuntimeVideoPacket[];
|
|
35
|
+
reason?: string;
|
|
36
|
+
reasonCode?: "encoder-capacity" | "video-disabled";
|
|
37
|
+
}
|
|
38
|
+
interface EncoderState {
|
|
39
|
+
streamId: string;
|
|
40
|
+
buffer: ReturnType<typeof createNativeVideoBuffer>;
|
|
41
|
+
ready: Promise<void>;
|
|
42
|
+
failure: string | null;
|
|
43
|
+
lastRequestedAt: number;
|
|
44
|
+
activeReads: number;
|
|
45
|
+
/** Coalesce recovery demand while a key is pending; retry only after a bounded wait. */
|
|
46
|
+
keyRequestedAt: number;
|
|
47
|
+
}
|
|
48
|
+
class VideoEncoderLimitError extends Error {}
|
|
49
|
+
interface Evaluation<T> {
|
|
50
|
+
result?: { value?: T };
|
|
51
|
+
exceptionDetails?: unknown;
|
|
52
|
+
}
|
|
53
|
+
export interface NativeVideoCaptureOptions {
|
|
54
|
+
connection: CdpConnection;
|
|
55
|
+
targetId: string;
|
|
56
|
+
width: number;
|
|
57
|
+
height: number;
|
|
58
|
+
/** Exact owning attachment, including retained emulation, must still be current. */
|
|
59
|
+
isCurrent(): boolean;
|
|
60
|
+
/** Local monotonic time for cohort expiry, separate from native frame timestamps. */
|
|
61
|
+
now?(): number;
|
|
62
|
+
}
|
|
63
|
+
const HELPER_READY_EXPRESSION = `new Promise((resolve) => {
|
|
64
|
+
if (document.readyState === "complete") resolve();
|
|
65
|
+
else addEventListener("load", () => resolve(), { once: true });
|
|
66
|
+
}).then(() => typeof startCapture === "function")`;
|
|
67
|
+
|
|
68
|
+
export class NativeVideoCapture {
|
|
69
|
+
private helper: CdpSession | null = null;
|
|
70
|
+
private helperTargetId: string | null = null;
|
|
71
|
+
private readonly encoders = new Map<string, EncoderState>();
|
|
72
|
+
private encoderChanges: Promise<void> = Promise.resolve();
|
|
73
|
+
private readonly idleTimer: ReturnType<typeof setInterval>;
|
|
74
|
+
private idleMaintenance: Promise<void> | null = null;
|
|
75
|
+
private readonly waiters = new Set<() => void>();
|
|
76
|
+
private clock: VideoSourceClock | null = null;
|
|
77
|
+
private stopped = false;
|
|
78
|
+
private idleStopped = false;
|
|
79
|
+
private closing: Promise<void> | null = null;
|
|
80
|
+
private lastDemandAt: number;
|
|
81
|
+
private pendingSourceReads = 0;
|
|
82
|
+
private generation = 1;
|
|
83
|
+
private failure: string | null = null;
|
|
84
|
+
private failureCode: VideoSourceFailureCode | null = null;
|
|
85
|
+
private readonly ready: Promise<void>;
|
|
86
|
+
constructor(private readonly options: NativeVideoCaptureOptions) {
|
|
87
|
+
this.lastDemandAt = this.now();
|
|
88
|
+
this.ready = this.start();
|
|
89
|
+
this.idleTimer = setInterval(() => {
|
|
90
|
+
// Slow codec allocation/teardown must not queue a maintenance task per tick.
|
|
91
|
+
if (this.idleMaintenance || this.stopped) return;
|
|
92
|
+
const maintenance = this.serializeEncoderChange(async () => {
|
|
93
|
+
if (this.stopped) return;
|
|
94
|
+
await this.pruneIdleEncoders();
|
|
95
|
+
if (
|
|
96
|
+
!this.encoders.size &&
|
|
97
|
+
!this.pendingSourceReads &&
|
|
98
|
+
this.now() - this.lastDemandAt > VIDEO_ENCODER_IDLE_MS
|
|
99
|
+
) {
|
|
100
|
+
// JPEG leases must not keep a 60fps native track alive without a video
|
|
101
|
+
// consumer. Runtime readVideo can reacquire through its existing fence.
|
|
102
|
+
this.idleStopped = true;
|
|
103
|
+
await this.stop();
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
this.idleMaintenance = maintenance;
|
|
107
|
+
void maintenance
|
|
108
|
+
.finally(() => {
|
|
109
|
+
if (this.idleMaintenance === maintenance) this.idleMaintenance = null;
|
|
110
|
+
})
|
|
111
|
+
.catch(() => undefined);
|
|
112
|
+
}, 1_000);
|
|
113
|
+
this.idleTimer.unref();
|
|
114
|
+
// Reads expose a safe unsupported reason; an abandoned start must not reject globally.
|
|
115
|
+
void this.ready.catch(() => {
|
|
116
|
+
this.recordSourceFailure("startup");
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
/** Source faults carry only fixed private classifications and safe reasons.
|
|
120
|
+
* A proven pixel mismatch cannot become retryable because a later cleanup,
|
|
121
|
+
* startup or reset error arrives after its original failure notification. */
|
|
122
|
+
private recordSourceFailure(code: VideoSourceFailureCode): void {
|
|
123
|
+
if (this.failureCode === "source-dimensions") {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
this.failureCode = code;
|
|
127
|
+
const reasons: Record<VideoSourceFailureCode, string> = {
|
|
128
|
+
startup: "Native tab video is unavailable",
|
|
129
|
+
"source-stopped": "Native tab video stopped",
|
|
130
|
+
"source-reset": "Native tab video reset failed",
|
|
131
|
+
"source-dimensions": "Native video dimensions do not match the configured viewport",
|
|
132
|
+
};
|
|
133
|
+
this.failure = reasons[code];
|
|
134
|
+
this.wake();
|
|
135
|
+
}
|
|
136
|
+
private assertCurrent(): void {
|
|
137
|
+
if (this.stopped || !this.options.connection.isOpen || !this.options.isCurrent()) {
|
|
138
|
+
throw new CdpUnavailableError("Native video attachment changed");
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
private async evaluate<T>(expression: string): Promise<T> {
|
|
142
|
+
this.assertCurrent();
|
|
143
|
+
const helper = this.helper;
|
|
144
|
+
if (!helper) throw new CdpUnavailableError("Native video helper is unavailable");
|
|
145
|
+
const result = await helper.send<Evaluation<T>>("Runtime.evaluate", {
|
|
146
|
+
expression,
|
|
147
|
+
awaitPromise: true,
|
|
148
|
+
returnByValue: true,
|
|
149
|
+
});
|
|
150
|
+
this.assertCurrent();
|
|
151
|
+
if (result.exceptionDetails) throw new Error("Native video helper rejected acquisition");
|
|
152
|
+
return result.result?.value as T;
|
|
153
|
+
}
|
|
154
|
+
private async start(): Promise<void> {
|
|
155
|
+
try {
|
|
156
|
+
this.assertCurrent();
|
|
157
|
+
const created = await this.options.connection.send<{ targetId: string }>(
|
|
158
|
+
"Target.createTarget",
|
|
159
|
+
{
|
|
160
|
+
url: `chrome-extension://${NATIVE_VIDEO_EXTENSION_ID}/recorder.html`,
|
|
161
|
+
background: true,
|
|
162
|
+
hidden: true,
|
|
163
|
+
},
|
|
164
|
+
{ mutation: true },
|
|
165
|
+
);
|
|
166
|
+
this.helperTargetId = created.targetId;
|
|
167
|
+
this.assertCurrent();
|
|
168
|
+
this.helper = await attachToTarget(this.options.connection, created.targetId);
|
|
169
|
+
this.assertCurrent();
|
|
170
|
+
await this.helper.send("Runtime.enable");
|
|
171
|
+
await this.helper.send("Runtime.addBinding", { name: "nativeVideoPacket" });
|
|
172
|
+
await this.helper.send("Performance.enable");
|
|
173
|
+
this.helper.on("Runtime.bindingCalled", this.onPacket);
|
|
174
|
+
const before = performance.now();
|
|
175
|
+
const metrics = await this.helper.send<{ metrics: { name: string; value: number }[] }>(
|
|
176
|
+
"Performance.getMetrics",
|
|
177
|
+
);
|
|
178
|
+
const after = performance.now();
|
|
179
|
+
const timestamp = metrics.metrics.find((metric) => metric.name === "Timestamp")?.value;
|
|
180
|
+
if (timestamp === undefined || after - before > VIDEO_SOURCE_CLOCK_TOLERANCE_MS * 2)
|
|
181
|
+
throw new Error("Native video source clock is unavailable");
|
|
182
|
+
this.clock = {
|
|
183
|
+
sourceMs: timestamp * 1000,
|
|
184
|
+
monotonicMs: (before + after) / 2,
|
|
185
|
+
};
|
|
186
|
+
// The recorder is a classic script in the helper document. Runtime.evaluate can
|
|
187
|
+
// arrive while that document is still loading, before startCapture exists;
|
|
188
|
+
// wait for the document's own load event instead of failing acquisition.
|
|
189
|
+
const loaded = await this.evaluate<boolean>(HELPER_READY_EXPRESSION);
|
|
190
|
+
if (!loaded) throw new Error("Native video helper did not load");
|
|
191
|
+
await this.evaluate(
|
|
192
|
+
`startCapture(${JSON.stringify(this.options.targetId)},${this.options.width},${this.options.height},${this.generation})`,
|
|
193
|
+
);
|
|
194
|
+
} catch (error) {
|
|
195
|
+
await this.closeHelper();
|
|
196
|
+
throw error;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
private readonly onPacket = (event: { name: string; payload: string }): void => {
|
|
200
|
+
if (event.name !== "nativeVideoPacket" || event.payload.length > 3_000_000) return;
|
|
201
|
+
let value: unknown;
|
|
202
|
+
try {
|
|
203
|
+
value = JSON.parse(event.payload);
|
|
204
|
+
} catch {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (value && typeof value === "object" && "error" in value) {
|
|
208
|
+
const streamId = "streamId" in value ? value.streamId : null;
|
|
209
|
+
const encoder =
|
|
210
|
+
typeof streamId === "string"
|
|
211
|
+
? [...this.encoders.values()].find((state) => state.streamId === streamId)
|
|
212
|
+
: null;
|
|
213
|
+
if (encoder) encoder.failure = "Native video quality is unavailable";
|
|
214
|
+
else if (!streamId) {
|
|
215
|
+
const code =
|
|
216
|
+
"reasonCode" in value && value.reasonCode === "source-dimensions"
|
|
217
|
+
? "source-dimensions"
|
|
218
|
+
: "source-stopped";
|
|
219
|
+
this.recordSourceFailure(code);
|
|
220
|
+
}
|
|
221
|
+
this.wake();
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const raw = value as Partial<NativeVideoPacket>;
|
|
225
|
+
// Ack only this hidden session. Even a refused stale packet must release producer pressure.
|
|
226
|
+
if (typeof raw.streamId === "string" && Number.isSafeInteger(raw.sequence)) {
|
|
227
|
+
void this.helper
|
|
228
|
+
?.send("Runtime.evaluate", {
|
|
229
|
+
expression: `acknowledge(${JSON.stringify(raw.streamId)},${raw.sequence})`,
|
|
230
|
+
})
|
|
231
|
+
.catch(() => undefined);
|
|
232
|
+
}
|
|
233
|
+
if (
|
|
234
|
+
this.stopped ||
|
|
235
|
+
!this.options.isCurrent() ||
|
|
236
|
+
!this.clock ||
|
|
237
|
+
raw.captureGeneration !== this.generation
|
|
238
|
+
)
|
|
239
|
+
return;
|
|
240
|
+
const sourceTime =
|
|
241
|
+
typeof raw.timestampUs === "number"
|
|
242
|
+
? videoSourceTime(raw.timestampUs, this.clock, performance.now(), Date.now())
|
|
243
|
+
: null;
|
|
244
|
+
if (!sourceTime) return;
|
|
245
|
+
const packet = runtimeVideoPacketSchema.safeParse({ ...raw, ...sourceTime });
|
|
246
|
+
if (
|
|
247
|
+
!packet.success ||
|
|
248
|
+
packet.data.width !== this.options.width ||
|
|
249
|
+
packet.data.height !== this.options.height
|
|
250
|
+
)
|
|
251
|
+
return;
|
|
252
|
+
const encoder = [...this.encoders.values()].find(
|
|
253
|
+
(state) => state.streamId === packet.data.streamId,
|
|
254
|
+
);
|
|
255
|
+
if (!encoder) return;
|
|
256
|
+
const accepted = encoder.buffer.add(packet.data);
|
|
257
|
+
if (accepted && packet.data.type === "key") encoder.keyRequestedAt = -Infinity;
|
|
258
|
+
this.wake();
|
|
259
|
+
};
|
|
260
|
+
private wake(): void {
|
|
261
|
+
for (const wake of this.waiters) wake();
|
|
262
|
+
this.waiters.clear();
|
|
263
|
+
}
|
|
264
|
+
private now(): number {
|
|
265
|
+
return this.options.now?.() ?? performance.now();
|
|
266
|
+
}
|
|
267
|
+
/** Serialize allocation/eviction only. Input and packet reads retain their own lanes. */
|
|
268
|
+
private async serializeEncoderChange<T>(operation: () => Promise<T>): Promise<T> {
|
|
269
|
+
const previous = this.encoderChanges;
|
|
270
|
+
const complete = Promise.withResolvers<void>();
|
|
271
|
+
this.encoderChanges = complete.promise;
|
|
272
|
+
await previous;
|
|
273
|
+
try {
|
|
274
|
+
return await operation();
|
|
275
|
+
} finally {
|
|
276
|
+
complete.resolve();
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
/** An idle cohort owns no active reader. Removal releases helper encoder and
|
|
280
|
+
* its retained bytes without restarting the track or another live cohort. */
|
|
281
|
+
private async pruneIdleEncoders(): Promise<void> {
|
|
282
|
+
for (const [key, state] of this.encoders) {
|
|
283
|
+
if (state.activeReads || this.now() - state.lastRequestedAt <= VIDEO_ENCODER_IDLE_MS)
|
|
284
|
+
continue;
|
|
285
|
+
await this.evaluate(`removeEncoder(${JSON.stringify(key)})`);
|
|
286
|
+
if (this.encoders.get(key) === state) this.encoders.delete(key);
|
|
287
|
+
state.buffer.clear();
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
private async ensureEncoder(settings: VideoEncoderSettings): Promise<EncoderState> {
|
|
291
|
+
this.lastDemandAt = this.now();
|
|
292
|
+
await this.ready;
|
|
293
|
+
this.assertCurrent();
|
|
294
|
+
return this.serializeEncoderChange(async () => {
|
|
295
|
+
this.assertCurrent();
|
|
296
|
+
const key = videoEncoderKey(settings);
|
|
297
|
+
// Touch the requested cohort before pruning; a resuming viewer can reuse
|
|
298
|
+
// its decoder stream if no other admission has already removed that cohort.
|
|
299
|
+
let state = this.encoders.get(key);
|
|
300
|
+
if (state) state.lastRequestedAt = this.now();
|
|
301
|
+
await this.pruneIdleEncoders();
|
|
302
|
+
if (!state) {
|
|
303
|
+
if (this.encoders.size >= VIDEO_ENCODER_LIMIT) throw new VideoEncoderLimitError();
|
|
304
|
+
state = {
|
|
305
|
+
streamId: randomUUID(),
|
|
306
|
+
buffer: createNativeVideoBuffer(),
|
|
307
|
+
ready: Promise.resolve(),
|
|
308
|
+
failure: null,
|
|
309
|
+
lastRequestedAt: this.now(),
|
|
310
|
+
activeReads: 0,
|
|
311
|
+
keyRequestedAt: -Infinity,
|
|
312
|
+
};
|
|
313
|
+
this.encoders.set(key, state);
|
|
314
|
+
const created = state;
|
|
315
|
+
state.ready = this.evaluate(
|
|
316
|
+
`addEncoder(${JSON.stringify(key)},${JSON.stringify(state.streamId)},${settings.bitrate},${settings.fps})`,
|
|
317
|
+
).then(() => undefined);
|
|
318
|
+
try {
|
|
319
|
+
await state.ready;
|
|
320
|
+
} catch (error) {
|
|
321
|
+
// A timeout does not cancel helper JavaScript. Fence this exact pending
|
|
322
|
+
// allocation before admitting a replacement, without touching peers.
|
|
323
|
+
await this.evaluate(`removeEncoder(${JSON.stringify(key)})`).catch(() => undefined);
|
|
324
|
+
if (this.encoders.get(key) === created) this.encoders.delete(key);
|
|
325
|
+
throw error;
|
|
326
|
+
}
|
|
327
|
+
} else {
|
|
328
|
+
await state.ready;
|
|
329
|
+
}
|
|
330
|
+
this.assertCurrent();
|
|
331
|
+
state.lastRequestedAt = this.now();
|
|
332
|
+
this.lastDemandAt = this.now();
|
|
333
|
+
state.activeReads += 1;
|
|
334
|
+
return state;
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
/** Per-cohort recovery never resets the source, peer encoder or sequence.
|
|
338
|
+
* Reserve the request before CDP await so overlapping readers share it. */
|
|
339
|
+
private async requestRecoveryKey(key: string, state: EncoderState): Promise<boolean> {
|
|
340
|
+
const now = this.now();
|
|
341
|
+
if (now - state.keyRequestedAt < VIDEO_MAX_WAIT_MS) return false;
|
|
342
|
+
state.keyRequestedAt = now;
|
|
343
|
+
await this.evaluate(`requestKeyFrame(${JSON.stringify(key)})`);
|
|
344
|
+
return true;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* After bounded CDP initialization, frame waiting is at most 500ms. Startup uses
|
|
349
|
+
* the existing CDP command timeout. No mutation serialization, stale receipt
|
|
350
|
+
* renewal, or screenshot fallback is performed here.
|
|
351
|
+
*/
|
|
352
|
+
async read(input: {
|
|
353
|
+
quality?: NativeVideoQuality;
|
|
354
|
+
bitrate?: number;
|
|
355
|
+
fps?: number;
|
|
356
|
+
streamId?: string | null;
|
|
357
|
+
afterSequence?: number;
|
|
358
|
+
waitMs?: number;
|
|
359
|
+
requestKeyFrame?: boolean;
|
|
360
|
+
}): Promise<NativeVideoRead> {
|
|
361
|
+
let ownedState: EncoderState | null = null;
|
|
362
|
+
let sourcePinned = false;
|
|
363
|
+
try {
|
|
364
|
+
const settings = resolveVideoEncoderSettings(input);
|
|
365
|
+
// A valid requested read pins source setup itself, before ready/encoder
|
|
366
|
+
// awaits. Slow authenticated acquisition must not idle-expire underneath it.
|
|
367
|
+
this.pendingSourceReads += 1;
|
|
368
|
+
sourcePinned = true;
|
|
369
|
+
const key = videoEncoderKey(settings);
|
|
370
|
+
const state = await this.ensureEncoder(settings);
|
|
371
|
+
ownedState = state;
|
|
372
|
+
const reset =
|
|
373
|
+
input.streamId !== undefined &&
|
|
374
|
+
input.streamId !== null &&
|
|
375
|
+
input.streamId !== state.streamId;
|
|
376
|
+
const after = reset ? 0 : (input.afterSequence ?? 0);
|
|
377
|
+
const needsKey =
|
|
378
|
+
input.requestKeyFrame || state.buffer.needsKeyFrame(after, performance.now());
|
|
379
|
+
const requested = needsKey && (await this.requestRecoveryKey(key, state));
|
|
380
|
+
if (!requested) await this.evaluate(`touchEncoder(${JSON.stringify(key)})`);
|
|
381
|
+
let packets = state.buffer.read(after, performance.now());
|
|
382
|
+
const waitMs = Math.min(VIDEO_MAX_WAIT_MS, Math.max(0, input.waitMs ?? 250));
|
|
383
|
+
if (!packets.length && waitMs && !this.failure && !state.failure) {
|
|
384
|
+
await new Promise<void>((resolve) => {
|
|
385
|
+
const wake = () => {
|
|
386
|
+
clearTimeout(timer);
|
|
387
|
+
this.waiters.delete(wake);
|
|
388
|
+
resolve();
|
|
389
|
+
};
|
|
390
|
+
const timer = setTimeout(wake, waitMs);
|
|
391
|
+
this.waiters.add(wake);
|
|
392
|
+
});
|
|
393
|
+
this.assertCurrent();
|
|
394
|
+
packets = state.buffer.read(after, performance.now());
|
|
395
|
+
// A gap can arrive while the reader waits. Request recovery before
|
|
396
|
+
// returning waiting, rather than depending on the next periodic GOP.
|
|
397
|
+
if (
|
|
398
|
+
!packets.length &&
|
|
399
|
+
!state.failure &&
|
|
400
|
+
state.buffer.needsKeyFrame(after, performance.now())
|
|
401
|
+
) {
|
|
402
|
+
await this.requestRecoveryKey(key, state);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
if (this.failure || state.failure)
|
|
406
|
+
return {
|
|
407
|
+
status: "unsupported",
|
|
408
|
+
streamId: state.streamId,
|
|
409
|
+
packets: [],
|
|
410
|
+
reason: this.failure ?? state.failure ?? "Native video quality is unavailable",
|
|
411
|
+
};
|
|
412
|
+
return {
|
|
413
|
+
status: packets.length ? (reset ? "reset" : "ready") : "waiting",
|
|
414
|
+
streamId: state.streamId,
|
|
415
|
+
packets,
|
|
416
|
+
};
|
|
417
|
+
} catch (error) {
|
|
418
|
+
return {
|
|
419
|
+
status: "unsupported",
|
|
420
|
+
streamId: null,
|
|
421
|
+
packets: [],
|
|
422
|
+
...(error instanceof VideoEncoderLimitError
|
|
423
|
+
? { reasonCode: "encoder-capacity" as const }
|
|
424
|
+
: {}),
|
|
425
|
+
reason:
|
|
426
|
+
error instanceof VideoEncoderLimitError
|
|
427
|
+
? "Three video profiles are active. Using JPEG for this viewer."
|
|
428
|
+
: "Native tab video is unavailable",
|
|
429
|
+
};
|
|
430
|
+
} finally {
|
|
431
|
+
if (ownedState) ownedState.activeReads -= 1;
|
|
432
|
+
if (sourcePinned) {
|
|
433
|
+
this.pendingSourceReads -= 1;
|
|
434
|
+
this.lastDemandAt = this.now();
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
/** Document/action boundary only. Live pointer samples must not call this operation. */
|
|
439
|
+
invalidateQueuedFrames(): void {
|
|
440
|
+
if (this.stopped) return;
|
|
441
|
+
this.generation += 1;
|
|
442
|
+
const generation = this.generation;
|
|
443
|
+
const clock = this.clock;
|
|
444
|
+
const minimumTimestampUs = clock
|
|
445
|
+
? Math.ceil((clock.sourceMs + performance.now() - clock.monotonicMs) * 1000)
|
|
446
|
+
: 0;
|
|
447
|
+
for (const state of this.encoders.values()) state.buffer.clear();
|
|
448
|
+
this.wake();
|
|
449
|
+
void this.ready
|
|
450
|
+
.then(async () => {
|
|
451
|
+
if (!this.stopped) await this.evaluate(`resetCapture(${generation},${minimumTimestampUs})`);
|
|
452
|
+
})
|
|
453
|
+
.catch(() => {
|
|
454
|
+
this.recordSourceFailure("source-reset");
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
/** Private source-wide classification. Cohort errors deliberately do not
|
|
458
|
+
* appear here: recovering one encoder must not retire healthy peer encoders. */
|
|
459
|
+
get sourceFailure(): VideoSourceFailureCode | null {
|
|
460
|
+
return this.failureCode;
|
|
461
|
+
}
|
|
462
|
+
/** Automatic no-consumer retirement, independent of source-fault recovery. */
|
|
463
|
+
get idleExpired(): boolean {
|
|
464
|
+
return this.idleStopped;
|
|
465
|
+
}
|
|
466
|
+
/** Idempotent cleanup revokes admission immediately, then closes only the owned helper target. */
|
|
467
|
+
async stop(): Promise<void> {
|
|
468
|
+
if (this.closing) return this.closing;
|
|
469
|
+
this.stopped = true;
|
|
470
|
+
clearInterval(this.idleTimer);
|
|
471
|
+
this.wake();
|
|
472
|
+
this.closing = (async () => {
|
|
473
|
+
await this.ready.catch(() => undefined);
|
|
474
|
+
await this.closeHelper();
|
|
475
|
+
})();
|
|
476
|
+
return this.closing;
|
|
477
|
+
}
|
|
478
|
+
private async closeHelper(): Promise<void> {
|
|
479
|
+
const helper = this.helper;
|
|
480
|
+
this.helper = null;
|
|
481
|
+
helper?.off("Runtime.bindingCalled", this.onPacket);
|
|
482
|
+
if (helper && this.options.connection.isOpen) {
|
|
483
|
+
await helper
|
|
484
|
+
.send("Runtime.evaluate", { expression: "stopCapture()", awaitPromise: true })
|
|
485
|
+
.catch(() => undefined);
|
|
486
|
+
await helper.detach().catch(() => undefined);
|
|
487
|
+
}
|
|
488
|
+
const targetId = this.helperTargetId;
|
|
489
|
+
this.helperTargetId = null;
|
|
490
|
+
if (targetId && this.options.connection.isOpen) {
|
|
491
|
+
await this.options.connection
|
|
492
|
+
.send("Target.closeTarget", { targetId }, { mutation: true })
|
|
493
|
+
.catch(() => undefined);
|
|
494
|
+
}
|
|
495
|
+
this.encoders.clear();
|
|
496
|
+
}
|
|
497
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/** Exercise the actual trusted helper program's producer bounds without a fake duplicate policy. */
|
|
2
|
+
import { createContext, runInContext } from "node:vm";
|
|
3
|
+
import { expect, it, vi } from "vitest";
|
|
4
|
+
import { NATIVE_VIDEO_EXTENSION_SOURCE } from "./native-video-extension-source";
|
|
5
|
+
|
|
6
|
+
it("oversize closes only that encoder, and repeated source frames cannot restart expensive keys", async () => {
|
|
7
|
+
const packets: { error?: string; streamId?: string }[] = [];
|
|
8
|
+
const encoders: { configuration: { bitrate: number }; calls: number; closed: boolean }[] = [];
|
|
9
|
+
let deliver: (result: unknown) => void = () => {};
|
|
10
|
+
let stopped = false;
|
|
11
|
+
const scaledFrames: {
|
|
12
|
+
timestamp: number;
|
|
13
|
+
displayWidth: number;
|
|
14
|
+
displayHeight: number;
|
|
15
|
+
close: ReturnType<typeof vi.fn>;
|
|
16
|
+
}[] = [];
|
|
17
|
+
const reader = {
|
|
18
|
+
read: () =>
|
|
19
|
+
new Promise((resolve) => {
|
|
20
|
+
deliver = resolve;
|
|
21
|
+
}),
|
|
22
|
+
cancel: async () => {
|
|
23
|
+
deliver({ done: true });
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
class Encoder {
|
|
27
|
+
static async isConfigSupported() {
|
|
28
|
+
return { supported: true };
|
|
29
|
+
}
|
|
30
|
+
configuration = { bitrate: 0 };
|
|
31
|
+
calls = 0;
|
|
32
|
+
closed = false;
|
|
33
|
+
encodeQueueSize = 0;
|
|
34
|
+
constructor(readonly callbacks: { output(chunk: unknown, metadata: unknown): void }) {
|
|
35
|
+
encoders.push(this);
|
|
36
|
+
}
|
|
37
|
+
configure(value: { bitrate: number }) {
|
|
38
|
+
this.configuration = value;
|
|
39
|
+
}
|
|
40
|
+
encode(frame: { timestamp: number }) {
|
|
41
|
+
this.calls++;
|
|
42
|
+
this.callbacks.output(
|
|
43
|
+
{
|
|
44
|
+
timestamp: frame.timestamp,
|
|
45
|
+
type: "key",
|
|
46
|
+
byteLength: this.configuration.bitrate > 5_000_000 ? 2_097_153 : 10,
|
|
47
|
+
copyTo: (bytes: Uint8Array) => bytes.fill(1),
|
|
48
|
+
},
|
|
49
|
+
{},
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
close() {
|
|
53
|
+
if (this.closed) throw new Error("already closed");
|
|
54
|
+
this.closed = true;
|
|
55
|
+
}
|
|
56
|
+
reset() {
|
|
57
|
+
if (this.closed) throw new Error("already closed");
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const context = createContext({
|
|
61
|
+
chrome: {
|
|
62
|
+
debugger: { getTargets: async () => [{ id: "exact", type: "page", tabId: 7 }] },
|
|
63
|
+
tabCapture: { getMediaStreamId: async () => "exact-stream" },
|
|
64
|
+
},
|
|
65
|
+
navigator: {
|
|
66
|
+
mediaDevices: {
|
|
67
|
+
getUserMedia: async () => ({
|
|
68
|
+
getVideoTracks: () => [{ getSettings: () => ({ width: 1280, height: 800 }) }],
|
|
69
|
+
getTracks: () => [
|
|
70
|
+
{
|
|
71
|
+
stop: () => {
|
|
72
|
+
stopped = true;
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
}),
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
MediaStreamTrackProcessor: class {
|
|
80
|
+
readable = { getReader: () => reader };
|
|
81
|
+
},
|
|
82
|
+
VideoEncoder: Encoder,
|
|
83
|
+
VideoFrame: class {
|
|
84
|
+
timestamp: number;
|
|
85
|
+
displayWidth: number;
|
|
86
|
+
displayHeight: number;
|
|
87
|
+
close = vi.fn();
|
|
88
|
+
constructor(
|
|
89
|
+
source: { timestamp: number },
|
|
90
|
+
options: { displayWidth: number; displayHeight: number },
|
|
91
|
+
) {
|
|
92
|
+
this.timestamp = source.timestamp;
|
|
93
|
+
this.displayWidth = options.displayWidth;
|
|
94
|
+
this.displayHeight = options.displayHeight;
|
|
95
|
+
scaledFrames.push(this);
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
nativeVideoPacket: (json: string) => packets.push(JSON.parse(json)),
|
|
99
|
+
performance: { now: () => 100 },
|
|
100
|
+
Uint8Array,
|
|
101
|
+
Map,
|
|
102
|
+
Set,
|
|
103
|
+
Object,
|
|
104
|
+
JSON,
|
|
105
|
+
Number,
|
|
106
|
+
btoa: (value: string) => Buffer.from(value, "binary").toString("base64"),
|
|
107
|
+
});
|
|
108
|
+
runInContext(NATIVE_VIDEO_EXTENSION_SOURCE, context);
|
|
109
|
+
await runInContext('startCapture("exact",1280,800,1)', context);
|
|
110
|
+
await runInContext('addEncoder("high","high-stream")', context);
|
|
111
|
+
await runInContext('addEncoder("low","low-stream")', context);
|
|
112
|
+
const frame = (timestamp: number) => ({
|
|
113
|
+
timestamp,
|
|
114
|
+
displayWidth: 1280,
|
|
115
|
+
displayHeight: 800,
|
|
116
|
+
close() {},
|
|
117
|
+
});
|
|
118
|
+
deliver({ value: frame(100000), done: false });
|
|
119
|
+
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
120
|
+
expect(encoders[0]?.closed).toBe(true);
|
|
121
|
+
expect(packets).toContainEqual({
|
|
122
|
+
error: "Native video quality exceeds its packet bound",
|
|
123
|
+
streamId: "high-stream",
|
|
124
|
+
});
|
|
125
|
+
deliver({ value: frame(133333), done: false });
|
|
126
|
+
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
127
|
+
expect(encoders[0]?.calls).toBe(1);
|
|
128
|
+
expect(encoders[1]?.calls).toBe(2);
|
|
129
|
+
expect(packets.filter((packet) => packet.streamId === "low-stream")).toHaveLength(2);
|
|
130
|
+
await runInContext("resetCapture(2,150000)", context);
|
|
131
|
+
deliver({ value: { ...frame(200000), displayHeight: 799 }, done: false });
|
|
132
|
+
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
133
|
+
expect(packets).toContainEqual({
|
|
134
|
+
error: "Native video dimensions changed",
|
|
135
|
+
reasonCode: "source-dimensions",
|
|
136
|
+
});
|
|
137
|
+
await runInContext("stopCapture()", context);
|
|
138
|
+
expect(stopped).toBe(true);
|
|
139
|
+
await runInContext('startCapture("exact",412,839,3)', context);
|
|
140
|
+
await runInContext('addEncoder("low","odd-stream")', context);
|
|
141
|
+
const close = vi.fn();
|
|
142
|
+
deliver({
|
|
143
|
+
value: { timestamp: 300000, displayWidth: 412, displayHeight: 838, format: "I420", close },
|
|
144
|
+
done: false,
|
|
145
|
+
});
|
|
146
|
+
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
147
|
+
expect(packets).toContainEqual(
|
|
148
|
+
expect.objectContaining({
|
|
149
|
+
streamId: "odd-stream",
|
|
150
|
+
width: 412,
|
|
151
|
+
height: 839,
|
|
152
|
+
timestampUs: 300000,
|
|
153
|
+
}),
|
|
154
|
+
);
|
|
155
|
+
expect(close).toHaveBeenCalledOnce();
|
|
156
|
+
expect(scaledFrames[0]?.close).toHaveBeenCalledOnce();
|
|
157
|
+
await runInContext("stopCapture()", context);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it.each([
|
|
161
|
+
[412, 839, 412, 838],
|
|
162
|
+
[393, 659, 392, 658],
|
|
163
|
+
[413, 800, 412, 800],
|
|
164
|
+
])(
|
|
165
|
+
"corrects only even-aligned YUV dimensions for %i by %i",
|
|
166
|
+
(width, height, nativeWidth, nativeHeight) => {
|
|
167
|
+
const frames: unknown[] = [];
|
|
168
|
+
const context = createContext({
|
|
169
|
+
VideoFrame: class {
|
|
170
|
+
constructor(
|
|
171
|
+
readonly source: unknown,
|
|
172
|
+
readonly options: unknown,
|
|
173
|
+
) {
|
|
174
|
+
frames.push(this);
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
runInContext(NATIVE_VIDEO_EXTENSION_SOURCE, context);
|
|
179
|
+
const native = {
|
|
180
|
+
displayWidth: nativeWidth,
|
|
181
|
+
displayHeight: nativeHeight,
|
|
182
|
+
format: "I420",
|
|
183
|
+
timestamp: 42,
|
|
184
|
+
};
|
|
185
|
+
context.frame = native;
|
|
186
|
+
const result = runInContext(`frameForViewport(frame,${width},${height})`, context);
|
|
187
|
+
expect(result.source).toBe(native);
|
|
188
|
+
expect(result.options).toEqual({ displayWidth: width, displayHeight: height });
|
|
189
|
+
expect(frames).toHaveLength(1);
|
|
190
|
+
context.frame = { ...native, displayWidth: width, displayHeight: height };
|
|
191
|
+
expect(runInContext(`frameForViewport(frame,${width},${height})`, context)).toBe(context.frame);
|
|
192
|
+
for (const invalid of [
|
|
193
|
+
{ ...native, displayHeight: nativeHeight - 2 },
|
|
194
|
+
{ ...native, displayWidth: nativeWidth + 2 },
|
|
195
|
+
{ ...native, format: "RGBA" },
|
|
196
|
+
]) {
|
|
197
|
+
context.frame = invalid;
|
|
198
|
+
expect(runInContext(`frameForViewport(frame,${width},${height})`, context)).toBeNull();
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
);
|