mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 09:08:46 +02:00
Add Playwright assistant regression coverage
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -16,3 +16,8 @@ tech_docs/
|
|||||||
.claude/
|
.claude/
|
||||||
.venv/
|
.venv/
|
||||||
frontend/openapi.json
|
frontend/openapi.json
|
||||||
|
|
||||||
|
# playwright (demo artifacts are generated, not committed)
|
||||||
|
frontend/test-results/
|
||||||
|
frontend/playwright-report/
|
||||||
|
frontend/e2e-docs/
|
||||||
|
|||||||
113
frontend/e2e/asistente.spec.ts
Normal file
113
frontend/e2e/asistente.spec.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
test.setTimeout(150_000);
|
||||||
|
|
||||||
|
async function startFreshAssistant(page: Page) {
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.getByLabel("Username").fill(process.env.ADMIN_USERNAME!);
|
||||||
|
await page.getByLabel("Password").fill(process.env.ADMIN_PASSWORD!);
|
||||||
|
await page.getByRole("button", { name: /sign in/i }).click();
|
||||||
|
await page.waitForURL("/");
|
||||||
|
|
||||||
|
const initialConnect = page.waitForResponse((response) => {
|
||||||
|
if (!response.url().includes("/api/copilotkit") || response.request().method() !== "POST") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(response.request().postData() ?? "{}").method === "agent/connect";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await page.goto("/asistente");
|
||||||
|
await expect(page.getByRole("heading", { name: "Asistente" })).toBeVisible();
|
||||||
|
await (await initialConnect).finished();
|
||||||
|
|
||||||
|
const newConversation = page.getByRole("button", { name: "Nueva conversación" });
|
||||||
|
if (await newConversation.isEnabled()) {
|
||||||
|
await newConversation.click();
|
||||||
|
const reconnect = page.waitForResponse((response) => {
|
||||||
|
if (!response.url().includes("/api/copilotkit") || response.request().method() !== "POST") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(response.request().postData() ?? "{}").method === "agent/connect";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await page.getByRole("button", { name: "Borrar historial" }).click();
|
||||||
|
await (await reconnect).finished();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("textbox", { name: "Escribe tu pregunta…" }),
|
||||||
|
).toBeEnabled({ timeout: 30_000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ask(page: Page, question: string, expectedTool: string) {
|
||||||
|
// The BFF response is the exact CopilotKit stream consumed by the browser.
|
||||||
|
// Checking it catches server-side RUN_ERROR events even if the UI changes.
|
||||||
|
const stream = page.waitForResponse(
|
||||||
|
(response) => {
|
||||||
|
if (
|
||||||
|
!response.url().includes("/api/copilotkit") ||
|
||||||
|
response.request().method() !== "POST"
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(response.request().postData() ?? "{}").method === "agent/run";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const composer = page.getByRole("textbox", { name: "Escribe tu pregunta…" });
|
||||||
|
await expect(composer).toBeEnabled({ timeout: 30_000 });
|
||||||
|
await composer.fill(question);
|
||||||
|
const sendButton = page.getByTestId("copilot-send-button");
|
||||||
|
await expect(sendButton).toBeEnabled({ timeout: 30_000 });
|
||||||
|
await sendButton.click();
|
||||||
|
await expect(page.getByText(question, { exact: true }).last()).toBeVisible();
|
||||||
|
|
||||||
|
const response = await stream;
|
||||||
|
await response.finished();
|
||||||
|
const events = await response.text();
|
||||||
|
expect(events).not.toContain("RUN_ERROR");
|
||||||
|
expect(events).toContain(expectedTool);
|
||||||
|
|
||||||
|
// The HTTP stream may finish before CopilotKit has committed the tool run
|
||||||
|
// and released its client-side running state. Waiting for the page action
|
||||||
|
// avoids losing the next Enter keypress during that short interval.
|
||||||
|
await expect(page.getByRole("button", { name: "Nueva conversación" })).toBeEnabled({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Asistente completes Luna's read-only tool flows", async ({ page }) => {
|
||||||
|
const consoleErrors: string[] = [];
|
||||||
|
page.on("console", (message) => {
|
||||||
|
if (message.type() === "error") consoleErrors.push(message.text());
|
||||||
|
});
|
||||||
|
|
||||||
|
await startFreshAssistant(page);
|
||||||
|
|
||||||
|
await ask(page, "What date is it today?", "get_current_date");
|
||||||
|
await ask(page, "List my last 10 transactions.", "get_recent_transactions");
|
||||||
|
await ask(page, "What is my net worth?", "get_net_worth");
|
||||||
|
await ask(page, "How much did I spend today?", "get_daily_spending");
|
||||||
|
await ask(page, "Show my spending by category this month.", "render_spending_summary");
|
||||||
|
|
||||||
|
const spendingCard = page.getByTestId("spending-summary-card");
|
||||||
|
await expect(spendingCard).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(page.getByRole("textbox", { name: "Escribe tu pregunta…" })).toBeEnabled({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByRole("heading", { name: "Asistente" })).toBeVisible();
|
||||||
|
await expect(page.getByText("Show my spending by category this month.", { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByTestId("spending-summary-card")).toBeVisible();
|
||||||
|
|
||||||
|
expect(consoleErrors.join("\n")).not.toContain("ChatClientException");
|
||||||
|
});
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
102
frontend/e2e/financiamientos-demo.spec.ts
Normal file
102
frontend/e2e/financiamientos-demo.spec.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { FeatureDoc } from "./feature-doc";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Demo walkthrough of the Financiamientos (Tasa Cero) feature. Doubles as an
|
||||||
|
* e2e smoke test and as the generator for e2e-docs/financiamientos/ — a
|
||||||
|
* captioned, screenshot-per-step Markdown doc plus the full video (native
|
||||||
|
* cursor/chapters, burned-in subtitles, spoken narration) in the Playwright
|
||||||
|
* HTML report.
|
||||||
|
*/
|
||||||
|
// NOTE: do not `test.use({ video: "off" })` here — disabling the fixture
|
||||||
|
// video breaks page.screencast's internal sizing (verified: it then records
|
||||||
|
// a shrunken frame letterboxed inside the requested canvas). The fixture's
|
||||||
|
// own video ends up redundant/unused but harmless.
|
||||||
|
|
||||||
|
test("@demo Financiamientos — compras Tasa Cero en cuotas", async ({ page }, testInfo) => {
|
||||||
|
const doc = new FeatureDoc(
|
||||||
|
page,
|
||||||
|
testInfo,
|
||||||
|
"financiamientos",
|
||||||
|
"Financiamientos — Tasa Cero",
|
||||||
|
"WealthySmart trackea las compras BAC Tasa Cero como planes de cuotas mensuales: cada cuota futura ya cuenta en el presupuesto del mes que le toca, en lugar de registrar la compra como un solo pago.",
|
||||||
|
);
|
||||||
|
await doc.start();
|
||||||
|
|
||||||
|
await doc.step(
|
||||||
|
"Iniciar sesión",
|
||||||
|
"El acceso está protegido con usuario y contraseña; la sesión vive en una cookie HTTP-only.",
|
||||||
|
async () => {
|
||||||
|
await page.goto("/login");
|
||||||
|
// CopilotKit mounts its dev web-inspector in dev builds; keep it out of
|
||||||
|
// the recording — it isn't part of the product.
|
||||||
|
await page.addStyleTag({ content: "cpk-web-inspector { display: none !important; }" });
|
||||||
|
await page.getByLabel("Username").fill(process.env.ADMIN_USERNAME!);
|
||||||
|
await page.getByLabel("Password").fill(process.env.ADMIN_PASSWORD!);
|
||||||
|
await expect(page.getByRole("button", { name: /sign in/i })).toBeEnabled();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await doc.step(
|
||||||
|
"Panel de inicio",
|
||||||
|
"Después del login se llega al dashboard Inicio, con el resumen del mes y la navegación lateral por módulos.",
|
||||||
|
async () => {
|
||||||
|
await page.getByRole("button", { name: /sign in/i }).click();
|
||||||
|
await page.waitForURL("/");
|
||||||
|
await expect(page.getByRole("link", { name: "Financiamientos", exact: true })).toBeVisible();
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await doc.step(
|
||||||
|
"Modo privacidad",
|
||||||
|
"El botón de privacidad difumina todos los montos sensibles — útil para compartir pantalla (y para este demo).",
|
||||||
|
async () => {
|
||||||
|
await page.getByRole("button", { name: "Toggle privacy mode" }).click();
|
||||||
|
await expect(page.locator("html")).toHaveClass(/privacy/);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await doc.step(
|
||||||
|
"Página de Financiamientos",
|
||||||
|
"El módulo resume todos los planes Tasa Cero: saldo faltante total, cuánto suman las cuotas de los planes activos cada mes, y cuántos planes siguen vivos.",
|
||||||
|
async () => {
|
||||||
|
await page.getByRole("link", { name: "Financiamientos", exact: true }).click();
|
||||||
|
await page.waitForURL("/financiamientos");
|
||||||
|
await expect(page.getByText("Saldo faltante total")).toBeVisible();
|
||||||
|
await expect(page.getByText("Cuotas por mes (activos)")).toBeVisible();
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await doc.step(
|
||||||
|
"Tabla de planes",
|
||||||
|
"Cada plan muestra el comercio, el avance de cuotas (facturadas vs. totales), el monto de la cuota, el saldo que falta y cuándo cae la próxima cuota.",
|
||||||
|
async () => {
|
||||||
|
const rows = page.getByRole("button", { name: /editar plan de/i });
|
||||||
|
await expect(rows.first()).toBeVisible();
|
||||||
|
expect(await rows.count()).toBeGreaterThan(0);
|
||||||
|
await expect(page.getByRole("columnheader", { name: "Próxima cuota" })).toBeVisible();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await doc.step(
|
||||||
|
"Detalle de un plan",
|
||||||
|
"Al hacer clic en un plan se abre su detalle editable: fecha ancla, número de cuotas y monto. Desde aquí también se puede deshacer la conversión a Tasa Cero.",
|
||||||
|
async () => {
|
||||||
|
await page.getByRole("button", { name: /editar plan de/i }).first().click();
|
||||||
|
await expect(page.getByRole("heading", { name: "Editar plan Tasa Cero" })).toBeVisible();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await doc.step(
|
||||||
|
"Cerrar el detalle",
|
||||||
|
"El plan queda igual que estaba — el recorrido es de solo lectura.",
|
||||||
|
async () => {
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(page.getByRole("heading", { name: "Editar plan Tasa Cero" })).toBeHidden();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await doc.save();
|
||||||
|
});
|
||||||
59
frontend/e2e/narration.ts
Normal file
59
frontend/e2e/narration.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { execFile, execFileSync } from "node:child_process";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
let sayChecked = false;
|
||||||
|
let sayAvailable = false;
|
||||||
|
|
||||||
|
function hasSay() {
|
||||||
|
if (!sayChecked) {
|
||||||
|
sayChecked = true;
|
||||||
|
try {
|
||||||
|
execFileSync("say", ["-v", "?"], { stdio: "ignore" });
|
||||||
|
sayAvailable = true;
|
||||||
|
} catch {
|
||||||
|
console.warn("`say` not found — skipping TTS narration (subtitles still work).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sayAvailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache = new Map<string, Promise<{ path: string; durationMs: number } | null>>();
|
||||||
|
|
||||||
|
/** Synthesizes Spanish narration for a caption via macOS `say`. Returns null (never throws) if unavailable. */
|
||||||
|
export function synthesize(
|
||||||
|
text: string,
|
||||||
|
outDir: string,
|
||||||
|
index: number,
|
||||||
|
voice = "Paulina",
|
||||||
|
): Promise<{ path: string; durationMs: number } | null> {
|
||||||
|
const key = `${voice}:${text}`;
|
||||||
|
const cached = cache.get(key);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const promise = (async () => {
|
||||||
|
if (!hasSay()) return null;
|
||||||
|
fs.mkdirSync(outDir, { recursive: true });
|
||||||
|
const file = path.join(outDir, `narration-${String(index).padStart(2, "0")}.aiff`);
|
||||||
|
try {
|
||||||
|
await execFileAsync("say", ["-v", voice, "-o", file, text]);
|
||||||
|
const { stdout } = await execFileAsync("ffprobe", [
|
||||||
|
"-v", "error",
|
||||||
|
"-show_entries", "format=duration",
|
||||||
|
"-of", "csv=p=0",
|
||||||
|
file,
|
||||||
|
]);
|
||||||
|
const durationMs = Math.round(parseFloat(stdout.trim()) * 1000);
|
||||||
|
return { path: file, durationMs };
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`Narration synthesis failed for "${text.slice(0, 40)}…": ${err}`);
|
||||||
|
fs.rmSync(file, { force: true });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
cache.set(key, promise);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
33
frontend/e2e/subtitles.ts
Normal file
33
frontend/e2e/subtitles.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
export interface Clip {
|
||||||
|
title: string;
|
||||||
|
caption: string;
|
||||||
|
file: string;
|
||||||
|
startMs: number;
|
||||||
|
endMs: number;
|
||||||
|
narration: { path: string; durationMs: number } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function msToSrtTimestamp(ms: number): string {
|
||||||
|
const clamped = Math.max(0, Math.round(ms));
|
||||||
|
const hours = Math.floor(clamped / 3_600_000);
|
||||||
|
const minutes = Math.floor((clamped % 3_600_000) / 60_000);
|
||||||
|
const seconds = Math.floor((clamped % 60_000) / 1000);
|
||||||
|
const millis = clamped % 1000;
|
||||||
|
const pad = (n: number, len = 2) => String(n).padStart(len, "0");
|
||||||
|
return `${pad(hours)}:${pad(minutes)}:${pad(seconds)},${pad(millis, 3)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One subtitle cue per step, spanning its full on-screen duration. */
|
||||||
|
export function buildSrt(clips: Clip[]): string {
|
||||||
|
return clips
|
||||||
|
.map((clip, i) => {
|
||||||
|
const index = i + 1;
|
||||||
|
return [
|
||||||
|
String(index),
|
||||||
|
`${msToSrtTimestamp(clip.startMs)} --> ${msToSrtTimestamp(clip.endMs)}`,
|
||||||
|
clip.caption,
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
88
frontend/e2e/video-render.ts
Normal file
88
frontend/e2e/video-render.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { execFile, execFileSync } from "node:child_process";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { buildSrt, type Clip } from "./subtitles";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
function hasFfmpeg() {
|
||||||
|
try {
|
||||||
|
execFileSync("ffmpeg", ["-version"], { stdio: "ignore" });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUBTITLE_STYLE =
|
||||||
|
"FontSize=22,PrimaryColour=&H00FFFFFF,BackColour=&H80000000,BorderStyle=3,Outline=1,Shadow=0,MarginV=40";
|
||||||
|
|
||||||
|
function escapeForFilter(p: string) {
|
||||||
|
// ffmpeg filter arguments treat ':' and '\' specially — escape both.
|
||||||
|
return p.replace(/\\/g, "\\\\").replace(/:/g, "\\:");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderFinalVideo({
|
||||||
|
rawWebmPath,
|
||||||
|
clips,
|
||||||
|
outDir,
|
||||||
|
}: {
|
||||||
|
rawWebmPath: string;
|
||||||
|
clips: Clip[];
|
||||||
|
outDir: string;
|
||||||
|
}): Promise<{ videoFile: string; degraded: string[] }> {
|
||||||
|
const degraded: string[] = [];
|
||||||
|
const srtPath = path.join(outDir, "captions.srt");
|
||||||
|
fs.writeFileSync(srtPath, buildSrt(clips));
|
||||||
|
|
||||||
|
if (!hasFfmpeg()) {
|
||||||
|
const dest = path.join(outDir, "video.webm");
|
||||||
|
fs.renameSync(rawWebmPath, dest);
|
||||||
|
degraded.push("ffmpeg not found — kept raw video.webm, no subtitles burned in, no narration.");
|
||||||
|
return { videoFile: dest, degraded };
|
||||||
|
}
|
||||||
|
|
||||||
|
const narrated = clips.filter((c) => c.narration);
|
||||||
|
const mp4Path = path.join(outDir, "video.mp4");
|
||||||
|
const srtArg = `subtitles=${escapeForFilter(srtPath)}:force_style='${SUBTITLE_STYLE}'`;
|
||||||
|
|
||||||
|
if (narrated.length === 0) {
|
||||||
|
degraded.push("No narration available — burned-in subtitles only.");
|
||||||
|
await execFileAsync("ffmpeg", [
|
||||||
|
"-y", "-i", rawWebmPath,
|
||||||
|
"-vf", srtArg,
|
||||||
|
"-c:v", "libx264", "-crf", "20", "-pix_fmt", "yuv420p",
|
||||||
|
"-movflags", "+faststart",
|
||||||
|
"-an",
|
||||||
|
mp4Path,
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
const audioInputs = narrated.flatMap((c) => ["-i", c.narration!.path]);
|
||||||
|
const adelayParts = narrated.map(
|
||||||
|
(c, i) => `[${i + 1}:a]adelay=${Math.max(0, Math.round(c.startMs))}|${Math.max(0, Math.round(c.startMs))}[a${i}]`,
|
||||||
|
);
|
||||||
|
const mixInputs = narrated.map((_, i) => `[a${i}]`).join("");
|
||||||
|
const filterComplex = [
|
||||||
|
`[0:v]${srtArg}[vout]`,
|
||||||
|
...adelayParts,
|
||||||
|
`${mixInputs}amix=inputs=${narrated.length}:normalize=0[aout]`,
|
||||||
|
].join(";");
|
||||||
|
|
||||||
|
await execFileAsync("ffmpeg", [
|
||||||
|
"-y", "-i", rawWebmPath, ...audioInputs,
|
||||||
|
"-filter_complex", filterComplex,
|
||||||
|
"-map", "[vout]", "-map", "[aout]",
|
||||||
|
"-c:v", "libx264", "-crf", "20", "-pix_fmt", "yuv420p",
|
||||||
|
"-c:a", "aac",
|
||||||
|
"-shortest",
|
||||||
|
"-movflags", "+faststart",
|
||||||
|
mp4Path,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.rmSync(rawWebmPath, { force: true });
|
||||||
|
for (const c of narrated) fs.rmSync(c.narration!.path, { force: true });
|
||||||
|
|
||||||
|
return { videoFile: mp4Path, degraded };
|
||||||
|
}
|
||||||
52
frontend/playwright.config.ts
Normal file
52
frontend/playwright.config.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { defineConfig } from "@playwright/test";
|
||||||
|
|
||||||
|
// Demo specs log in with the real dev credentials; pull them from the repo
|
||||||
|
// .env so they never live in the test source.
|
||||||
|
const repoEnv = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.env");
|
||||||
|
if (fs.existsSync(repoEnv)) {
|
||||||
|
for (const line of fs.readFileSync(repoEnv, "utf8").split("\n")) {
|
||||||
|
const m = line.match(/^([A-Z_]+)=["']?(.*?)["']?$/);
|
||||||
|
if (m && !(m[1] in process.env)) process.env[m[1]] = m[2];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./e2e",
|
||||||
|
outputDir: "./test-results",
|
||||||
|
fullyParallel: false,
|
||||||
|
// Demo runs synthesize narration and run several ffmpeg passes in save();
|
||||||
|
// that can exceed the 30s default, especially with many steps.
|
||||||
|
timeout: process.env.PW_DEMO ? 120_000 : 30_000,
|
||||||
|
reporter: [
|
||||||
|
["list"],
|
||||||
|
["html", { outputFolder: "playwright-report", open: "never" }],
|
||||||
|
],
|
||||||
|
use: {
|
||||||
|
baseURL: "http://localhost:3000",
|
||||||
|
// Demo runs (pnpm demo) pace every action so the recorded video is
|
||||||
|
// watchable by a human; functional e2e runs stay at full speed.
|
||||||
|
launchOptions: {
|
||||||
|
slowMo: process.env.PW_DEMO ? 500 : 0,
|
||||||
|
},
|
||||||
|
locale: "es-CR",
|
||||||
|
timezoneId: "America/Costa_Rica",
|
||||||
|
viewport: { width: 1920, height: 1080 },
|
||||||
|
// Retina-density rendering so doc screenshots stay crisp when zoomed.
|
||||||
|
deviceScaleFactor: 2,
|
||||||
|
// Full recordings are only useful for the paced demo. Keeping them off
|
||||||
|
// for regular e2e avoids making test success depend on artifact uploads.
|
||||||
|
video: process.env.PW_DEMO ? { mode: "on", size: { width: 1920, height: 1080 } } : "off",
|
||||||
|
screenshot: "only-on-failure",
|
||||||
|
trace: process.env.PW_DEMO ? "on" : "on-first-retry",
|
||||||
|
},
|
||||||
|
webServer: {
|
||||||
|
command: "pnpm dev",
|
||||||
|
url: "http://localhost:3000",
|
||||||
|
reuseExistingServer: true,
|
||||||
|
stdout: "ignore",
|
||||||
|
timeout: 60_000,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -76,7 +76,10 @@ export function SpendingSummaryCard({
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-border bg-card text-card-foreground p-4 space-y-4 my-2 shadow-sm">
|
<div
|
||||||
|
data-testid="spending-summary-card"
|
||||||
|
className="rounded-xl border border-border bg-card text-card-foreground p-4 space-y-4 my-2 shadow-sm"
|
||||||
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
Reference in New Issue
Block a user