mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 10:08:46 +02:00
Add Playwright assistant regression coverage
This commit is contained in:
127
frontend/e2e/feature-doc.ts
Normal file
127
frontend/e2e/feature-doc.ts
Normal 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,
|
||||
"",
|
||||
``,
|
||||
"",
|
||||
]),
|
||||
].join("\n");
|
||||
fs.writeFileSync(path.join(this.dir, "index.md"), md);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user