storytellurx/scripts/product-demo/replay.spec.ts

156 lines
6.8 KiB
TypeScript

import { demoScenes as scenes, demoDesign } from "./scenes";
import { z } from "zod";
import { test, expect } from "@playwright/test";
import { readFile, writeFile, mkdir } from "node:fs/promises";
import {
nativeConversationStream,
nativeImageStream,
neutralTurn,
} from "../../src/apps/product/fixtures/native-conversation-stream";
// A scripted product walkthrough using imagery generated by Rev Animated in capture.ts or generate-images.ts.
// All playback goes through the real /chat page, native streams, persistence and reveal.
for (const scene of scenes)
test(`${scene.id} product walkthrough`, async ({ page }, testInfo) => {
const dir = `reports/image-reveal-demo/${scene.id}`;
await mkdir(dir, { recursive: true });
const started = Date.now();
await page.addInitScript(() => {
sessionStorage.setItem(
"stx:onboarding:v2:product-demo",
JSON.stringify({
version: 2,
locale: "fr",
intensity: "beginner",
adultConfirmed: true,
warningsAccepted: true,
participationConfirmed: true,
}),
);
localStorage.setItem("stx:metrics-tour:completed:v2", "true");
});
const image = z
.object({ images: z.array(z.object({ url: z.string() })).min(1) })
.parse(JSON.parse(await readFile(`${dir}/provider-image.json`, "utf8"))).images[0]!.url;
const turn = neutralTurn(scene.id, 0.2, 2, {
modality: "text-image",
visualPrompt: demoDesign(scene.id).title[0],
});
turn.fragments = [...scene.lines];
turn.statePatch.shortTitleSummary = demoDesign(scene.id).title[0];
turn.suggestions = scene.replies.map((text) => ({
displayText: text,
promptValue: text,
style: "default",
}));
turn.explanations = { context: "Une rencontre onirique entre paysage, matière et lumière." };
const body = await nativeConversationStream(turn);
await page.route("**/api/ai/chat", (route) =>
route.fulfill({ contentType: "text/event-stream", body }),
);
await page.route("**/api/ai/image", async (route) => {
await new Promise((resolve) => setTimeout(resolve, 1100));
await route.fulfill({
contentType: "text/event-stream",
body: await nativeImageStream("success", image),
});
});
await page.goto(
`/chat?persona-id=${scene.id}&model-id=gemini&gateway-id=nanogpt&session-id=product-demo`,
);
const input = page.getByRole("textbox");
await input.fill("Ouvre un paysage de rêve, dans la brume.");
await page.waitForTimeout(500);
const profileRate = Number(process.env["PROFILE_REVEAL"] ?? 0);
const profiler = profileRate ? await page.context().newCDPSession(page) : null;
if (profiler) {
await profiler.send("Emulation.setCPUThrottlingRate", { rate: profileRate });
await profiler.send("Performance.enable");
}
await input.press("Enter");
await expect(page.locator('[data-image-reveal="summon"]')).toBeVisible({ timeout: 15000 });
const summon = Date.now() - started;
await expect(page.locator(".image-reveal__scene")).toHaveAttribute(
"data-reveal-renderer",
"webgpu",
);
const ink = await page
.locator("[data-image-reveal]")
.evaluate((element) => getComputedStyle(element).getPropertyValue("--reveal-ink").trim());
if (profiler) {
const before = await profiler.send("Performance.getMetrics");
const frames = await page.evaluate(
() =>
new Promise<{ count: number; medianMs: number; p95Ms: number; over33ms: number }>(
(resolve) => {
const intervals: number[] = [];
let previous = performance.now();
const sample = (now: number) => {
intervals.push(now - previous);
previous = now;
if (document.querySelector("[data-image-reveal]")) requestAnimationFrame(sample);
else {
intervals.sort((a, b) => a - b);
resolve({
count: intervals.length,
medianMs: intervals[Math.floor(intervals.length / 2)] ?? 0,
p95Ms: intervals[Math.floor(intervals.length * 0.95)] ?? 0,
over33ms: intervals.filter((ms) => ms > 33.4).length,
});
}
};
requestAnimationFrame(sample);
},
),
);
const after = await profiler.send("Performance.getMetrics");
const delta = (name: string) =>
(after.metrics.find((metric) => metric.name === name)?.value ?? 0) -
(before.metrics.find((metric) => metric.name === name)?.value ?? 0);
const report = {
persona: scene.id,
cpuThrottle: profileRate,
viewport: page.viewportSize(),
frames,
layoutCount: delta("LayoutCount"),
layoutMs: delta("LayoutDuration") * 1000,
scriptMs: delta("ScriptDuration") * 1000,
taskMs: delta("TaskDuration") * 1000,
};
await writeFile(`${dir}/performance-${profileRate}x.json`, JSON.stringify(report, null, 2));
console.log(JSON.stringify(report));
await expect(page.locator("[data-image-reveal]")).toHaveCount(0);
return;
}
// Capture the transient pixels immediately; Playwright screenshots wait for web fonts.
const screenshots = await page.context().newCDPSession(page);
await page.waitForTimeout(150);
const coarse = await screenshots.send("Page.captureScreenshot", { format: "png" });
await writeFile(`${dir}/product-summon.png`, Buffer.from(coarse.data, "base64"));
await page.waitForTimeout(300);
const pixels = await screenshots.send("Page.captureScreenshot", { format: "png" });
await writeFile(`${dir}/product-pixels.png`, Buffer.from(pixels.data, "base64"));
await expect(page.locator('[data-image-reveal="reveal"]')).toBeVisible();
await page.waitForTimeout(300);
const clear = await screenshots.send("Page.captureScreenshot", { format: "png" });
await writeFile(`${dir}/product-reveal.png`, Buffer.from(clear.data, "base64"));
await screenshots.detach();
await expect(page.locator("[data-image-reveal]")).toHaveCount(0, { timeout: 10000 });
await page.waitForTimeout(600);
await page.screenshot({ path: `${dir}/product-dialogue.png` });
const savedImage = await page.locator("[data-image-slot] img").last().getAttribute("src");
if (savedImage?.startsWith("data:"))
await writeFile(`${dir}/generated.webp`, Buffer.from(savedImage.split(",")[1]!, "base64"));
await page.waitForTimeout(1600);
await writeFile(
`${dir}/product-timing.json`,
JSON.stringify({
summon,
ink,
title: demoDesign(scene.id).title[0],
source: "Scripted dialogue; live Rev Animated image replay",
video: testInfo.outputPath("video.webm"),
}),
);
});