fix(player): perf harness waits for assets-ready before play instead of a fixed 5s (#4137)

This commit is contained in:
Miguel Ángel
2026-09-18 20:58:30 -04:00
committed by GitHub
parent f2976536de
commit 4bbcf650ae
6 changed files with 75 additions and 3 deletions
+47
View File
@@ -0,0 +1,47 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { waitForPlayerAssetsReady } from "./runner.ts";
type Pred = () => boolean;
/** A page whose `__playerAssetsReady` flips after `gateMs`, polled like puppeteer's waitForFunction. */
function pageWithSlowGate(gateMs: number) {
const win: { __playerAssetsReady?: boolean } = {};
setTimeout(() => (win.__playerAssetsReady = true), gateMs);
return {
waitForFunction: (pred: Pred, opts: { timeout: number }) =>
new Promise<void>((resolve, reject) => {
const started = Date.now();
const poll = setInterval(() => {
(globalThis as { window?: unknown }).window = win;
if (pred()) {
clearInterval(poll);
resolve();
} else if (Date.now() - started >= opts.timeout) {
clearInterval(poll);
reject(new Error(`Waiting failed: ${opts.timeout}ms exceeded`));
}
}, 5);
}),
};
}
describe("waitForPlayerAssetsReady", () => {
afterEach(() => vi.useRealTimers());
it("outlasts an asset gate slower than the old fixed 5s play-confirm window", async () => {
vi.useFakeTimers();
const page = pageWithSlowGate(7_000);
const done = waitForPlayerAssetsReady(page as never);
await vi.advanceTimersByTimeAsync(7_100);
await expect(done).resolves.toBeUndefined();
});
it("names the queued asset gate when it never opens", async () => {
vi.useFakeTimers();
const page = pageWithSlowGate(60_000);
const done = waitForPlayerAssetsReady(page as never, 1_000);
const assertion = expect(done).rejects.toThrow(/never emitted assetsready.*queued behind/);
await vi.advanceTimersByTimeAsync(1_100);
await assertion;
});
});
+18
View File
@@ -41,6 +41,7 @@ declare global {
interface Window {
__playerReady?: boolean;
__playerReadyAt?: number;
__playerAssetsReady?: boolean;
__playerNavStart?: number;
__playerDuration?: number;
__playerError?: string;
@@ -126,6 +127,23 @@ export async function loadHostPage(
return { loadMs, duration };
}
/**
* Wait for the player's own `assetsready` signal. `play()` queues until it fires, so a
* scenario that plays right after `ready` and waits a fixed window for `isPlaying` races the gate.
*/
export async function waitForPlayerAssetsReady(
page: Pick<Page, "waitForFunction">,
timeoutMs = 30_000,
): Promise<void> {
try {
await page.waitForFunction(() => window.__playerAssetsReady === true, { timeout: timeoutMs });
} catch (e) {
throw new Error(
`[player-perf] player never emitted assetsready within ${timeoutMs}ms; play() stays queued behind the asset gate (${e instanceof Error ? e.message : String(e)})`,
);
}
}
export function percentile(samples: number[], pct: number): number {
if (samples.length === 0) return 0;
const sorted = [...samples].sort((a, b) => a - b);
@@ -51,7 +51,7 @@
*/
import type { Browser, Frame, Page } from "puppeteer-core";
import { loadHostPage } from "../runner.ts";
import { loadHostPage, waitForPlayerAssetsReady } from "../runner.ts";
import type { Metric } from "../perf-gate.ts";
export type FpsScenarioOpts = {
@@ -120,6 +120,7 @@ async function runOnce(
try {
const page = await ctx.newPage();
const { duration } = await loadHostPage(page, opts.origin, { fixture });
await waitForPlayerAssetsReady(page);
const frame = await getFixtureFrame(page, fixture);
// Install the wall-clock sampler in the iframe context. We use setInterval
@@ -53,7 +53,7 @@
*/
import type { Browser, Frame, Page } from "puppeteer-core";
import { loadHostPage, percentile } from "../runner.ts";
import { loadHostPage, waitForPlayerAssetsReady, percentile } from "../runner.ts";
import type { Metric } from "../perf-gate.ts";
export type DriftScenarioOpts = {
@@ -126,6 +126,7 @@ async function runOnce(
try {
const page = await ctx.newPage();
const { duration } = await loadHostPage(page, opts.origin, { fixture });
await waitForPlayerAssetsReady(page);
const requiredDurationSec = PLAYBACK_DURATION_MS / 1000;
if (duration < requiredDurationSec) {
throw new Error(
@@ -73,7 +73,7 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import type { Browser, Frame, Page } from "puppeteer-core";
import { loadHostPage } from "../runner.ts";
import { loadHostPage, waitForPlayerAssetsReady } from "../runner.ts";
import type { Metric } from "../perf-gate.ts";
export type ParityScenarioOpts = {
@@ -261,6 +261,7 @@ async function runOnce(
try {
const page = await ctx.newPage();
const { duration } = await loadHostPage(page, opts.origin, { fixture });
await waitForPlayerAssetsReady(page);
if (duration < TARGET_TIME_S + 0.1) {
throw new Error(
`[scenario:parity] fixture composition is ${duration.toFixed(2)}s but parity target needs >= ${(TARGET_TIME_S + 0.1).toFixed(2)}s`,
+4
View File
@@ -100,6 +100,7 @@ function buildHostHtml(fixtureName: string, width: number, height: number): stri
></hyperframes-player>
<script>
window.__playerReady = false;
window.__playerAssetsReady = false;
window.__playerReadyAt = null;
window.__playerNavStart = performance.timeOrigin + performance.now();
window.__sandboxProbeResults = [];
@@ -114,6 +115,9 @@ function buildHostHtml(fixtureName: string, width: number, height: number): stri
window.__playerReadyAt = performance.timeOrigin + performance.now();
window.__playerDuration = (event.detail && event.detail.duration) || 0;
});
player.addEventListener("assetsready", function () {
window.__playerAssetsReady = true;
});
player.addEventListener("error", function (event) {
window.__playerError = (event.detail && event.detail.message) || "unknown";
});