// landing-e2e.mjs — aceptación de la landing pública y del acceso mágico. // // Cubre lo que ningún test existente cubre: que `/` sirva la landing en lugar del // login, que `/login` siga siendo alcanzable con su formulario sin interacción // previa (de eso dependen los otros tres audits), que el acceso mágico entre de un // clic, y que la landing no desborde en horizontal ni esconda contenido cuando el // sistema pide menos movimiento. // // Uso: npm run test:landing (requiere `npm run dev` corriendo) import assert from "node:assert/strict"; const BASE = process.env.LANDING_BASE_URL || "http://localhost:5173"; const T = 20000; async function pickBrowser() { const pw = await import("playwright"); for (const name of ["webkit", "chromium"]) { try { return { browser: await pw[name].launch(), engine: name }; } catch { /* siguiente motor */ } } throw new Error("No se pudo lanzar ningún navegador de Playwright."); } const passed = []; async function check(name, fn) { await fn(); passed.push(name); console.log(` ok ${name}`); } async function main() { const { browser, engine } = await pickBrowser(); console.log(`Motor: ${engine}\nBase: ${BASE}\n`); try { // ---- 1. `/` sirve la landing, no el login ---- const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } }); ctx.setDefaultTimeout(T); ctx.setDefaultNavigationTimeout(T); const page = await ctx.newPage(); const pageErrors = []; page.on("pageerror", (e) => pageErrors.push(e.message)); await page.goto(`${BASE}/`, { waitUntil: "networkidle" }); await check("`/` monta la landing", async () => { await page.waitForSelector("[data-ld-hero-title]", { state: "visible" }); }); await check("`/` no expone el formulario de login", async () => { assert.equal(await page.locator('input[type="email"]').count(), 0); }); await check("la landing tiene las 7 secciones del funnel", async () => { assert.equal(await page.locator("[data-ld-section]").count(), 7); }); await check("la landing no lanza errores de runtime", async () => { assert.deepEqual(pageErrors, []); }); // ---- 2. `/login` conserva el formulario sin interacción previa ---- // Es un requisito duro: visual-audit, responsive-audit y pwa-e2e rellenan // estos campos directo tras el goto. Colapsarlos rompería los tres. await page.goto(`${BASE}/login`, { waitUntil: "networkidle" }); await check("`/login` muestra el formulario manual sin abrir nada", async () => { await page.waitForSelector('input[type="email"]', { state: "visible" }); await page.waitForSelector('input[type="password"]', { state: "visible" }); await page.waitForSelector('button[type="submit"]', { state: "visible" }); }); await check("`/login` ofrece al menos 3 cuentas de acceso mágico", async () => { await page.waitForSelector("[data-magic-login]", { state: "visible" }); const n = await page.locator("[data-magic-login]").count(); assert.ok(n >= 3, `esperaba >= 3 tarjetas, encontré ${n}`); }); // ---- 3. El acceso mágico entra de un clic ---- await check("un clic en la tarjeta de dueña entra al panel", async () => { await page.locator('[data-magic-login][data-role="owner"]').first().click(); await page.waitForURL(/\/dashboard/, { timeout: T }); }); // ---- 4. Con sesión, `/` redirige al panel ---- await check("`/` redirige al panel cuando hay sesión", async () => { await page.goto(`${BASE}/`, { waitUntil: "networkidle" }); await page.waitForURL(/\/dashboard/, { timeout: T }); }); await ctx.close(); // ---- 5. Sin sesión, una ruta protegida manda al login ---- const anon = await browser.newContext({ viewport: { width: 1280, height: 900 } }); anon.setDefaultTimeout(T); anon.setDefaultNavigationTimeout(T); const anonPage = await anon.newPage(); await check("`/dashboard` sin sesión redirige a `/login`", async () => { await anonPage.goto(`${BASE}/dashboard`, { waitUntil: "networkidle" }); await anonPage.waitForURL(/\/login/, { timeout: T }); }); await anon.close(); // ---- 6. Sin desborde horizontal en el teléfono más estrecho del objetivo ---- const phone = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 3, hasTouch: true, }); phone.setDefaultTimeout(T); phone.setDefaultNavigationTimeout(T); const phonePage = await phone.newPage(); await phonePage.goto(`${BASE}/`, { waitUntil: "networkidle" }); await phonePage.waitForSelector("[data-ld-hero-title]", { state: "visible" }); await check("la landing no desborda en horizontal a 390px", async () => { // Se recorre toda la página: las secciones sticky y los visuales anchos solo // desbordan una vez que entran en viewport y arrancan su animación. const overflow = await phonePage.evaluate(async () => { const doc = document.documentElement; let worst = 0; const steps = Math.ceil(doc.scrollHeight / window.innerHeight) + 1; for (let i = 0; i < steps; i += 1) { window.scrollTo(0, i * window.innerHeight); await new Promise((r) => setTimeout(r, 350)); worst = Math.max( worst, Math.max(doc.scrollWidth, document.body.scrollWidth) - doc.clientWidth ); } return worst; }); assert.ok(overflow <= 1, `desborde de ${overflow}px`); }); await phone.close(); // ---- 7. Movimiento reducido no esconde contenido ---- // El fallo que se busca: un initial={{opacity:0}} cuyo whileInView nunca corre // deja el bloque invisible de forma permanente. const calm = await browser.newContext({ viewport: { width: 1280, height: 900 }, reducedMotion: "reduce", }); calm.setDefaultTimeout(T); calm.setDefaultNavigationTimeout(T); const calmPage = await calm.newPage(); await calmPage.goto(`${BASE}/`, { waitUntil: "networkidle" }); await calmPage.waitForSelector("[data-ld-hero-title]", { state: "visible" }); await check("con movimiento reducido el hero es visible", async () => { const o = await calmPage .locator("[data-ld-hero-title]") .evaluate((el) => Number(getComputedStyle(el).opacity)); assert.ok(o >= 0.99, `opacity del hero = ${o}`); }); await check("con movimiento reducido el CTA de cierre es visible", async () => { const cta = calmPage.locator("[data-ld-closing-cta]"); await cta.scrollIntoViewIfNeeded(); const o = await cta.evaluate((el) => Number(getComputedStyle(el).opacity)); assert.ok(o >= 0.99, `opacity del CTA de cierre = ${o}`); }); await check("con movimiento reducido todas las secciones son visibles", async () => { const hidden = await calmPage.evaluate(async () => { const out = []; const nodes = [...document.querySelectorAll("[data-ld-section]")]; for (const [i, el] of nodes.entries()) { el.scrollIntoView(); await new Promise((r) => setTimeout(r, 250)); const invisibles = [...el.querySelectorAll("*")].filter( (n) => Number(getComputedStyle(n).opacity) < 0.05 && n.textContent?.trim() ); if (invisibles.length) out.push({ section: i, count: invisibles.length, sample: invisibles[0].textContent.trim().slice(0, 50), }); } return out; }); assert.deepEqual(hidden, []); }); await calm.close(); console.log(`\n${passed.length} comprobaciones OK`); } finally { await browser.close(); } } main().catch((error) => { console.error(`\nlanding-e2e falló: ${error.message}`); process.exitCode = 1; });