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 & { kind: "media" }; type Snapshot = { botId?: string; threadId: string; messages: Array<{ seq: number } & Record>; }; /** 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 { 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 { 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("shows a finished video analysis as a done card without files", async ({ page }, testInfo) => { const stamp = Date.now(); await signup(page, `media-analysis-${stamp}@rakazo.test`, "password12", "Media Analysis"); await completeOnboarding(page); const botId = activeBotId(page); // The cut points go to the bot only; the card just tells the user the analysis ran. await seedMediaMessage(page, botId, () => ({ kind: "media", jobId: "media-job-analysis", operation: "analyze_video", status: "done", title: "Find the cut points", progress: 1, outputs: [], })); await page.reload(); const card = page.getByTestId("media-card"); await expect(card).toBeVisible({ timeout: 30_000 }); await expect(card).toHaveAttribute("data-status", "done"); await expect(card).toContainText("Find the cut points"); await expect(card).toContainText("Done"); await expect(card.getByRole("button")).toHaveCount(0); await expect(card.getByRole("link")).toHaveCount(0); await captureScreenshot(page, testInfo, "media-card-analysis"); }); 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"] }, }); });