evOo-local/apps/web/e2e/media-card.spec.ts
Claude 45856b1af9
Offer platforms in the media card's export menu and show their checks
The card's Export menu now lists the platforms first, by brand name and
untranslated, then the aspect ratios after a separator; the platform an
output was made for is left out, like its own shape. A platform export
shows the platform's name under the player, or, when its check did not
pass, one line for the first issue instead ("Too long for TikTok (max
10:00)"), in the warning or destructive color. Skipped platforms get
the same kind of line, and they stand in for the error of a job that
skipped every platform, so the card does not say the same thing twice
in two languages.

The E2E spec seeds a platform export with a failed check and a skipped
platform, and captures the card and the open menu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-05 09:53:01 +00:00

305 lines
10 KiB
TypeScript

import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { Page } from "@playwright/test";
import { expect, test } from "@playwright/test";
import {
activeBotId,
captureScreenshot,
completeOnboarding,
fetchRpcEnvelope,
rpc,
signup,
} from "./helpers";
type MediaBlock = Record<string, unknown> & { kind: "media" };
type Snapshot = {
botId?: string;
threadId: string;
messages: Array<{ seq: number } & Record<string, unknown>>;
};
/** One second of a quiet 440 Hz tone as 8 kHz mono 16-bit WAV, small enough to inline. */
function toneWavBase64(): string {
const rate = 8000;
const samples = rate;
const buffer = Buffer.alloc(44 + samples * 2);
buffer.write("RIFF", 0);
buffer.writeUInt32LE(36 + samples * 2, 4);
buffer.write("WAVEfmt ", 8);
buffer.writeUInt32LE(16, 16);
buffer.writeUInt16LE(1, 20);
buffer.writeUInt16LE(1, 22);
buffer.writeUInt32LE(rate, 24);
buffer.writeUInt32LE(rate * 2, 28);
buffer.writeUInt16LE(2, 32);
buffer.writeUInt16LE(16, 34);
buffer.write("data", 36);
buffer.writeUInt32LE(samples * 2, 40);
for (let index = 0; index < samples; index += 1) {
buffer.writeInt16LE(
Math.round(Math.sin((2 * Math.PI * 440 * index) / rate) * 3000),
44 + index * 2,
);
}
return buffer.toString("base64");
}
/** A small grey gradient still, drawn by the browser so no binary fixture is needed. */
async function stillPngBase64(page: Page): Promise<string> {
return page.evaluate(() => {
const canvas = document.createElement("canvas");
canvas.width = 320;
canvas.height = 180;
const context = canvas.getContext("2d")!;
const gradient = context.createLinearGradient(0, 0, 320, 180);
gradient.addColorStop(0, "rgb(40, 40, 40)");
gradient.addColorStop(1, "rgb(200, 200, 200)");
context.fillStyle = gradient;
context.fillRect(0, 0, 320, 180);
return canvas.toDataURL("image/png").split(",")[1]!;
});
}
/**
* Media jobs run on a GPU server or ffmpeg that CI does not have, so the card is seeded: real
* artifacts are uploaded, and the bot thread's snapshot (from bootstrap on load, threads/get on
* refresh) carries a bot message with the job's card.
*/
async function seedMediaMessage(page: Page, botId: string, block: () => MediaBlock) {
const seed = (snapshot: Snapshot | undefined) => {
if (snapshot?.botId !== botId) return;
const seq = Math.max(0, ...snapshot.messages.map((message) => message.seq)) + 1;
snapshot.messages.push({
id: `media-message-${seq}`,
threadId: snapshot.threadId,
seq,
role: "bot",
botId,
blocks: [block()],
createdAt: new Date().toISOString(),
});
};
for (const procedure of ["bootstrap", "threads/get"]) {
await page.route(`**/rpc/${procedure}`, async (route) => {
const fetched = await fetchRpcEnvelope(route);
const json = fetched.body.json as (Snapshot & { thread?: Snapshot }) | undefined;
seed(procedure === "bootstrap" ? json?.thread : json);
// Fresh headers: the original content length no longer matches the patched body.
await route.fulfill({
status: fetched.status,
contentType: "application/json",
body: JSON.stringify(fetched.body),
});
});
}
}
/** CI has no ffmpeg or transcription server; the card asks what the space can run. */
async function offerFollowUps(page: Page) {
await page.route("**/rpc/media/capabilities", (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify({
json: {
generate_image: false,
generate_video: false,
edit_image: false,
edit_video: false,
export_video: true,
transcribe: true,
},
}),
}),
);
}
/** Records follow-up jobs the card starts instead of running them. */
async function recordStartedJobs(page: Page): Promise<unknown[]> {
const started: unknown[] = [];
await page.route("**/rpc/media/jobs/start", async (route) => {
started.push((route.request().postDataJSON() as { json: unknown }).json);
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({ json: { jobId: "media-job-2", messageId: "media-message-next" } }),
});
});
return started;
}
test("renders a media job card while it runs and when it is done", async ({ page }, testInfo) => {
const stamp = Date.now();
await signup(page, `media-card-${stamp}@rakazo.test`, "password12", "Media Card");
await completeOnboarding(page);
const botId = activeBotId(page);
const still = await rpc<{ id: string; size: number }>(page, "artifacts/create", {
botId,
name: "still.png",
mimeType: "image/png",
contentBase64: await stillPngBase64(page),
});
const voice = await rpc<{ id: string; size: number }>(page, "artifacts/create", {
botId,
name: "voice-note.wav",
mimeType: "audio/wav",
contentBase64: toneWavBase64(),
});
let state: "running" | "done" = "running";
await seedMediaMessage(page, botId, () =>
state === "running"
? {
kind: "media",
jobId: "media-job-1",
operation: "edit_video",
status: "running",
title: "Cut the pauses and reframe for 9:16",
progress: 0.4,
outputs: [],
}
: {
kind: "media",
jobId: "media-job-1",
operation: "edit_video",
status: "done",
title: "Cut the pauses and reframe for 9:16",
outputs: [
{
artifactId: still.id,
kind: "image",
mimeType: "image/png",
name: "still.png",
size: still.size,
width: 320,
height: 180,
},
{
artifactId: voice.id,
kind: "audio",
mimeType: "audio/wav",
name: "voice-note.wav",
size: voice.size,
durationMs: 1000,
},
],
},
);
await offerFollowUps(page);
const started = await recordStartedJobs(page);
await page.reload();
const card = page.getByTestId("media-card");
await expect(card).toBeVisible({ timeout: 30_000 });
await expect(card).toHaveAttribute("data-status", "running");
await expect(card).toContainText("Cut the pauses and reframe for 9:16");
await expect(card).toContainText("Working…");
await expect(card.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "40");
await captureScreenshot(page, testInfo, "media-card-running");
state = "done";
await page.reload();
await expect(card).toHaveAttribute("data-status", "done", { timeout: 30_000 });
await expect(card).toContainText("Done");
await expect(card.getByRole("progressbar")).toHaveCount(0);
await expect(card.getByRole("img", { name: "still.png" })).toBeVisible();
await expect(card.locator("audio")).toHaveCount(1);
await expect(card.getByRole("link", { name: "Download voice-note.wav" })).toHaveAttribute(
"download",
"voice-note.wav",
);
await expect(card.getByRole("button", { name: "Transcribe" })).toBeVisible();
// Export applies to video only.
await expect(card.getByRole("button", { name: "Export" })).toHaveCount(0);
await captureScreenshot(page, testInfo, "media-card-done");
await card.getByRole("button", { name: "Transcribe" }).click();
await expect.poll(() => started.length).toBe(1);
expect(started[0]).toEqual({
botId,
messageId: expect.stringMatching(/^media-message-\d+$/),
operation: "transcribe",
input: { artifact_id: voice.id, title: "voice-note.wav" },
});
});
test("a platform export names its platform, shows a failed check and exports for others", async ({
page,
}, testInfo) => {
const stamp = Date.now();
await signup(page, `media-platform-${stamp}@rakazo.test`, "password12", "Media Platform");
await completeOnboarding(page);
const botId = activeBotId(page);
const fixture = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"fixtures",
"media-clip.mp4",
);
const clip = await rpc<{ id: string; size: number }>(page, "artifacts/create", {
botId,
name: "clip-tiktok.mp4",
mimeType: "video/mp4",
contentBase64: readFileSync(fixture).toString("base64"),
});
await seedMediaMessage(page, botId, () => ({
kind: "media",
jobId: "media-job-platforms",
operation: "export_video",
status: "done",
title: "TikTok · Snapchat Spotlight",
outputs: [
{
artifactId: clip.id,
kind: "video",
mimeType: "video/mp4",
name: "clip-tiktok.mp4",
size: clip.size,
width: 1080,
height: 1920,
durationMs: 1000,
platform: "tiktok",
check: {
status: "fail",
issues: [{ code: "file_too_large", maxMb: 287, actualMb: 301.2 }],
},
},
],
skipped: [
{
platform: "snapchat-spotlight",
issues: [{ code: "too_short", minMs: 5000, actualMs: 1000 }],
},
],
}));
await offerFollowUps(page);
const started = await recordStartedJobs(page);
await page.reload();
const card = page.getByTestId("media-card");
await expect(card).toHaveAttribute("data-status", "done", { timeout: 30_000 });
await expect(card).toContainText("Too large for TikTok (max 287 MB)");
await expect(card).toContainText("Too short for Snapchat Spotlight (min 0:05)");
await captureScreenshot(page, testInfo, "media-card-platform-check");
await card.getByRole("button", { name: "Export" }).click();
const menu = page.getByRole("menu");
await expect(menu.getByRole("menuitem", { name: "YouTube Shorts" })).toBeVisible();
await expect(menu.getByRole("menuitem", { name: "Instagram Reels" })).toBeVisible();
// Not the platform or the shape it already has.
await expect(menu.getByRole("menuitem", { name: "TikTok" })).toHaveCount(0);
await expect(menu.getByRole("menuitem", { name: "9:16" })).toHaveCount(0);
await expect(menu.getByRole("separator")).toHaveCount(1);
await expect(menu.getByRole("menuitem", { name: "1:1" })).toBeVisible();
await captureScreenshot(page, testInfo, "media-card-platform-export-menu");
await menu.getByRole("menuitem", { name: "YouTube Shorts" }).click();
await expect.poll(() => started.length).toBe(1);
expect(started[0]).toEqual({
botId,
messageId: expect.stringMatching(/^media-message-\d+$/),
operation: "export_video",
input: { artifact_id: clip.id, platforms: ["youtube-shorts"] },
});
});