chore(sprint-5/etapa-6): limpieza técnica — E2E Supabase, fix flakiness guardar, docs cierre sprint
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled

This commit is contained in:
2026-06-23 22:33:01 -03:00
parent 485cc91d73
commit 5ca5d7ea31
10 changed files with 269 additions and 135 deletions

View File

@@ -7,19 +7,44 @@
* Ejecutar: npx playwright test
*/
import { test, expect } from '@playwright/test'
import { readFileSync, mkdirSync } from 'fs'
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 })
})
@@ -312,74 +337,69 @@ async function analyzeJpegColors(jpegBuf: Buffer): Promise<{
// ─── Test: gradiente real con costos dispares ─────────────────────────────────
test('Heatmap con gradiente real — medium-with-gateways.bpmn', async ({ page }) => {
// 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)
}
test.describe('Heatmap con gradiente real', () => {
let createdProcessId: string | null = null
// ── 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\//)
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)
}
})
const processId = page.url().split('/workspace/')[1]
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'
)
// ── 2. Inyectar costos dispares directamente en IndexedDB (Dexie) ───────────
await page.evaluate(async ({ procId, costs }) => {
const DB_NAME = 'ProcessCostPlatform'
// 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)
}
const db = await new Promise<IDBDatabase>((resolve, reject) => {
const req = indexedDB.open(DB_NAME)
req.onsuccess = () => resolve(req.result)
req.onerror = () => reject(req.error)
})
// ── 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 activities = await new Promise<any[]>((resolve, reject) => {
const tx = db.transaction('activities', 'readonly')
const index = tx.objectStore('activities').index('processId')
const req = index.getAll(procId)
req.onsuccess = () => resolve(req.result)
req.onerror = () => reject(req.error)
})
const processId = page.url().split('/workspace/')[1]
createdProcessId = processId
await new Promise<void>((resolve, reject) => {
const tx = db.transaction('activities', 'readwrite')
const store = tx.objectStore('activities')
let pending = activities.length
if (pending === 0) { resolve(); return }
// ── 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
for (const act of activities) {
const req = store.put({
...act,
directCostFixed: (costs as Record<string, number>)[act.bpmnElementId] ?? 200,
executionTimeMinutes: 60,
})
req.onsuccess = () => { if (--pending === 0) resolve() }
req.onerror = () => reject(req.error)
}
})
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
db.close()
}, { procId: processId, costs: COST_BY_ELEMENT })
// ── 3. Recargar workspace para que Zustand relea desde Dexie ────────────────
await page.reload()
// ── 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
@@ -452,4 +472,5 @@ test('Heatmap con gradiente real — medium-with-gateways.bpmn', async ({ page }
// 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).
})
})

View File

@@ -76,10 +76,11 @@ async function makeAutomatable(
const timeInput = page.getByLabel(/Tiempo automatizado/i)
await timeInput.fill(String(timeMin))
// Guardar
const guardarBtn = page.getByRole('button', { name: /Guardar/i })
await guardarBtn.click()
await page.waitForTimeout(500)
// Guardar — espera a que el botón esté habilitado (isDirty) antes de clickear,
// y a que vuelva a deshabilitarse (confirmación de guardado) antes de continuar.
await page.waitForSelector('[data-testid="save-activity-btn"]:not([disabled])', { timeout: 10_000 })
await page.click('[data-testid="save-activity-btn"]')
await page.waitForSelector('[data-testid="save-activity-btn"][disabled]', { timeout: 10_000 })
}
// ─── Helper: configurar volumetría en tab Global ──────────────────────────────

View File

