/** * T-series: Dark mode (Fase 9.1). * * T-01 picking "Dark" on /settings persists across a hard reload. * T-02 "System" follows the emulated prefers-color-scheme. * T-03 a refresh in dark mode never renders a light first frame * (anti-FOUC inline script in app.html). We also check /logged-out * which lives outside the (app) group and would otherwise miss * any layout-level theme hydration. */ import { test, expect } from '@playwright/test'; import { USERS } from '../fixtures/users.js'; import { loginAs } from '../fixtures/login.js'; test.describe('Theme — three modes, persistence, anti-FOUC', () => { test('T-01: picking Dark on /settings persists across reload', async ({ page }) => { await loginAs(page, USERS.ana); await page.goto('/settings'); // Reset to a known starting state. await page.evaluate(() => localStorage.removeItem('theme')); const darkButton = page .getByRole('radiogroup', { name: /appearance|apariencia/i }) .getByRole('radio', { name: /dark|oscuro/i }); await expect(darkButton).toBeVisible({ timeout: 10_000 }); await darkButton.click(); // Immediate effect: . await expect.poll(() => page.evaluate(() => document.documentElement.dataset.theme)).toBe( 'dark' ); await expect.poll(() => page.evaluate(() => localStorage.getItem('theme'))).toBe('dark'); // Hard reload → still dark, no flash to light. await page.reload(); await expect.poll(() => page.evaluate(() => document.documentElement.dataset.theme)).toBe( 'dark' ); // Sanity: the toggle still reflects the choice. await expect( page .getByRole('radiogroup', { name: /appearance|apariencia/i }) .getByRole('radio', { name: /dark|oscuro/i, checked: true }) ).toBeVisible({ timeout: 10_000 }); }); test('T-02: "System" follows the emulated prefers-color-scheme', async ({ browser }) => { // Two contexts — one emulates dark OS, the other light. Both pick // "System" and we assert each resolves to the matching theme. const darkCtx = await browser.newContext({ colorScheme: 'dark' }); const darkPage = await darkCtx.newPage(); await loginAs(darkPage, USERS.ana); await darkPage.goto('/settings'); await darkPage.evaluate(() => localStorage.removeItem('theme')); await darkPage .getByRole('radiogroup', { name: /appearance|apariencia/i }) .getByRole('radio', { name: /system|sistema/i }) .click(); await expect .poll(() => darkPage.evaluate(() => document.documentElement.dataset.theme)) .toBe('dark'); await darkCtx.close(); const lightCtx = await browser.newContext({ colorScheme: 'light' }); const lightPage = await lightCtx.newPage(); await loginAs(lightPage, USERS.borja); await lightPage.goto('/settings'); await lightPage.evaluate(() => localStorage.removeItem('theme')); await lightPage .getByRole('radiogroup', { name: /appearance|apariencia/i }) .getByRole('radio', { name: /system|sistema/i }) .click(); await expect .poll(() => lightPage.evaluate(() => document.documentElement.dataset.theme)) .toBe('light'); await lightCtx.close(); }); test('T-03: refresh in dark mode never paints a light first frame (anti-FOUC)', async ({ browser }) => { const ctx = await browser.newContext(); const page = await ctx.newPage(); await loginAs(page, USERS.ana); // Lock the preference into localStorage so the inline app.html script // has to do the work on the next navigation. await page.evaluate(() => localStorage.setItem('theme', 'dark')); // Hard reload of /settings (an authed page). await page.goto('/settings'); const themeAttr = await page.evaluate(() => document.documentElement.getAttribute('data-theme') ); expect(themeAttr).toBe('dark'); // /logged-out lives outside the (app) group — no layout hydration // helps it. The inline script in app.html must still kick in. await page.goto('/logged-out'); const themeAttrLoggedOut = await page.evaluate(() => document.documentElement.getAttribute('data-theme') ); expect(themeAttrLoggedOut).toBe('dark'); // Also assert there is no visible light flash by reading the actual // computed background colour. The dark token resolves to rgb(2, 6, 23). const bg = await page.evaluate(() => getComputedStyle(document.body).backgroundColor); expect(bg.replace(/\s/g, '')).toBe('rgb(2,6,23)'); await ctx.close(); }); });