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// 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) { 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); } }