Compare commits

...

3 Commits

Author SHA1 Message Date
Carlos Escalante
fbc0816be6 Add Playwright assistant regression coverage
Some checks failed
Deploy to VPS / test (push) Failing after 1m37s
Deploy to VPS / deploy (push) Has been skipped
2026-07-14 21:48:59 -06:00
Carlos Escalante
301a0b687a Use Responses API with Luna by default 2026-07-14 21:48:54 -06:00
Carlos Escalante
b57a899634 Upgrade agent and CopilotKit dependencies 2026-07-14 21:48:40 -06:00
20 changed files with 1489 additions and 1031 deletions

View File

@@ -23,4 +23,4 @@ VAPID_PUBLIC_KEY=
# ── AI agent (optional in dev; Asistente won't work without it) ──────────────
OPENAI_API_KEY=
AGENT_MODEL=gpt-5.4-mini
AGENT_MODEL=gpt-5.6-luna

View File

@@ -51,7 +51,7 @@ jobs:
VAPID_PRIVATE_KEY=${{ secrets.VAPID_PRIVATE_KEY }}
VAPID_PUBLIC_KEY=${{ secrets.VAPID_PUBLIC_KEY }}
OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY }}
AGENT_MODEL=gpt-5.4-mini
AGENT_MODEL=gpt-5.6-luna
ENVEOF
sed -i 's/^[[:space:]]*//' .env.prod

5
.gitignore vendored
View File

@@ -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/

View File

@@ -3,7 +3,7 @@
from __future__ import annotations
from agent_framework import Agent
from agent_framework.openai import OpenAIChatCompletionClient
from agent_framework.openai import OpenAIChatClient
from app.config import settings
from app.agent.tools import TOOLS
@@ -83,7 +83,9 @@ Generative UI — render tools:
def build_agent() -> Agent:
client = OpenAIChatCompletionClient(
# Use the Responses API for every configured model. It preserves one tool
# transport across model changes and supports Luna's tool-result turns.
client = OpenAIChatClient(
api_key=settings.OPENAI_API_KEY,
model=settings.AGENT_MODEL,
)

View File

@@ -20,7 +20,7 @@ class Settings(BaseSettings):
VAPID_PUBLIC_KEY: str = ""
VAPID_CLAIM_EMAIL: str = "mailto:admin@wealth.cescalante.dev"
OPENAI_API_KEY: str = ""
AGENT_MODEL: str = "gpt-5.4-mini"
AGENT_MODEL: str = "gpt-5.6-luna"
@property
def cors_origins_list(self) -> list[str]:

View File

@@ -1,8 +1,8 @@
a2a-sdk==0.3.23
ag-ui-protocol==0.1.19
agent-framework==1.10.0
agent-framework==1.11.0
agent-framework-a2a==1.0.0b260428
agent-framework-ag-ui==1.0.0rc7
agent-framework-ag-ui==1.0.0rc8
agent-framework-anthropic==1.0.0b260428
agent-framework-azure-ai-search==1.0.0b260428
agent-framework-azure-cosmos==1.0.0b260428
@@ -11,7 +11,7 @@ agent-framework-bedrock==1.0.0b260428
agent-framework-chatkit==1.0.0b260428
agent-framework-claude==1.0.0b260428
agent-framework-copilotstudio==1.0.0b260428
agent-framework-core==1.10.0
agent-framework-core==1.11.0
agent-framework-declarative==1.0.0b260428
agent-framework-devui==1.0.0b260428
agent-framework-durabletask==1.0.0b260428
@@ -21,7 +21,7 @@ agent-framework-github-copilot==1.0.0b260402
agent-framework-lab==1.0.0b251024
agent-framework-mem0==1.0.0b260428
agent-framework-ollama==1.0.0b260428
agent-framework-openai==1.10.0
agent-framework-openai==1.10.1
agent-framework-orchestrations==1.0.0b260428
agent-framework-purview==1.0.0b260428
agent-framework-redis==1.0.0b260428
@@ -165,7 +165,7 @@ six==1.17.0
sniffio==1.3.1
SQLAlchemy==2.0.50
sqlmodel==0.0.38
sse-starlette==3.4.4
sse-starlette==3.4.5
starlette==1.2.1
tenacity==9.1.4
tqdm==4.68.2

View File

@@ -11,6 +11,6 @@ httpx
pywebpush
py-vapid
python-dateutil
agent-framework==1.10.0
agent-framework-ag-ui==1.0.0rc7
agent-framework-openai==1.10.0
agent-framework==1.11.0
agent-framework-ag-ui==1.0.0rc8
agent-framework-openai==1.10.1

View File

@@ -0,0 +1,14 @@
"""Assistant transport configuration."""
from agent_framework.openai import OpenAIChatClient
from app.agent import agent as agent_module
def test_agent_uses_responses_client_for_the_configured_model(monkeypatch):
monkeypatch.setattr(agent_module.settings, "AGENT_MODEL", "gpt-5.6-luna")
agent = agent_module.build_agent()
assert isinstance(agent.client, OpenAIChatClient)
assert agent.client.model == "gpt-5.6-luna"

View File

@@ -31,7 +31,7 @@ services:
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
OPENAI_API_KEY: ${OPENAI_API_KEY}
AGENT_MODEL: ${AGENT_MODEL:-gpt-5.4-mini}
AGENT_MODEL: ${AGENT_MODEL:-gpt-5.6-luna}
# MAF 1.6+ enables OTel instrumentation by default; keep it off explicitly.
ENABLE_INSTRUMENTATION: "false"
expose:

View File

@@ -30,7 +30,7 @@ services:
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
AGENT_MODEL: ${AGENT_MODEL:-gpt-5.4-mini}
AGENT_MODEL: ${AGENT_MODEL:-gpt-5.6-luna}
# MAF 1.6+ enables OTel instrumentation by default; keep it off explicitly.
ENABLE_INSTRUMENTATION: "false"
ports:

View 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
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);
}
}

View 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
View 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
View 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");
}

View 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 };
}

View File

@@ -9,14 +9,16 @@
"build": "vite build",
"preview": "tsx server.ts",
"typecheck": "tsc --noEmit",
"e2e": "playwright test",
"demo": "PW_DEMO=1 playwright test --grep @demo",
"gen:api": "openapi-typescript openapi.json -o src/lib/api-types.gen.ts"
},
"dependencies": {
"@ag-ui/client": "0.0.57",
"@base-ui/react": "^1.4.1",
"@copilotkit/react-core": "1.62.2",
"@copilotkit/react-ui": "1.62.2",
"@copilotkit/runtime": "1.62.2",
"@copilotkit/react-core": "1.62.3",
"@copilotkit/react-ui": "1.62.3",
"@copilotkit/runtime": "1.62.3",
"@fontsource-variable/ibm-plex-sans": "^5.2.8",
"@fontsource-variable/noto-sans": "^5.2.10",
"@hono/node-server": "^1.14.4",
@@ -41,6 +43,7 @@
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
"@tailwindcss/vite": "^4",
"@types/node": "^20",
"@types/react": "^19",

View 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,
},
});

1881
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -76,7 +76,10 @@ export function SpendingSummaryCard({
];
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 */}
<div className="flex items-start justify-between gap-4">
<div>