Files
Recordly/tests/ui/project-hover-composition.spec.ts
webadderall a7f55b48b1 Fix Recordly 2.0 part 7 review findings
Integrate validated app changes and preserve original stack history.
2026-11-07 14:48:48 +11:00

459 lines
15 KiB
TypeScript

import { expect, type Page, test } from "@playwright/test";
import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
test.setTimeout(90000);
type DelayedLayer =
| "webcam"
| "background"
| "caption"
| "text"
| "video"
| "metadata"
| "annotation";
async function openProject(page: Page, layer: DelayedLayer) {
await installDesktopBridge(page);
await installDesktopBridgeOverrides(
page,
(layer) => {
window.electronAPI.listProjectFiles = async () => ({
success: true,
entries: [
{
path: "/projects/complete-scene.recordly",
name: "Complete scene",
updatedAt: 1,
thumbnailPath: `${location.origin}/tests/ui/fixtures/recording-thumbnail.jpg`,
isCurrent: false,
isInProjectsDirectory: true,
},
],
});
window.electronAPI.getProjectPreview = async () => {
if (layer === "metadata")
await new Promise<void>((resolve) => {
Object.assign(window, { releaseHoverMetadata: resolve });
});
return {
success: true,
value: {
videoUrl: `${location.origin}/tests/ui/fixtures/filmstrip.mp4`,
webcamUrl:
layer === "webcam" ? `${location.origin}/hover-camera.mp4` : null,
project: {
version: 1,
videoPath: "/recordings/complete-scene.mp4",
editor: {
aspectRatio: "native",
cropRegion: { x: 0.25, y: 0, width: 0.5, height: 1 },
wallpaper:
layer === "background"
? `${location.origin}/hover-wallpaper.png`
: layer === "video"
? `${location.origin}/tests/ui/fixtures/background.webm`
: "#304060",
annotationRegions:
layer === "text"
? [
{
id: "later-text",
type: "text",
startMs: 1000,
endMs: 6000,
content: "Later words",
position: { x: 50, y: 50 },
size: { width: 60, height: 25 },
style: {
fontFamily: '"DM Sans"',
fontStyle: "italic",
},
zIndex: 1,
},
]
: layer === "annotation"
? [
{
id: "delayed-image",
type: "image",
startMs: 1000,
endMs: 6000,
content:
"data:image/svg+xml," +
encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect id="hover-annotation" width="64" height="64" fill="#ff3200"/></svg>',
),
position: { x: 50, y: 50 },
size: { width: 35, height: 35 },
zIndex: 1,
},
]
: [],
webcam: {
enabled: layer === "webcam",
sourcePath:
layer === "webcam" ? "/recordings/camera.mp4" : null,
},
autoCaptions: [
{ id: "cue", startMs: 0, endMs: 6000, text: "Ready together" },
],
autoCaptionSettings: {
enabled: layer === "caption",
style: "tiktok",
animationStyle: "none",
},
clipRegions: [
{
id: "clip",
startMs: 0,
endMs: 6000,
sourceStartMs: 0,
speed: 1,
},
],
},
},
},
};
};
},
layer,
);
await page.goto("/?windowType=editor");
await page.getByRole("button", { name: "Home", exact: true }).click();
const card = page.getByRole("button", { name: "Complete scene", exact: true });
await expect(card).toBeVisible({ timeout: 25000 });
return card;
}
async function sampleLoadingFrames(page: Page, milliseconds = 900) {
return page.evaluate(async (milliseconds) => {
let frames = 0;
let exposedFrames = 0;
const end = performance.now() + milliseconds;
await new Promise<void>((resolve) => {
function sample() {
frames++;
for (const element of document.querySelectorAll<HTMLElement>(
"[data-project-hover-preview]",
)) {
const style = getComputedStyle(element);
if (Number(style.opacity) > 0 && style.visibility !== "hidden") exposedFrames++;
}
if (performance.now() >= end) resolve();
else requestAnimationFrame(sample);
}
requestAnimationFrame(sample);
});
return { frames, exposedFrames };
}, milliseconds);
}
for (const layer of ["webcam", "background", "caption", "text"] as const) {
test(`hover keeps its thumbnail for every frame while ${layer} loads, including reentry`, async ({
page,
}) => {
let release!: () => void;
const pending = new Promise<void>((resolve) => {
release = resolve;
});
let requested = false;
const resource =
layer === "webcam"
? "**/hover-camera.mp4"
: layer === "background"
? "**/hover-wallpaper.png"
: layer === "text"
? /DMSans-Italic-Variable\.ttf(?:\?.*)?$/
: /TikTokSans-Variable\.ttf(?:\?.*)?$/;
const fontLayer = layer === "caption" || layer === "text";
await page.route(resource, async (route) => {
if (fontLayer && route.request().resourceType() !== "font") {
await route.continue();
return;
}
requested = true;
await pending;
if (fontLayer) await route.continue();
else
await route.fulfill({
path:
layer === "webcam"
? "tests/ui/fixtures/preview.mp4"
: "tests/ui/fixtures/recording-thumbnail.jpg",
contentType: layer === "webcam" ? "video/mp4" : "image/jpeg",
});
});
try {
const card = await openProject(page, layer);
await card.hover();
const hover = card.locator("[data-project-hover-preview]");
await expect(hover).toHaveCount(1);
await expect.poll(() => requested).toBe(true);
if (layer === "text")
await expect(hover.locator('[data-annotation-id="later-text"]')).toHaveCount(0);
await expect
.poll(() =>
hover
.locator('video[src$="/filmstrip.mp4"]')
.evaluate((video: HTMLVideoElement) => video.readyState),
)
.toBeGreaterThanOrEqual(2);
const first = await sampleLoadingFrames(page);
expect(first.frames).toBeGreaterThan(8);
expect(first.exposedFrames).toBe(0);
await expect(card.locator('img[src*="recording-thumbnail.jpg"]')).toBeVisible();
await page.mouse.move(0, 0);
await expect(hover).toHaveCount(0);
await card.hover();
await expect(hover).toHaveCount(1);
const reentry = await sampleLoadingFrames(page, 400);
expect(reentry.exposedFrames).toBe(0);
release();
await expect(hover).toHaveCSS("opacity", "1", { timeout: 15000 });
if (layer === "caption") await expect(hover.getByText("Ready together")).toBeVisible();
if (layer === "text") {
await expect(hover.getByText("Later words")).toBeVisible();
const frames = await sampleLoadingFrames(page, 500);
expect(frames.exposedFrames).toBe(frames.frames);
}
if (layer === "webcam")
await expect(hover.locator("[data-webcam-overlay]")).toHaveAttribute(
"data-webcam-ready",
"true",
);
const ratio = await hover
.locator("[data-preview-captions-ready]")
.evaluate((element) => element.clientWidth / element.clientHeight);
expect(ratio).toBeCloseTo(8 / 9, 1);
await hover.screenshot({ path: test.info().outputPath(`${layer}-complete.png`) });
} finally {
release();
}
});
}
test("unrelated document font loading cannot hide or seek an established hover", async ({
page,
}) => {
let release!: () => void;
const pending = new Promise<void>((resolve) => {
release = resolve;
});
let requested = false;
await page.route("**/unrelated-hover-font.ttf", async (route) => {
requested = true;
await pending;
await route.fulfill({
path: "src/assets/fonts/dm-sans/DMSans-Italic-Variable.ttf",
contentType: "font/ttf",
});
});
try {
const card = await openProject(page, "video");
await card.hover();
const hover = card.locator("[data-project-hover-preview]");
await expect(hover).toHaveCSS("opacity", "1", { timeout: 15000 });
await hover.evaluate((element) => {
const video = element.querySelector<HTMLVideoElement>('video[src$="/filmstrip.mp4"]')!;
element.dataset.unrelatedFontSeeks = "0";
video.addEventListener("seeking", () => {
element.dataset.unrelatedFontSeeks = String(
Number(element.dataset.unrelatedFontSeeks) + 1,
);
});
const face = new FontFace(
"Unrelated hover font",
`url("${location.origin}/unrelated-hover-font.ttf")`,
);
document.fonts.add(face);
void face.load();
});
await expect.poll(() => requested).toBe(true);
await expect.poll(() => page.evaluate(() => document.fonts.status)).toBe("loading");
const loading = await sampleLoadingFrames(page, 600);
expect(loading.frames).toBeGreaterThan(8);
expect(loading.exposedFrames).toBe(loading.frames);
release();
await expect.poll(() => page.evaluate(() => document.fonts.status)).toBe("loaded");
const loaded = await sampleLoadingFrames(page, 400);
expect(loaded.exposedFrames).toBe(loaded.frames);
await expect(hover).toHaveAttribute("data-unrelated-font-seeks", "0");
} finally {
release();
}
});
test("missing composed media falls back to the static thumbnail", async ({ page }) => {
let abort!: () => void;
const pending = new Promise<void>((resolve) => {
abort = resolve;
});
await page.route("**/hover-camera.mp4", async (route) => {
await pending;
await route.abort("failed");
});
const card = await openProject(page, "webcam");
await card.hover();
await expect(card.locator("[data-project-hover-preview]")).toHaveCount(1);
abort();
await expect(card.locator("[data-project-hover-preview]")).toHaveCount(0, { timeout: 10000 });
await expect(card.locator('img[src*="recording-thumbnail.jpg"]')).toBeVisible();
});
test("an old hover's rejected play promise cannot remove its replacement", async ({ page }) => {
await page.addInitScript(() => {
const play = HTMLMediaElement.prototype.play;
let intercepted = false;
HTMLMediaElement.prototype.play = function () {
if (
!intercepted &&
this instanceof HTMLVideoElement &&
this.closest("[data-project-hover-preview]") &&
this.src.endsWith("/filmstrip.mp4")
) {
intercepted = true;
return new Promise<void>((_resolve, reject) => {
Object.assign(window, {
rejectOldHoverPlay: () =>
reject(new DOMException("Old hover disposed", "AbortError")),
});
document.documentElement.dataset.oldHoverPlayPending = "true";
});
}
return play.call(this);
};
});
const card = await openProject(page, "background");
await page.route("**/hover-wallpaper.png", (route) =>
route.fulfill({
path: "tests/ui/fixtures/recording-thumbnail.jpg",
contentType: "image/jpeg",
}),
);
await card.hover();
const hover = card.locator("[data-project-hover-preview]");
await expect(page.locator("html")).toHaveAttribute("data-old-hover-play-pending", "true", {
timeout: 15000,
});
await page.mouse.move(0, 0);
await expect(hover).toHaveCount(0);
await card.hover();
await expect(hover).toHaveCSS("opacity", "1", { timeout: 15000 });
await page.evaluate(() =>
(window as unknown as { rejectOldHoverPlay(): void }).rejectOldHoverPlay(),
);
const frames = await sampleLoadingFrames(page, 400);
expect(frames.exposedFrames).toBe(frames.frames);
await expect(hover).toHaveCount(1);
});
test("video wallpaper stays composed through seeks and a loop", async ({ page }) => {
const card = await openProject(page, "video");
await card.hover();
const hover = card.locator("[data-project-hover-preview]");
await expect(hover).toHaveCSS("opacity", "1", { timeout: 15000 });
const results = await hover.evaluate(async (element) => {
const background = element.querySelector<HTMLVideoElement>(
"video[data-preview-background]",
)!;
let hidden = 0;
let sampled = 0;
let seeks = 0;
background.addEventListener("seeking", () => {
seeks++;
});
background.currentTime = Math.max(0, background.duration - 0.1);
await background.play();
const end = performance.now() + 1200;
await new Promise<void>((resolve) => {
function sample() {
sampled++;
const style = getComputedStyle(element);
if (style.visibility === "hidden" || Number(style.opacity) === 0) hidden++;
if (performance.now() < end) requestAnimationFrame(sample);
else resolve();
}
requestAnimationFrame(sample);
});
return { hidden, sampled, seeks };
});
expect(results.seeks).toBeGreaterThan(0);
expect(results.sampled).toBeGreaterThan(8);
expect(results.hidden).toBe(0);
});
test("async project metadata keeps its thumbnail through leave and reentry", async ({ page }) => {
const card = await openProject(page, "metadata");
await card.hover();
await expect
.poll(() =>
page.evaluate(
() =>
typeof (window as unknown as { releaseHoverMetadata?: unknown })
.releaseHoverMetadata,
),
)
.toBe("function");
expect((await sampleLoadingFrames(page)).exposedFrames).toBe(0);
await page.mouse.move(0, 0);
await card.hover();
expect((await sampleLoadingFrames(page, 400)).exposedFrames).toBe(0);
await page.evaluate(() =>
(window as unknown as { releaseHoverMetadata(): void }).releaseHoverMetadata(),
);
await expect(card.locator("[data-project-hover-preview]")).toHaveCSS("opacity", "1", {
timeout: 15000,
});
});
test("future image annotation decodes before any hover frame, without a late pop or reentry race", async ({
page,
}) => {
await page.addInitScript(() => {
const decode = HTMLImageElement.prototype.decode;
let release!: () => void;
const pending = new Promise<void>((resolve) => {
release = resolve;
});
Object.assign(window, { releaseHoverAnnotation: release });
HTMLImageElement.prototype.decode = function () {
const decoded = decode.call(this);
if (!this.src.includes("hover-annotation")) return decoded;
return decoded.then(() => {
document.documentElement.dataset.annotationDecodePending = "true";
return pending;
});
};
});
const card = await openProject(page, "annotation");
await card.hover();
const hover = card.locator("[data-project-hover-preview]");
await expect(hover).toHaveCount(1);
await expect(page.locator("html")).toHaveAttribute("data-annotation-decode-pending", "true", {
timeout: 15000,
});
// This saved image enters at 1s: it is not mounted at the paused opening frame.
await expect(hover.locator('[data-annotation-id="delayed-image"]')).toHaveCount(0);
expect((await sampleLoadingFrames(page)).exposedFrames).toBe(0);
await page.mouse.move(0, 0);
await expect(hover).toHaveCount(0);
await card.hover();
await expect(hover).toHaveCount(1);
expect((await sampleLoadingFrames(page, 400)).exposedFrames).toBe(0);
await page.evaluate(() =>
(window as unknown as { releaseHoverAnnotation(): void }).releaseHoverAnnotation(),
);
await expect(hover).toHaveCSS("opacity", "1", { timeout: 15000 });
const drawnFrames = await sampleLoadingFrames(page, 1800);
expect(drawnFrames.exposedFrames).toBe(drawnFrames.frames);
const image = hover.locator('[data-annotation-id="delayed-image"] img');
await expect(image).toBeVisible();
expect(
await image.evaluate(
(element: HTMLImageElement) => element.complete && element.naturalWidth > 0,
),
).toBe(true);
await hover.screenshot({ path: test.info().outputPath("annotation-complete.png") });
});