Add Playwright assistant regression coverage
Some checks failed
Deploy to VPS / test (push) Failing after 1m37s
Deploy to VPS / deploy (push) Has been skipped

This commit is contained in:
Carlos Escalante
2026-07-14 21:48:59 -06:00
parent 301a0b687a
commit fbc0816be6
9 changed files with 583 additions and 1 deletions

127
frontend/e2e/feature-doc.ts Normal file
View File

@@ -0,0 +1,127 @@
import fs from "node:fs";
import path from "node:path";
import { test, type Page, type TestInfo } from "@playwright/test";
import * as narration from "./narration";
import { renderFinalVideo } from "./video-render";
import type { Clip } from "./subtitles";
const DEMO = !!process.env.PW_DEMO;
/**
* Wraps test.step() so every step of a demo spec produces a captioned
* screenshot, and — in PW_DEMO mode — a native Playwright screencast
* recording with cursor annotations, chapter-title cards, burned-in
* subtitles and (when `say` is available) spoken Spanish narration.
* Screenshots/video/subtitles land in e2e-docs/<slug>/ and are also
* attached to the Playwright HTML report; save() stitches everything into
* a Markdown walkthrough that can be published as-is.
*/
export class FeatureDoc {
private steps: { title: string; caption: string; file: string }[] = [];
private clips: Clip[] = [];
private readonly dir: string;
private recordingStartedAt = 0;
constructor(
private page: Page,
private testInfo: TestInfo,
private slug: string,
private title: string,
private intro: string,
) {
this.dir = path.resolve("e2e-docs", slug);
fs.rmSync(this.dir, { recursive: true, force: true });
fs.mkdirSync(this.dir, { recursive: true });
}
/** Starts the native screencast recording. Call before the first step(). */
async start() {
if (!DEMO) return;
this.recordingStartedAt = Date.now();
await this.page.screencast.start({
path: path.join(this.dir, "raw.webm"),
size: { width: 1920, height: 1080 },
});
await this.page.screencast.showActions({ cursor: "pointer" });
}
async step(title: string, caption: string, fn: () => Promise<void>) {
const narrationPromise = DEMO
? narration.synthesize(caption, this.testInfo.outputDir, this.clips.length)
: Promise.resolve(null);
const stepStart = Date.now();
if (DEMO) {
await this.page.screencast.showChapter(title, { description: caption, duration: 1500 });
}
await test.step(title, fn);
const narrationResult = await narrationPromise;
if (DEMO) {
const elapsed = Date.now() - stepStart;
const target = (narrationResult?.durationMs ?? 0) + 300;
if (target > elapsed) await this.page.waitForTimeout(target - elapsed);
}
const file = `${String(this.steps.length + 1).padStart(2, "0")}-${title
.toLowerCase()
.normalize("NFD")
.replace(/[̀-ͯ]+/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")}.png`;
const body = await this.page.screenshot({
path: path.join(this.dir, file),
animations: "disabled",
});
await this.testInfo.attach(title, { body, contentType: "image/png" });
this.steps.push({ title, caption, file });
if (DEMO) {
this.clips.push({
title,
caption,
file,
startMs: stepStart - this.recordingStartedAt,
endMs: Date.now() - this.recordingStartedAt,
narration: narrationResult,
});
}
}
async save() {
let videoName = "video.mp4";
if (DEMO) {
await this.page.screencast.stop();
const { videoFile, degraded } = await renderFinalVideo({
rawWebmPath: path.join(this.dir, "raw.webm"),
clips: this.clips,
outDir: this.dir,
});
for (const warning of degraded) console.warn(`[${this.slug}] ${warning}`);
videoName = path.basename(videoFile);
await this.testInfo.attach("video", {
path: videoFile,
contentType: videoName.endsWith(".mp4") ? "video/mp4" : "video/webm",
});
}
const md = [
`# ${this.title}`,
"",
this.intro,
"",
`> Generado automáticamente por \`${path.basename(this.testInfo.file)}\`${this.steps.length} pasos. El video completo del recorrido está en [${videoName}](${videoName}).`,
"",
...this.steps.flatMap((s, i) => [
`## ${i + 1}. ${s.title}`,
"",
s.caption,
"",
`![${s.title}](${s.file})`,
"",
]),
].join("\n");
fs.writeFileSync(path.join(this.dir, "index.md"), md);
}
}