From fbc0816be6ac79d052beeb7640d65bf89fa0e5cd Mon Sep 17 00:00:00 2001 From: Carlos Escalante Date: Tue, 14 Jul 2026 21:48:59 -0600 Subject: [PATCH] Add Playwright assistant regression coverage --- .gitignore | 5 + frontend/e2e/asistente.spec.ts | 113 ++++++++++++++++++ frontend/e2e/feature-doc.ts | 127 +++++++++++++++++++++ frontend/e2e/financiamientos-demo.spec.ts | 102 +++++++++++++++++ frontend/e2e/narration.ts | 59 ++++++++++ frontend/e2e/subtitles.ts | 33 ++++++ frontend/e2e/video-render.ts | 88 ++++++++++++++ frontend/playwright.config.ts | 52 +++++++++ frontend/src/components/chat/ChatCards.tsx | 5 +- 9 files changed, 583 insertions(+), 1 deletion(-) create mode 100644 frontend/e2e/asistente.spec.ts create mode 100644 frontend/e2e/feature-doc.ts create mode 100644 frontend/e2e/financiamientos-demo.spec.ts create mode 100644 frontend/e2e/narration.ts create mode 100644 frontend/e2e/subtitles.ts create mode 100644 frontend/e2e/video-render.ts create mode 100644 frontend/playwright.config.ts diff --git a/.gitignore b/.gitignore index 70a7bc6..9625f75 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,8 @@ tech_docs/ .claude/ .venv/ frontend/openapi.json + +# playwright (demo artifacts are generated, not committed) +frontend/test-results/ +frontend/playwright-report/ +frontend/e2e-docs/ diff --git a/frontend/e2e/asistente.spec.ts b/frontend/e2e/asistente.spec.ts new file mode 100644 index 0000000..f933cc6 --- /dev/null +++ b/frontend/e2e/asistente.spec.ts @@ -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"); +}); diff --git a/frontend/e2e/feature-doc.ts b/frontend/e2e/feature-doc.ts new file mode 100644 index 0000000..9b3c257 --- /dev/null +++ b/frontend/e2e/feature-doc.ts @@ -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// 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); + } +} diff --git a/frontend/e2e/financiamientos-demo.spec.ts b/frontend/e2e/financiamientos-demo.spec.ts new file mode 100644 index 0000000..0414320 --- /dev/null +++ b/frontend/e2e/financiamientos-demo.spec.ts @@ -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(); +}); diff --git a/frontend/e2e/narration.ts b/frontend/e2e/narration.ts new file mode 100644 index 0000000..ba6eb54 --- /dev/null +++ b/frontend/e2e/narration.ts @@ -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>(); + +/** 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; +} diff --git a/frontend/e2e/subtitles.ts b/frontend/e2e/subtitles.ts new file mode 100644 index 0000000..a95f3f0 --- /dev/null +++ b/frontend/e2e/subtitles.ts @@ -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"); +} diff --git a/frontend/e2e/video-render.ts b/frontend/e2e/video-render.ts new file mode 100644 index 0000000..27504ce --- /dev/null +++ b/frontend/e2e/video-render.ts @@ -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 }; +} diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..5f99e6b --- /dev/null +++ b/frontend/playwright.config.ts @@ -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, + }, +}); diff --git a/frontend/src/components/chat/ChatCards.tsx b/frontend/src/components/chat/ChatCards.tsx index d52fdf5..09a84ae 100644 --- a/frontend/src/components/chat/ChatCards.tsx +++ b/frontend/src/components/chat/ChatCards.tsx @@ -76,7 +76,10 @@ export function SpendingSummaryCard({ ]; return ( -
+
{/* Header */}