fix(studio): harden preview recovery

This commit is contained in:
ukimsanov
2026-07-27 23:55:06 -07:00
parent 3af9a7df2d
commit c62bd4c454
4 changed files with 151 additions and 17 deletions
@@ -1,12 +1,79 @@
// @vitest-environment happy-dom
import { describe, expect, it } from "vitest";
import { act, createElement } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
hasUnloadedAssets,
Player,
readPreviewErrorMessage,
shouldShowCompositionLoadingOverlay,
} from "./Player";
vi.mock("@hyperframes/player", () => ({}));
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
let root: Root | null = null;
let lifecycleLog: string[] = [];
class TestHyperframesPlayer extends HTMLElement {
readonly iframeElement = document.createElement("iframe");
constructor() {
super();
const addIframeListener = this.iframeElement.addEventListener.bind(this.iframeElement);
this.iframeElement.addEventListener = ((type, listener, options) => {
lifecycleLog.push(`iframe:${type}`);
addIframeListener(type, listener, options);
}) as typeof this.iframeElement.addEventListener;
const addPlayerListener = this.addEventListener.bind(this);
this.addEventListener = ((type, listener, options) => {
lifecycleLog.push(`player:${type}`);
addPlayerListener(type, listener, options);
}) as typeof this.addEventListener;
const setPlayerAttribute = this.setAttribute.bind(this);
this.setAttribute = (name, value) => {
if (name === "src") lifecycleLog.push("src");
setPlayerAttribute(name, value);
};
}
}
if (!customElements.get("hyperframes-player")) {
customElements.define("hyperframes-player", TestHyperframesPlayer);
}
afterEach(() => {
if (root) act(() => root?.unmount());
root = null;
lifecycleLog = [];
document.body.innerHTML = "";
});
async function mountPlayer() {
const host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
await act(async () => {
root?.render(
createElement(Player, {
directUrl: "/api/projects/demo/preview",
onLoad: vi.fn(),
suppressLoadingOverlay: true,
}),
);
await Promise.resolve();
});
const player = host.querySelector<TestHyperframesPlayer>("hyperframes-player");
if (!player) throw new Error("player did not mount");
return { host, player };
}
function createAudioIframe() {
const iframe = document.createElement("iframe");
document.body.appendChild(iframe);
@@ -32,6 +99,47 @@ describe("preview errors", () => {
"The composition preview did not become ready.",
);
});
it("attaches lifecycle listeners before navigating the player", async () => {
await mountPlayer();
const srcIndex = lifecycleLog.indexOf("src");
expect(srcIndex).toBeGreaterThan(-1);
for (const listener of [
"iframe:load",
"player:click",
"player:shadertransitionstate",
"player:ready",
"player:error",
]) {
expect(lifecycleLog.indexOf(listener)).toBeGreaterThan(-1);
expect(lifecycleLog.indexOf(listener)).toBeLessThan(srcIndex);
}
});
it("retries a failed preview with a fresh player URL", async () => {
const { host, player } = await mountPlayer();
act(() => {
player.dispatchEvent(
new CustomEvent("error", {
detail: { message: "Composition timeline not found after 8s" },
}),
);
});
expect(host.querySelector('[data-testid="composition-preview-error"]')).not.toBeNull();
const retry = Array.from(host.querySelectorAll("button")).find(
(button) => button.textContent === "Retry preview",
);
if (!retry) throw new Error("retry action did not render");
act(() => retry.click());
const retryUrl = new URL(player.getAttribute("src") ?? "", window.location.origin);
expect(retryUrl.searchParams.get("_hfStudioRetry")).toBe("1");
expect(host.querySelector('[data-testid="composition-preview-error"]')).toBeNull();
});
});
describe("composition loading overlay", () => {