@@ -60,9 +60,9 @@ async function setActivityCosts(
const autoTimeInput = page.getByLabel(/Tiempo automatizado/i)
await autoTimeInput.fill(String(automatedTime))
const guardar = page.getByRole('button', { name: /Guardar/i })
await guardar.click()
await page.waitForTimeout(500)
await page.waitForSelector('[data-testid="save-activity-btn"]:not([disabled])', { timeout: 10_000 })
await page.click('[data-testid="save-activity-btn"]')
await page.waitForSelector('[data-testid="save-activity-btn"][disabled]', { timeout: 10_000 })
}
async function configureGlobal(

View File

@@ -62,6 +62,16 @@ async function globalSetup() {
return
}
// Diagnóstico Sprint 5 Etapa 6a-extra: @supabase/auth-js v2 define STORAGE_KEY =
// 'supabase.auth.token' como default de GoTrueClient (constants.js), pero
// @supabase/supabase-js's createClient() SOBRESCRIBE ese default calculando
// `sb-${projectRef}-auth-token` a partir de la URL (ver SupabaseClient constructor:
// `i = sb-${hostname.split('.')[0]}-auth-token`, mergeado en `auth.storageKey`),
// salvo que el caller pase un storageKey explícito. src/lib/supabase.ts no lo hace,
// así que esta es la clave REAL que usa la app. Confirmado empíricamente: el test
// "app header shows user avatar" (sprint-4-smoke.spec.ts) requiere que AuthContext
// encuentre una sesión real bajo esta clave — pasa, y auth-state.json generado
// contiene exactamente `sb-${projectRef}-auth-token`. No es un bug.
const projectRef = new URL(supabaseUrl).hostname.split('.')[0]
const storageKey = `sb-${projectRef}-auth-token`

View File

@@ -15,19 +15,44 @@
* Ejecutar: npx playwright test tests/e2e/validate-dod-etapa4.spec.ts
*/
import { test, expect } from '@playwright/test'
import { readFileSync, mkdirSync, writeFileSync } from 'fs'
import { readFileSync, mkdirSync, writeFileSync, 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 })
})
@@ -134,73 +159,67 @@ const AUTO_TIME = 5
// ─── Test único que genera y valida los 6 archivos ───────────────────────────
test('DoD Etapa 4 — genera y valida los 6 archivos de export para medium-with-gateways', async ({ page }) => {
test.describe('DoD Etapa 4', () => {
let createdProcessId: string | null = null
// ── Fase 1: importar BPMN e inyectar configuración en IndexedDB ─────────────
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('DoD Etapa 4 — genera y valida los 6 archivos de export para medium-with-gateways', 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'
)
// ── Fase 1: importar BPMN e inyectar configuración en Supabase ──────────────
await page.goto('/import')
await page.locator('#bpmn-file-input').setInputFiles(resolve(BPMN_DIR, 'medium-with-gateways.bpmn'))
await page.waitForURL(/\/workspace\//, { timeout: 15_000 })
const processId = page.url().split('/workspace/')[1]
createdProcessId = processId
await page.waitForTimeout(1_500)
// Inyectar costos de actividades y flags de automatización en IndexedDB
await page.evaluate(async ({ procId, actualCosts, automatable, autoCost, autoTime }) => {
const DB_NAME = 'ProcessCostPlatform'
const db = await new Promise<IDBDatabase>((res, rej) => {
const req = indexedDB.open(DB_NAME)
req.onsuccess = () => res(req.result)
req.onerror = () => rej(req.error)
})
const acts = await new Promise<any[]>((res, rej) => {
const tx = db.transaction('activities', 'readonly')
const req = tx.objectStore('activities').index('processId').getAll(procId)
req.onsuccess = () => res(req.result)
req.onerror = () => rej(req.error)
})
await new Promise<void>((res, rej) => {
const tx = db.transaction('activities', 'readwrite')
const store = tx.objectStore('activities')
let pending = acts.length
if (pending === 0) { res(); return }
for (const act of acts) {
const isAuto = (automatable as string[]).includes(act.bpmnElementId)
const req = store.put({
...act,
directCostFixed: (actualCosts as Record<string, number>)[act.bpmnElementId] ?? 200,
executionTimeMinutes: 60,
automatable: isAuto,
automatedCostFixed: isAuto ? autoCost : 0,
automatedTimeMinutes: isAuto ? autoTime : 0,
})
req.onsuccess = () => { if (--pending === 0) res() }
req.onerror = () => rej(req.error)
// Inyectar costos de actividades y flags de automatización directamente en Supabase
// (service_role_key bypasa RLS — equivalente al patrón antiguo de escritura en IndexedDB)
const supabaseAdmin = createClient(SUPABASE_URL!, SERVICE_ROLE_KEY!)
const { data: acts, error: actsErr } = await supabaseAdmin
.from('activities')
.select('id, bpmn_element_id')
.eq('process_id', processId)
if (actsErr) throw actsErr
const { error: upsertActsErr } = await supabaseAdmin.from('activities').upsert(
(acts ?? []).map((act) => {
const isAuto = AUTOMATABLE.has(act.bpmn_element_id as string)
return {
id: act.id,
direct_cost_fixed: ACTUAL_COST[act.bpmn_element_id as string] ?? 200,
execution_time_minutes: 60,
automatable: isAuto,
automated_cost_fixed: isAuto ? AUTO_COST : 0,
automated_time_minutes: isAuto ? AUTO_TIME : 0,
}
})
)
if (upsertActsErr) throw upsertActsErr
// Inyectar volumetría en el proceso
const proc = await new Promise<any>((res, rej) => {
const tx = db.transaction('processes', 'readonly')
const req = tx.objectStore('processes').get(procId)
req.onsuccess = () => res(req.result)
req.onerror = () => rej(req.error)
// Inyectar volumetría en el proceso
const { error: procErr } = await supabaseAdmin
.from('processes')
.update({
annual_frequency: 12_000,
analysis_horizon_years: 3,
automation_investment: 50_000,
})
if (proc) {
await new Promise<void>((res, rej) => {
const tx = db.transaction('processes', 'readwrite')
const req = tx.objectStore('processes').put({
...proc,
annualFrequency: 12_000,
analysisHorizonYears: 3,
automationInvestment: 50_000,
})
req.onsuccess = () => res()
req.onerror = () => rej(req.error)
})
}
db.close()
}, { procId: processId, actualCosts: ACTUAL_COST, automatable: [...AUTOMATABLE], autoCost: AUTO_COST, autoTime: AUTO_TIME })
.eq('id', processId)
if (procErr) throw procErr
// Recargar para que el store de Zustand relea desde Dexie
// Recargar 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)
@@ -442,4 +461,5 @@ test('DoD Etapa 4 — genera y valida los 6 archivos de export para medium-with-
expect(roiHasROI, 'roi.csv debe tener metadata de ROI anualizado').toBe(true)
expect(roiHasFreq, 'roi.csv debe tener frecuencia anual 12000').toBe(true)
expect(roiHasAutomatable, 'roi.csv debe tener columna Automatizable').toBe(true)
})
})