Both slices extend the edit plan request, the video operation imports, the edit_video contract imports and the media card E2E; the conflicts are unions of both sides. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
332 lines
11 KiB
TypeScript
332 lines
11 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("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"] },
|
|
});
|
|
});
|