fix(sprint-6/etapa-3): localStorage key en ProfileSheet + eliminar setTimeout(350) + specs E2E de auth
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled

This commit is contained in:
2026-07-06 18:51:52 -03:00
parent b42cdc7e80
commit b117ad258d
4 changed files with 184 additions and 25 deletions

View File

@@ -47,15 +47,25 @@ en los 3 PDFs. Si hay truncamiento, evaluar:
## Deuda media — afecta DX o evolución del producto ## Deuda media — afecta DX o evolución del producto
### [INVESTIGATING] Clave de localStorage del auth E2E vs producción ### [RESOLVED] Clave de localStorage del auth E2E vs producción
**Estado actual:** contradicción no resuelta entre dos hechos empíricos verificados: **Resultado:** la "contradicción" era un bug real, no solo una discrepancia de documentación.
- En producción, `ProfileSheet.getAccessToken()` lee `supabase.auth.token` y funciona correctamente (Sprint 5 Etapa 4). `tests/e2e/auth-localStorage-key.spec.ts` (Sprint 6 Etapa 3) confirmó empíricamente que la
- En E2E, `auth-setup.ts` escribe la sesión bajo `sb-${projectRef}-auth-token` y el smoke test "app header shows user avatar" pasa. única clave presente en `localStorage` con sesión real es `sb-${projectRef}-auth-token`
**Hipótesis:** el cliente Supabase de la app (`src/lib/supabase.ts`) y el de `auth-setup.ts` podrían estar usando `storageKey` distinto. La documentación inline en `auth-setup.ts` explica el comportamiento observado pero no reconcilia la contradicción. (`sb-xgqbkxcliyjzisoccsur-auth-token` en este proyecto) — `'supabase.auth.token'` (STORAGE_KEY
**Impacto:** si el auth E2E no funciona realmente (sesión vacía), todos los specs con `storageState` serían inválidos — pasarían por ausencia de assertions fuertes, no porque el auth funcione. default de `@supabase/auth-js`'s `GoTrueClient` en aislamiento) **no existe**, porque
**Acción:** en Sprint 6, comparar el `localStorage` real en producción vs el `auth-state.json` del E2E. Ejecutar un spec que haga una query autenticada a Supabase y afirme que devuelve datos reales del usuario de test. `@supabase/supabase-js`'s `createClient()` sobrescribe ese default calculando la clave
**Archivos afectados:** `tests/e2e/setup/auth-setup.ts`, `src/lib/supabase.ts`. `sb-`-prefijada desde la URL del proyecto.
**Origen:** Sprint 5 Etapa 6 diagnóstico de auth-setup.
`ProfileSheet.getAccessToken()` leía `'supabase.auth.token'` — la clave vieja, inexistente.
Esto significaba que **el guardado de perfil (nombre, empresa) nunca llegaba a Supabase**:
`getAccessToken()` retornaba `null`, el `if (!token) return` silenciaba el error, y el usuario
veía el cambio solo en memoria (`updateUser()`) sin persistir. Bug real, no solo deuda de
documentación.
**Fix:** `getAccessToken()` ahora calcula `sb-${projectRef}-auth-token` igual que
`auth-setup.ts`. Verificado end-to-end: PATCH a `/rest/v1/users` devuelve 204, y tras un
reload completo de página el valor guardado se relee correctamente desde la DB.
**Sprint cerrado en:** Sprint 6 Etapa 3.
### [BACKLOG] `getCurrentUser()` en SupabaseAuthService — sin callers activos ### [BACKLOG] `getCurrentUser()` en SupabaseAuthService — sin callers activos
**Estado actual:** `SupabaseAuthService.getCurrentUser()` usa `getSession()` directamente (sin timeout, sin processLock workaround). Es la única excepción documentada a la regla — justificada porque necesita el objeto sesión completo, no solo una barrera. Sin callers activos hoy. **Estado actual:** `SupabaseAuthService.getCurrentUser()` usa `getSession()` directamente (sin timeout, sin processLock workaround). Es la única excepción documentada a la regla — justificada porque necesita el objeto sesión completo, no solo una barrera. Sin callers activos hoy.
@@ -64,11 +74,14 @@ en los 3 PDFs. Si hay truncamiento, evaluar:
**Archivos afectados:** `src/auth/SupabaseAuthService.ts` (~línea 27, comentario explicativo presente). **Archivos afectados:** `src/auth/SupabaseAuthService.ts` (~línea 27, comentario explicativo presente).
**Origen:** OBSERVATIONS Sprint 5 Etapa 1 — ítem prometido que faltaba registrar acá. **Origen:** OBSERVATIONS Sprint 5 Etapa 1 — ítem prometido que faltaba registrar acá.
### [BACKLOG] `setTimeout(350)` en ProfileSheet — candidato a limpieza ### [RESOLVED] `setTimeout(350)` en ProfileSheet — candidato a limpieza
**Estado actual:** `ProfileSheet.handleSave()` difiere `updateUser` 350ms como mitigación de la race condition con Radix. El fix definitivo vive en `src/components/ui/sheet.tsx` (cleanup defensivo de `pointer-events`). El setTimeout es redundante pero inofensivo. **Resultado:** `setTimeout` eliminado — `updateUser()` se llama directamente tras
**Solución propuesta:** eliminar el `setTimeout`, verificar que el fix en `sheet.tsx` sea suficiente por sí solo. `onOpenChange(false)`. Verificado end-to-end (Sprint 6 Etapa 3): el fix definitivo en
**Archivos afectados:** `src/components/ProfileSheet.tsx`. `src/components/ui/sheet.tsx` (`useEffect` sobre el prop `open`, limpieza defensiva de
**Origen:** Sprint 5 Etapa 4G (fix intermedio que quedó al resolverse la causa raíz en 4H). `pointer-events`) es suficiente por sí solo — `document.body.style.pointerEvents` queda
vacío tras guardar, y la navegación posterior (click en "Importar BPMN") funciona sin
bloqueos.
**Sprint cerrado en:** Sprint 6 Etapa 3.
### [RESOLVED] E2E specs con Dexie directo — reescribir para Supabase ### [RESOLVED] E2E specs con Dexie directo — reescribir para Supabase
**Resultado:** `export-pdf.spec.ts` (test "Heatmap con gradiente real") y `validate-dod-etapa4.spec.ts` reescritos para inyectar datos vía `@supabase/supabase-js` con `SUPABASE_SERVICE_ROLE_KEY` (bypasa RLS), en lugar de IndexedDB directo. `afterAll` borra el proceso creado por cada test (cascada en `activities`/`simulations`). Si `SUPABASE_SERVICE_ROLE_KEY` no está configurada en `.env.test`, ambos tests hacen `test.skip()` con mensaje descriptivo en vez de fallar — ver `.env.example`. Diagnóstico previo de `auth-setup.ts` confirmó que la storageKey (`sb-${projectRef}-auth-token`) ya era correcta (no era parte del problema): `@supabase/supabase-js`'s `createClient()` sobrescribe el `STORAGE_KEY` default de `auth-js` con esa clave calculada desde la URL. **Resultado:** `export-pdf.spec.ts` (test "Heatmap con gradiente real") y `validate-dod-etapa4.spec.ts` reescritos para inyectar datos vía `@supabase/supabase-js` con `SUPABASE_SERVICE_ROLE_KEY` (bypasa RLS), en lugar de IndexedDB directo. `afterAll` borra el proceso creado por cada test (cascada en `activities`/`simulations`). Si `SUPABASE_SERVICE_ROLE_KEY` no está configurada en `.env.test`, ambos tests hacen `test.skip()` con mensaje descriptivo en vez de fallar — ver `.env.example`. Diagnóstico previo de `auth-setup.ts` confirmó que la storageKey (`sb-${projectRef}-auth-token`) ya era correcta (no era parte del problema): `@supabase/supabase-js`'s `createClient()` sobrescribe el `STORAGE_KEY` default de `auth-js` con esa clave calculada desde la URL.
@@ -92,6 +105,16 @@ en los 3 PDFs. Si hay truncamiento, evaluar:
**Costo:** bajo, mecánico — mismo cambio repetido 9 veces. **Costo:** bajo, mecánico — mismo cambio repetido 9 veces.
**Origen:** Sprint 5 Etapa 6 — identificado durante el grep de diagnóstico, no aplicado por respeto al scope explícito del prompt. **Origen:** Sprint 5 Etapa 6 — identificado durante el grep de diagnóstico, no aplicado por respeto al scope explícito del prompt.
### [BACKLOG] CumulativeSavingsChart — derivación de inversión base desde cumulativeSavingsHorizon
**Estado actual:** cuando `roiBase` está presente (modo sensibilidad activo), el componente
recupera la inversión original algebraicamente: `investment_base = roiBase.annualSavings * horizonYears - roiBase.cumulativeSavingsHorizon`.
Es correcto mientras `horizonYears` del chart coincida con el usado en `calculateRoi` (hoy siempre es el mismo).
**Riesgo:** si en el futuro el chart recibe un horizonte diferente al del cálculo de ROI, el valor
de referencia del eje Y para la línea base será incorrecto, sin error visible.
**Solución propuesta:** agregar prop `investmentBase?: number` a `CumulativeSavingsChartProps`
y pasar `process.automationInvestment` directamente desde `ReportPage`.
**Costo:** bajo. **Origen:** Sprint 6 Etapa 2 — detectado en auditoría post-implementación.
### [INVESTIGATING] Dexie live queries (sincronización store ↔ db) ### [INVESTIGATING] Dexie live queries (sincronización store ↔ db)
**Estado actual:** la invalidación del resultado de simulación se garantiza vía ESLint rule (`no-restricted-imports`) + convención: todas las mutaciones pasan por `useProcessStore`. **Estado actual:** la invalidación del resultado de simulación se garantiza vía ESLint rule (`no-restricted-imports`) + convención: todas las mutaciones pasan por `useProcessStore`.

View File

@@ -10,10 +10,21 @@ import { useAuth } from '@/auth/AuthContext'
// Lee el access token directamente de localStorage sin pasar por el cliente Supabase. // Lee el access token directamente de localStorage sin pasar por el cliente Supabase.
// Evita contención con processLock — el save de perfil no necesita refresh de token // Evita contención con processLock — el save de perfil no necesita refresh de token
// porque es una operación rápida y el token está fresco en sesiones activas. // porque es una operación rápida y el token está fresco en sesiones activas.
// Clave verificada: STORAGE_KEY = 'supabase.auth.token' en @supabase/auth-js GoTrueClient. //
// Clave real confirmada por diagnóstico (Sprint 6 Etapa 3, tests/e2e/auth-localStorage-key.spec.ts):
// 'supabase.auth.token' es el STORAGE_KEY default de @supabase/auth-js's GoTrueClient en
// aislamiento, pero @supabase/supabase-js's createClient() SOBRESCRIBE ese default calculando
// `sb-${projectRef}-auth-token` desde la URL del proyecto (projectRef = hostname.split('.')[0]),
// salvo que el caller pase un storageKey explícito — src/lib/supabase.ts no lo hace. El spec de
// diagnóstico confirmó empíricamente: 'supabase.auth.token' NO existe en localStorage; la única
// clave presente es `sb-${projectRef}-auth-token` con un access_token válido. Con la clave vieja,
// getAccessToken() siempre retornaba null → el persist a Supabase nunca se ejecutaba (línea 68
// silenciaba el error) — el usuario veía el cambio en UI pero no se guardaba.
function getAccessToken(): string | null { function getAccessToken(): string | null {
try { try {
const raw = localStorage.getItem('supabase.auth.token') const projectRef = new URL(SUPABASE_URL).hostname.split('.')[0]
const storageKey = `sb-${projectRef}-auth-token`
const raw = localStorage.getItem(storageKey)
if (!raw) return null if (!raw) return null
const parsed = JSON.parse(raw) as { access_token?: string } const parsed = JSON.parse(raw) as { access_token?: string }
return parsed.access_token ?? null return parsed.access_token ?? null
@@ -48,16 +59,13 @@ export function ProfileSheet({ open, onOpenChange }: ProfileSheetProps) {
const savedName = name.trim() const savedName = name.trim()
const savedCompany = company.trim() || undefined const savedCompany = company.trim() || undefined
// Cerramos el Sheet primero. updateUser() se difiere 350ms — más que la animación // Cerramos el Sheet y actualizamos el estado local de inmediato — actualización
// de cierre de Radix (duration-300 = 300ms) — para que el cleanup de // optimista. El fix para el bug de Radix/react-remove-scroll (pointer-events:none
// @radix-ui/react-remove-scroll restaure pointer-events:none del body ANTES // atascado en el body tras cerrar) vive en src/components/ui/sheet.tsx (useEffect
// de que el re-render de AuthContext vuelva a procesar el Sheet. Si updateUser // que observa el prop `open` y limpia el body defensivamente). El defer de 350ms
// corre durante la animación, Radix no ejecuta el cleanup y body queda con // que existía acá era redundante una vez resuelta la causa raíz ahí.
// pointer-events:none permanente, bloqueando toda interacción fuera del canvas BPMN.
onOpenChange(false) onOpenChange(false)
setTimeout(() => { updateUser({ name: savedName, company: savedCompany })
updateUser({ name: savedName, company: savedCompany })
}, 350)
// Persistir en Supabase en background — fire & forget. // Persistir en Supabase en background — fire & forget.
// Usamos raw fetch (no el cliente Supabase) para evitar contención con processLock. // Usamos raw fetch (no el cliente Supabase) para evitar contención con processLock.

View File

@@ -0,0 +1,72 @@
/**
* Diagnóstico Sprint 6 Etapa 3 Parte A: ¿bajo qué clave de localStorage escribe
* supabase-js v2 la sesión en este entorno? ProfileSheet.getAccessToken() lee
* 'supabase.auth.token' (STORAGE_KEY default de @supabase/auth-js GoTrueClient),
* pero auth-setup.ts documenta que @supabase/supabase-js's createClient() sobrescribe
* ese default calculando `sb-${projectRef}-auth-token` desde la URL del proyecto —
* a menos que el caller pase un storageKey explícito (src/lib/supabase.ts no lo hace).
*
* Este spec confirma empíricamente cuál de las dos claves existe realmente en el
* localStorage de una sesión autenticada real, antes de tocar ProfileSheet.tsx.
*/
import { test, expect } from '@playwright/test'
import { readFileSync, existsSync } from 'fs'
import { resolve } from 'path'
import { fileURLToPath } from 'url'
test.use({ storageState: 'tests/e2e/setup/auth-state.json' })
const __dirname = fileURLToPath(new URL('.', import.meta.url))
const ROOT = resolve(__dirname, '../..')
// Mismo patrón manual que auth-setup.ts / export-pdf.spec.ts — no hay dotenv
// cargando process.env automáticamente en el runner de Playwright.
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
}
test('diagnóstico: confirmar la clave real de localStorage para la sesión Supabase', async ({ page }) => {
await page.goto('/')
// Esperar a que la app cargue (AuthContext lee el storageState y monta el header)
await page.waitForSelector('header button[title]', { timeout: 10_000 })
const localEnv = loadEnvFile('.env.local')
const supabaseUrl = localEnv.VITE_SUPABASE_URL
const projectRef = supabaseUrl ? new URL(supabaseUrl).hostname.split('.')[0] : null
const diagnosis = await page.evaluate((ref: string | null) => {
const keys = Object.keys(localStorage)
const authKeys = keys.filter((k) => k.includes('auth') || k.startsWith('sb-'))
const legacyRaw = localStorage.getItem('supabase.auth.token')
const v2Key = ref ? `sb-${ref}-auth-token` : null
const v2Raw = v2Key ? localStorage.getItem(v2Key) : null
return {
allAuthRelatedKeys: authKeys,
legacyKeyExists: legacyRaw !== null,
v2KeyName: v2Key,
v2KeyExists: v2Raw !== null,
legacyHasAccessToken: legacyRaw ? Boolean((JSON.parse(legacyRaw) as { access_token?: string }).access_token) : false,
v2HasAccessToken: v2Raw ? Boolean((JSON.parse(v2Raw) as { access_token?: string }).access_token) : false,
}
}, projectRef)
console.log('[diagnóstico localStorage]', JSON.stringify(diagnosis, null, 2))
// Afirmación central: la clave v2 (sb-{projectRef}-auth-token) es la que existe y
// tiene un access_token real. La clave legacy ('supabase.auth.token') NO existe.
expect(diagnosis.v2KeyExists, `Esperaba que ${diagnosis.v2KeyName} exista en localStorage`).toBe(true)
expect(diagnosis.v2HasAccessToken, 'La clave v2 debe contener un access_token no vacío').toBe(true)
expect(diagnosis.legacyKeyExists, "'supabase.auth.token' (clave legacy que lee ProfileSheet hoy) NO debería existir").toBe(false)
})

View File

@@ -0,0 +1,56 @@
/**
* Sprint 6 Etapa 3 Parte C — Validación de que el auth E2E funciona realmente
* (no solo que el storageState se carga, sino que Supabase devuelve datos reales
* del usuario autenticado).
*
* No existe data-testid="app-header-user-avatar" en AppHeader.tsx — el trigger del
* dropdown es un <button title={user.name}> dentro del <header> (mismo selector que
* ya usa tests/e2e/sprint-4-smoke.spec.ts: 'header button[title]').
*/
import { test, expect } from '@playwright/test'
import { readFileSync, existsSync } from 'fs'
import { resolve } from 'path'
import { fileURLToPath } from 'url'
test.use({ storageState: 'tests/e2e/setup/auth-state.json' })
const __dirname = fileURLToPath(new URL('.', import.meta.url))
const ROOT = resolve(__dirname, '../..')
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 testEnv = loadEnvFile('.env.test')
const TEST_USER_EMAIL = testEnv.TEST_USER_EMAIL
test('auth E2E: Supabase devuelve datos reales del usuario autenticado', async ({ page }) => {
test.skip(!TEST_USER_EMAIL, 'TEST_USER_EMAIL no configurada en .env.test — ver .env.example')
await page.goto('/')
// Esperar a que AppHeader muestre el trigger del dropdown de usuario
// (confirma que AuthContext cargó la sesión y el avatar renderiza con user.name real)
const avatarTrigger = page.locator('header button[title]').first()
await expect(avatarTrigger).toBeVisible({ timeout: 10_000 })
// El título del botón es el nombre real del usuario — no debe estar vacío
const titleAttr = await avatarTrigger.getAttribute('title')
expect(titleAttr, 'el trigger debe tener el nombre real del usuario, no vacío').toBeTruthy()
// Abrir el dropdown y confirmar que el email del usuario de test aparece —
// esto requiere que AuthContext haya construido el AuthUser real desde la sesión
// de Supabase (no un mock ni un valor placeholder).
await avatarTrigger.click()
await expect(page.getByText(TEST_USER_EMAIL)).toBeVisible({ timeout: 5_000 })
})