Files
inq-roi-simulador-web/tests/e2e/export-pdf.spec.ts
2026-06-23 22:33:01 -03:00

477 lines
22 KiB
TypeScript

/**
* E2E: Verifica que el export PDF y CSV funcionen de extremo a extremo.
*
* Por cada BPMN: importa → simula → descarga PDF y CSV → verifica tamaño y contenido.
* El test de acentos verifica que los caracteres UTF-8 se preserven en el PDF.
*
* Ejecutar: npx playwright test
*/
import { test, expect } from '@playwright/test'
import { readFileSync, mkdirSync, existsSync } from 'fs'
import { resolve } from 'path'
import { fileURLToPath } from 'url'
import { PDFParse } from 'pdf-parse'
import sharp from 'sharp'
import { PDFDocument, PDFName, PDFRawStream } from 'pdf-lib'
import { createClient } from '@supabase/supabase-js'
test.use({ storageState: 'tests/e2e/setup/auth-state.json' })
const __dirname = fileURLToPath(new URL('.', import.meta.url))
const ROOT = resolve(__dirname, '../..')
const BPMN_DIR = resolve(__dirname, '../../public/sample-processes')
const OUTPUT_DIR = resolve(__dirname, '__output__')
// ─── Cliente Supabase con service_role_key para inyección directa de datos ────
// Reemplaza la inyección vía IndexedDB (Dexie, eliminado en Sprint 4) por escritura
// directa en Supabase, bypaseando RLS con el service_role_key. Ver .env.example.
function loadEnvFile(filename: string): Record<string, string> {
const path = resolve(ROOT, filename)
if (!existsSync(path)) return {}
const out: Record<string, string> = {}
for (const line of readFileSync(path, 'utf-8').split('\n')) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) continue
const idx = trimmed.indexOf('=')
if (idx === -1) continue
out[trimmed.slice(0, idx)] = trimmed.slice(idx + 1)
}
return out
}
const localEnv = loadEnvFile('.env.local')
const testEnv = loadEnvFile('.env.test')
const SUPABASE_URL = localEnv.VITE_SUPABASE_URL
const SERVICE_ROLE_KEY = testEnv.SUPABASE_SERVICE_ROLE_KEY
test.beforeAll(() => {
mkdirSync(OUTPUT_DIR, { recursive: true })
})
// ─── Helper: parsea texto de un buffer PDF con pdf-parse v2 ──────────────────
async function parsePdf(buffer: Buffer): Promise<{ numpages: number; text: string }> {
const parser = new PDFParse({ data: buffer })
const result = await parser.getText()
await parser.destroy()
return { numpages: result.total, text: result.text }
}
// ─── Helper: importa un BPMN, simula y llega al reporte ──────────────────────
async function importAndSimulate(page: import('@playwright/test').Page, bpmnFile: string) {
await page.goto('/import')
// Subir el BPMN vía el input oculto (equivalente a drag-and-drop programático)
const fileInput = page.locator('#bpmn-file-input')
await fileInput.setInputFiles(resolve(BPMN_DIR, bpmnFile))
// Esperar navegación al workspace
await page.waitForURL(/\/workspace\//, { timeout: 15_000 })
// Esperar a que desaparezca el spinner de carga (workspace renderiza el botón Simular)
await page.waitForSelector('button:has-text("Simular")', { timeout: 15_000 })
// Tiempo adicional para que Dexie cargue gateways y actividades en la store
await page.waitForTimeout(1_500)
// Verificar que el botón no está deshabilitado y hacer clic
const simularBtn = page.getByRole('button', { name: 'Simular' })
await expect(simularBtn).not.toBeDisabled({ timeout: 5_000 })
await simularBtn.click()
// Esperar navegación al reporte
await page.waitForURL(/\/report\//, { timeout: 20_000 })
// Esperar a que el reporte cargue y el botón de export sea visible
await page.waitForSelector('button:has-text("Exportar PDF")', { timeout: 30_000 })
// Esperar a que bpmn-js en HeatmapCanvas haya renderizado el diagrama.
// .djs-group es creado por bpmn-js después de importXML() — confirma render real.
await page.waitForSelector('.bpmn-container .djs-group', { timeout: 30_000 })
}
// ─── Casos por BPMN ──────────────────────────────────────────────────────────
const CASES = [
{
file: 'simple-linear.bpmn',
slug: 'simple-linear',
processNameFragment: 'simple-linear',
currency: 'USD',
},
{
file: 'medium-with-gateways.bpmn',
slug: 'medium-with-gateways',
processNameFragment: 'medium-with-gateways',
currency: 'USD',
},
{
file: 'complex-with-loop.bpmn',
slug: 'complex-with-loop',
processNameFragment: 'complex-with-loop',
currency: 'USD',
},
]
for (const c of CASES) {
test(`Export PDF + CSV — ${c.file}`, async ({ page }) => {
await importAndSimulate(page, c.file)
// ── PDF ─────────────────────────────────────────────────────────────────
const pdfPath = resolve(OUTPUT_DIR, `${c.slug}.pdf`)
const pdfDownload = page.waitForEvent('download', { timeout: 90_000 })
await page.getByRole('button', { name: 'Exportar PDF' }).click()
const dl = await pdfDownload
await dl.saveAs(pdfPath)
// Esperar a que el estado "Generando…" desaparezca
await page.waitForSelector('button:has-text("Exportar PDF")', { timeout: 30_000 })
// Verificar tamaño
const pdfBuffer = readFileSync(pdfPath)
const pdfBytes = pdfBuffer.length
console.log(`\n📄 ${c.file} — PDF: ${pdfBytes} bytes (${(pdfBytes / 1024).toFixed(1)} KB)`)
// PDF mínimo: debe tener al menos algo de contenido
expect(pdfBytes, `PDF de ${c.file} debe ser > 40 KB`).toBeGreaterThan(40_000)
expect(pdfBytes, `PDF de ${c.file} debe ser < 2 MB`).toBeLessThan(2_000_000)
// El heatmap JPEG debe estar embebido en el PDF (marker SOI: FF D8 FF).
// Sin actividades con costos reales el JPEG es pequeño (~32 KB, color neutro #cbd5e1)
// pero SIEMPRE debe estar presente cuando el canvas se renderizó correctamente.
const jpegMarker = Buffer.from([0xff, 0xd8, 0xff])
const hasJpeg = pdfBuffer.includes(jpegMarker)
console.log(` Heatmap JPEG embebido: ${hasJpeg}`)
expect(hasJpeg, 'PDF generado sin heatmap embebido — la race condition de captureImage no fue resuelta').toBe(true)
// Verificar contenido
const { numpages, text } = await parsePdf(pdfBuffer)
console.log(` Páginas: ${numpages}`)
console.log(` Texto (primeros 200 chars): ${text.slice(0, 200).replace(/\n/g, ' ')}`)
expect(numpages, 'PDF debe tener al menos 2 páginas').toBeGreaterThanOrEqual(2)
// El KPI de costo aparece en mayúsculas en el PDF: "COSTO TOTAL ESPERADO"
expect(text, 'PDF debe contener "COSTO TOTAL"').toContain('COSTO TOTAL')
expect(text, `PDF debe contener la moneda "${c.currency}"`).toContain(c.currency)
expect(text, 'PDF debe contener el nombre del proceso').toContain(c.processNameFragment)
// ── CSV ─────────────────────────────────────────────────────────────────
const csvPath = resolve(OUTPUT_DIR, `${c.slug}.csv`)
const csvDownload = page.waitForEvent('download')
await page.getByRole('button', { name: 'Exportar CSV' }).click()
const dlCsv = await csvDownload
await dlCsv.saveAs(csvPath)
const csvBuffer = readFileSync(csvPath)
const csvBytes = csvBuffer.length
console.log(` CSV: ${csvBytes} bytes`)
expect(csvBytes, `CSV de ${c.file} debe ser > 100 bytes`).toBeGreaterThan(100)
const csvText = csvBuffer.toString('utf-8')
expect(csvText, 'CSV debe tener BOM UTF-8').toMatch(/^/)
expect(csvText, 'CSV debe contener "Costo total"').toContain('Costo total')
expect(csvText, 'CSV debe contener la moneda').toContain(c.currency)
})
}
// ─── Test especial: acentos y ñ ───────────────────────────────────────────────
test('Export PDF — acentos y ñ se preservan en el PDF (accent-test.bpmn)', async ({ page }) => {
await importAndSimulate(page, 'accent-test.bpmn')
const pdfPath = resolve(OUTPUT_DIR, 'accent-test.pdf')
const pdfDownload = page.waitForEvent('download', { timeout: 90_000 })
await page.getByRole('button', { name: 'Exportar PDF' }).click()
const dl = await pdfDownload
await dl.saveAs(pdfPath)
const pdfBuffer = readFileSync(pdfPath)
const pdfBytes = pdfBuffer.length
console.log(`\n🔤 accent-test.bpmn — PDF: ${pdfBytes} bytes (${(pdfBytes / 1024).toFixed(1)} KB)`)
expect(pdfBytes, 'PDF debe ser > 40 KB').toBeGreaterThan(40_000)
const jpegMarker = Buffer.from([0xff, 0xd8, 0xff])
const hasJpeg = pdfBuffer.includes(jpegMarker)
console.log(` Heatmap JPEG embebido: ${hasJpeg}`)
expect(hasJpeg, 'PDF generado sin heatmap embebido — la race condition de captureImage no fue resuelta').toBe(true)
const { numpages, text } = await parsePdf(pdfBuffer)
console.log(` Páginas: ${numpages}`)
console.log(` Texto completo:\n${text}`)
expect(numpages).toBeGreaterThanOrEqual(2)
// Verificar que los caracteres acentuados se preservan
expect(text, 'PDF debe contener "Análisis de procesos contables"')
.toContain('Análisis de procesos contables')
expect(text, 'PDF debe contener "Validación de información"')
.toContain('Validación de información')
expect(text, 'PDF debe contener "Inspección visual y funcional"')
.toContain('Inspección visual y funcional')
expect(text, 'PDF debe contener "Diseño de campañas"')
.toContain('Diseño de campañas')
// CSV del accent-test
await page.waitForSelector('button:has-text("Exportar CSV")', { timeout: 10_000 })
const csvPath = resolve(OUTPUT_DIR, 'accent-test.csv')
const csvDownload = page.waitForEvent('download')
await page.getByRole('button', { name: 'Exportar CSV' }).click()
const dlCsv = await csvDownload
await dlCsv.saveAs(csvPath)
const csvText = readFileSync(csvPath, 'utf-8')
console.log(` CSV (primeros 400 chars): ${csvText.slice(0, 400)}`)
expect(csvText).toContain('Análisis de procesos contables')
expect(csvText).toContain('Validación de información')
expect(csvText).toContain('Inspección visual y funcional')
expect(csvText).toContain('Diseño de campañas')
})
// ─── Helpers para extracción y análisis de JPEG ───────────────────────────────
/**
* Extrae el primer stream JPEG (DCTDecode) del PDF.
* Intenta con pdf-lib; si falla, usa búsqueda binaria de marcador SOI/EOI.
*/
async function extractJpegFromPdf(pdfBuf: Buffer): Promise<Buffer> {
// Intento 1: pdf-lib (semántico, correcto)
try {
const doc = await PDFDocument.load(pdfBuf, { ignoreEncryption: true })
for (const [, obj] of doc.context.enumerateIndirectObjects()) {
if (obj instanceof PDFRawStream) {
const subtype = obj.dict.lookupMaybe(PDFName.of('Subtype'), PDFName)
const filter = obj.dict.lookupMaybe(PDFName.of('Filter'), PDFName)
if (subtype?.toString() === '/Image' && filter?.toString() === '/DCTDecode') {
return Buffer.from(obj.contents)
}
}
}
} catch {
// pdf-lib no pudo parsear — caer a método binario
}
// Intento 2: búsqueda binaria de marcador JPEG SOI (FF D8 FF)
const soi = Buffer.from([0xff, 0xd8, 0xff])
const start = pdfBuf.indexOf(soi)
if (start === -1) throw new Error('No se encontró ningún JPEG en el PDF')
const endstreamBuf = Buffer.from('endstream')
const endstreamPos = pdfBuf.indexOf(endstreamBuf, start)
if (endstreamPos === -1) throw new Error('No se encontró "endstream" después del JPEG')
// Buscar EOI (FF D9) retrocediendo desde 'endstream'
let eoi = endstreamPos - 1
while (eoi > start + 2) {
if (pdfBuf[eoi] === 0xd9 && pdfBuf[eoi - 1] === 0xff) { eoi++; break }
eoi--
}
const jpeg = pdfBuf.subarray(start, eoi)
if (jpeg.length < 100) throw new Error(`JPEG extraído muy pequeño: ${jpeg.length} bytes`)
return jpeg
}
/**
* Cuenta píxeles por categoría de color en un buffer JPEG.
* Recalibrado Sprint 5 Etapa 3 — gradiente de 4 stops (verde→amarillo→naranja→rojo).
* Colores de referencia con opacity 0.82 sobre fondo #f8fafc (blend = bg·0.18 + color·0.82):
*
* Verde (#10b981 + bg) → RGB ≈ (58, 197, 151)
* Amarillo(#f59e0b + bg) → RGB ≈ (246, 175, 54)
* Naranja (#f97316 + bg) → RGB ≈ (249, 139, 63)
* Rojo (#ef4444 + bg) → RGB ≈ (241, 101, 101)
*
* Clasificación por vecino más cercano (distancia euclidiana en RGB) en vez de desigualdades
* ad-hoc: con 4 stops, amarillo y naranja quedan demasiado cerca para distinguirlos con
* reglas simples de umbral. MAX_DIST excluye fondo/texto/bordes que no son parte del heatmap.
*/
const HEAT_REF_COLORS = {
green: { r: 58, g: 197, b: 151 },
amber: { r: 246, g: 175, b: 54 },
orange: { r: 249, g: 139, b: 63 },
red: { r: 241, g: 101, b: 101 },
} as const
const MAX_HEAT_COLOR_DIST = 40
function colorDistance(a: { r: number; g: number; b: number }, b: { r: number; g: number; b: number }): number {
return Math.sqrt((a.r - b.r) ** 2 + (a.g - b.g) ** 2 + (a.b - b.b) ** 2)
}
async function analyzeJpegColors(jpegBuf: Buffer): Promise<{
greenPx: number; amberPx: number; orangePx: number; redPx: number; total: number
}> {
const { data, info } = await sharp(jpegBuf).raw().toBuffer({ resolveWithObject: true })
const ch = info.channels // 3 para JPEG (RGB)
let greenPx = 0, amberPx = 0, orangePx = 0, redPx = 0
for (let i = 0; i < data.length; i += ch) {
const px = { r: data[i], g: data[i + 1], b: data[i + 2] }
let closestName: keyof typeof HEAT_REF_COLORS | null = null
let closestDist = Infinity
for (const [name, ref] of Object.entries(HEAT_REF_COLORS) as Array<[keyof typeof HEAT_REF_COLORS, typeof HEAT_REF_COLORS['green']]>) {
const dist = colorDistance(px, ref)
if (dist < closestDist) { closestDist = dist; closestName = name }
}
if (closestDist > MAX_HEAT_COLOR_DIST) continue
if (closestName === 'green') greenPx++
else if (closestName === 'amber') amberPx++
else if (closestName === 'orange') orangePx++
else if (closestName === 'red') redPx++
}
return { greenPx, amberPx, orangePx, redPx, total: data.length / ch }
}
// ─── Test: gradiente real con costos dispares ─────────────────────────────────
test.describe('Heatmap con gradiente real', () => {
let createdProcessId: string | null = null
test.afterAll(async () => {
if (createdProcessId && SUPABASE_URL && SERVICE_ROLE_KEY) {
const supabaseAdmin = createClient(SUPABASE_URL, SERVICE_ROLE_KEY)
await supabaseAdmin.from('processes').delete().eq('id', createdProcessId)
}
})
test('Heatmap con gradiente real — medium-with-gateways.bpmn', async ({ page }) => {
test.skip(
!SUPABASE_URL || !SERVICE_ROLE_KEY,
'SUPABASE_SERVICE_ROLE_KEY no configurada en .env.test — ver .env.example para el formato esperado'
)
// Costos dispares por bpmnElementId para forzar varianza significativa.
// Probabilidades de ejecución del proceso: task_recv/valid/analisis/archivo = 1.0,
// los demás ≈ 0.5. Se asignan costos para que:
// task_recv → expectedCost $10.000 → costo más alto → ROJO
// task_analisis → expectedCost $5.000 → mitad del max → ÁMBAR (t ≈ 0.5)
// resto → expectedCost < $500 → costos bajos → VERDE
const COST_BY_ELEMENT: Record<string, number> = {
task_recv: 10_000, // prob 1.0 → expected $10.000 → ROJO
task_valid: 200, // prob 1.0 → expected $200
task_score: 200, // prob 0.5 → expected $100
task_pedir_docs: 200, // prob 0.5 → expected $100
task_bureau: 200, // prob 0.5 → expected $100
task_empleador: 200, // prob 0.5 → expected $100
task_analisis: 5_000, // prob 1.0 → expected $5.000 → ÁMBAR
task_aprobar: 200, // prob 0.5 → expected $100
task_rechazar: 200, // prob 0.5 → expected $100
task_desembolso: 200, // prob 0.5 → expected $100
task_archivo: 200, // prob 1.0 → expected $200 → VERDE (mínimo)
}
// ── 1. Importar BPMN y navegar al workspace ─────────────────────────────────
await page.goto('/import')
await page.locator('#bpmn-file-input').setInputFiles(resolve(BPMN_DIR, 'medium-with-gateways.bpmn'))
await page.waitForURL(/\/workspace\//)
const processId = page.url().split('/workspace/')[1]
createdProcessId = processId
// ── 2. Inyectar costos dispares directamente en Supabase (service_role_key) ──
const supabaseAdmin = createClient(SUPABASE_URL!, SERVICE_ROLE_KEY!)
const { data: activities, error: actErr } = await supabaseAdmin
.from('activities')
.select('id, bpmn_element_id')
.eq('process_id', processId)
if (actErr) throw actErr
const { error: upsertErr } = await supabaseAdmin.from('activities').upsert(
(activities ?? []).map((act) => ({
id: act.id,
direct_cost_fixed: COST_BY_ELEMENT[act.bpmn_element_id as string] ?? 200,
execution_time_minutes: 60,
}))
)
if (upsertErr) throw upsertErr
// ── 3. Recargar workspace para que el store de Zustand relea desde Supabase ──
await page.reload()
await page.waitForSelector('button:has-text("Simular")', { timeout: 15_000 })
await page.waitForTimeout(1_500) // esperar carga de store
// ── 4. Simular y navegar al reporte ────────────────────────────────────────
const simularBtn = page.getByRole('button', { name: 'Simular' })
await expect(simularBtn).not.toBeDisabled({ timeout: 5_000 })
await simularBtn.click()
await page.waitForURL(/\/report\//, { timeout: 20_000 })
await page.waitForSelector('button:has-text("Exportar PDF")', { timeout: 20_000 })
// Forzar modo Min-max: este test verifica la mecánica del gradiente de color con
// costos diseñados para posicionarse proporcionalmente (task_analisis a la mitad
// del rango). El modo 'percentile' (default desde Sprint 5 Etapa 3) normaliza por
// rango/rank, no por valor — rompería la posición esperada de "mitad" del fixture.
await page.getByRole('button', { name: 'Min-max' }).click()
await page.waitForTimeout(300)
// ── 5. Esperar a que el heatmap tenga colores reales (no neutro #cbd5e1) ────
// Usamos style.fill (inline CSS) porque applyHeatmapToViewer ahora lo usa
// en vez de setAttribute (para garantizar mayor especificidad que bpmn-js CSS).
await page.waitForFunction(() => {
const rects = Array.from(document.querySelectorAll('.bpmn-container .djs-visual rect'))
return rects.some((r) => {
const fill = (r as HTMLElement).style.fill
return fill && fill !== '#cbd5e1'
})
}, { timeout: 30_000 })
// Capturar estado de los fills para diagnóstico
const fillsSnapshot = await page.evaluate(() =>
Array.from(document.querySelectorAll('.bpmn-container .djs-visual rect'))
.map((r) => (r as HTMLElement).style.fill || r.getAttribute('fill'))
)
console.log('\n🎨 Fills del heatmap:', fillsSnapshot)
// Screenshot para evidencia visual
await page.screenshot({ path: resolve(OUTPUT_DIR, 'medium-with-gradient-report.png'), fullPage: false })
// ── 6. Exportar PDF ─────────────────────────────────────────────────────────
const pdfPath = resolve(OUTPUT_DIR, 'medium-with-gradient.pdf')
const pdfDownload = page.waitForEvent('download', { timeout: 90_000 })
await page.getByRole('button', { name: 'Exportar PDF' }).click()
const dl = await pdfDownload
await dl.saveAs(pdfPath)
// ── 7. Verificar tamaño ─────────────────────────────────────────────────────
const pdfBuf = readFileSync(pdfPath)
const pdfKB = (pdfBuf.length / 1024).toFixed(1)
console.log(`\n📄 medium-with-gradient.pdf: ${pdfBuf.length} bytes (${pdfKB} KB)`)
// JPEG de colores sólidos comprime casi igual que el gris neutro.
// El umbral mínimo es > 50 KB (PDF sin JPEG pesa ~28 KB; con JPEG ~60-80 KB).
expect(pdfBuf.length, 'PDF con gradiente real debe ser > 50 KB').toBeGreaterThan(50_000)
// ── 8. Extraer JPEG y analizar píxeles ──────────────────────────────────────
const jpegBuf = await extractJpegFromPdf(pdfBuf)
console.log(` JPEG extraído: ${jpegBuf.length} bytes`)
const { greenPx, amberPx, orangePx, redPx, total } = await analyzeJpegColors(jpegBuf)
console.log(` Total píxeles: ${total.toLocaleString()}`)
console.log(` 🟢 Verde: ${greenPx.toLocaleString()} px`)
console.log(` 🟡 Ámbar: ${amberPx.toLocaleString()} px`)
console.log(` 🟠 Naranja: ${orangePx.toLocaleString()} px`)
console.log(` 🔴 Rojo: ${redPx.toLocaleString()} px`)
const MIN_PX = 100
expect(greenPx, `Píxeles verdes en heatmap (≥ ${MIN_PX}): ${greenPx}`).toBeGreaterThanOrEqual(MIN_PX)
expect(amberPx, `Píxeles ámbar en heatmap (≥ ${MIN_PX}): ${amberPx}`).toBeGreaterThanOrEqual(MIN_PX)
expect(redPx, `Píxeles rojos en heatmap (≥ ${MIN_PX}): ${redPx}`).toBeGreaterThanOrEqual(MIN_PX)
// orangePx no se exige con mínimo estricto: con solo 11 actividades el stop naranja
// (t=0.67) puede no tener ninguna actividad exactamente ahí — alcanza con que exista
// la transición suave entre amarillo y rojo (lo prueban los tests unitarios de colors.ts).
})
})