evOo-local/apps/web/e2e/media-card.spec.ts
Claude 8b2dcaa767
feat(web): show media jobs as a live card in the conversation
Media jobs report progress and results through the media block, which the
web app did not render.

- MediaCard renders the block: operation icon, title, status (reusing the
  task card's labels, plus "Waiting for GPU" for jobs queued behind the
  GPU), a progress bar while active, the error when failed, and outputs:
  images as thumbnails with the existing lightbox, video and audio in the
  media player, subtitle and text files as file cards. Finished video and
  audio get a download link, "Export" with presets labelled by aspect ratio
  (the video's own shape left out) and "Transcribe", offered only when the
  space can run them; running jobs get "Cancel".
- MessageView renders it with one branch.
- An E2E seeds the card on a thread snapshot with real uploaded artifacts.
- artifact-url.ts and MediaPlayer.tsx are minimal stand-ins for the
  pipeline workstream's files and give way to them on merge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:35:21 +00:00

165 lines
5.4 KiB
TypeScript

import { expect, type Page, test } from "@playwright/test";
import {
activeBotId,
captureScreenshot,
completeOnboarding,
fetchRpcEnvelope,
fulfillFetchedRpc,
rpc,
signup,
} from "./helpers";
type MediaBlock = Record<string, unknown> & { kind: "media" };
type Snapshot = {
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 thread snapshot carries a bot message with the job's card.
*/
async function seedMediaMessage(page: Page, botId: string, block: () => MediaBlock) {
await page.route("**/rpc/threads/get", async (route) => {
const fetched = await fetchRpcEnvelope(route);
const snapshot = fetched.body.json as (Snapshot & { botId?: string }) | undefined;
if (snapshot?.botId === botId) {
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(),
});
}
await fulfillFetchedRpc(route, fetched);
});
}
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 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 captureScreenshot(page, testInfo, "media-card-done");
});