import { expect, test } from '@playwright/test'; function createToken() { const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' }), 'utf-8').toString('base64'); const payload = Buffer.from( JSON.stringify({ sub: 'e2e', exp: Math.floor(Date.now() / 1000) + 3600 }), 'utf-8' ).toString('base64'); return `${header}.${payload}.sig`; } async function openDashboard(page: import('@playwright/test').Page) { const token = createToken(); await page.goto('/login'); await page.evaluate((accessToken) => { localStorage.setItem('headroom_access_token', accessToken); localStorage.setItem('headroom_refresh_token', accessToken); }, token); await page.goto('/dashboard'); await page.waitForURL('/dashboard'); } async function loginThroughForm(page: import('@playwright/test').Page) { await page.goto('/login'); await page.fill('input[type="email"]', 'superuser@headroom.test'); await page.fill('input[type="password"]', 'password'); await page.click('button[type="submit"]'); await page.waitForURL('/dashboard'); } test.describe('Layout E2E', () => { test.beforeEach(async ({ page }) => { await page.goto('/login'); await page.context().clearCookies(); await page.evaluate(() => localStorage.clear()); }); test('dashboard page has sidebar', async ({ page }) => { await openDashboard(page); await expect(page.locator('[data-testid="sidebar"]')).toBeVisible(); }); test('login redirects to dashboard with sidebar', async ({ page }) => { await loginThroughForm(page); await expect(page.locator('[data-testid="sidebar"]')).toBeVisible(); }); test('sidebar toggle works', async ({ page }) => { await openDashboard(page); const initial = await page.evaluate(() => document.documentElement.getAttribute('data-sidebar')); await page.click('[data-testid="sidebar-toggle"]'); await expect .poll(async () => page.evaluate(() => document.documentElement.getAttribute('data-sidebar'))) .not.toBe(initial); }); test('theme toggle works', async ({ page }) => { await openDashboard(page); const before = await page.evaluate(() => localStorage.getItem('headroom_theme') ?? 'light'); await page.click('[data-testid="theme-toggle"]'); await expect .poll(async () => page.evaluate(() => localStorage.getItem('headroom_theme'))) .not.toBe(before); }); test('month selector updates period store', async ({ page }) => { await openDashboard(page); const before = await page.evaluate(() => localStorage.getItem('headroom_selected_period')); await page.click('[data-testid="month-selector"] button'); await page.getByRole('button', { name: 'Next' }).click(); await expect .poll(async () => page.evaluate(() => localStorage.getItem('headroom_selected_period'))) .not.toBe(before); }); test('breadcrumbs reflect current route', async ({ page }) => { await openDashboard(page); await expect(page.locator('[data-testid="breadcrumbs"]')).toContainText('Dashboard'); }); test('login page has no sidebar', async ({ page }) => { await page.goto('/login'); await expect(page.locator('[data-testid="sidebar"]')).toHaveCount(0); }); test('sidebar hidden by default on mobile', async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await openDashboard(page); await expect .poll(async () => page.evaluate(() => document.documentElement.getAttribute('data-sidebar'))) .toBe('hidden'); }); test('hamburger shows sidebar on mobile', async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await openDashboard(page); await page.click('[data-testid="mobile-hamburger"]'); await expect .poll(async () => page.evaluate(() => document.documentElement.getAttribute('data-sidebar'))) .toBe('expanded'); }); test('sidebar overlays content and closes on backdrop click on mobile', async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await openDashboard(page); await page.click('[data-testid="mobile-hamburger"]'); await expect .poll(async () => page.evaluate(() => { const main = document.querySelector('[data-testid="layout-main"]'); if (!main) return null; return getComputedStyle(main).marginLeft; }) ) .toBe('0px'); await page.evaluate(() => { const backdrop = document.querySelector('[data-testid="sidebar-backdrop"]') as HTMLElement | null; backdrop?.click(); }); await expect .poll(async () => page.evaluate(() => document.documentElement.getAttribute('data-sidebar'))) .toBe('hidden'); }); test('all key breakpoints render expected sidebar state', async ({ page }) => { const breakpoints: Array<[number, number, 'hidden' | 'collapsed' | 'expanded']> = [ [320, 700, 'hidden'], [768, 900, 'collapsed'], [1024, 900, 'collapsed'], [1280, 900, 'expanded'] ]; for (const [width, height, expected] of breakpoints) { await page.setViewportSize({ width, height }); await page.goto('/login'); await page.evaluate(() => localStorage.setItem('headroom_sidebar_state', 'expanded')); await openDashboard(page); await expect .poll(async () => page.evaluate(() => document.documentElement.getAttribute('data-sidebar'))) .toBe(expected); } }); test('keyboard shortcut toggles sidebar', async ({ page }) => { await page.setViewportSize({ width: 1280, height: 900 }); await openDashboard(page); const before = await page.evaluate(() => document.documentElement.getAttribute('data-sidebar')); await page.keyboard.down('Control'); await page.keyboard.press('\\'); await page.keyboard.up('Control'); await expect .poll(async () => page.evaluate(() => document.documentElement.getAttribute('data-sidebar'))) .not.toBe(before); }); test('sidebar is visible after login', async ({ page }) => { await loginThroughForm(page); // Sidebar should be visible const sidebar = page.locator('[data-testid="sidebar"]'); await expect(sidebar).toBeVisible(); // Verify sidebar has content await expect(sidebar.locator('text=Dashboard')).toBeVisible(); }); test('sidebar toggle button works when collapsed', async ({ page }) => { await page.setViewportSize({ width: 1280, height: 900 }); await openDashboard(page); // Get initial state const sidebar = page.locator('[data-testid="sidebar"]'); await expect(sidebar).toBeVisible(); // Find and click collapse button (if exists) const collapseButton = page.locator('[data-testid="sidebar-toggle"], [aria-label*="toggle"], button').filter({ has: page.locator('svg') }).first(); if (await collapseButton.isVisible().catch(() => false)) { // Collapse the sidebar await collapseButton.click(); // Wait for collapsed state await page.waitForTimeout(300); // Sidebar should still exist but be in collapsed state await expect(sidebar).toBeAttached(); // Click toggle again to expand await collapseButton.click(); await page.waitForTimeout(300); // Sidebar should be visible again await expect(sidebar).toBeVisible(); } }); test('page refresh after login does not show blank page', async ({ page }) => { await loginThroughForm(page); // Verify we're on dashboard await expect(page).toHaveURL('/dashboard'); await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible(); // Get the token before refresh const tokenBefore = await page.evaluate(() => localStorage.getItem('headroom_access_token')); expect(tokenBefore).toBeTruthy(); // Refresh the page await page.reload(); // Wait for page to fully load after refresh await page.waitForLoadState('networkidle'); // Check what URL we're on (for debugging) const currentUrl = page.url(); // We should either be on dashboard (success) or redirected to login (bug) // If we're on login, that's the bug being reported if (currentUrl.includes('/login')) { // Bug: We were redirected to login after refresh // Check if token is still there const tokenAfter = await page.evaluate(() => localStorage.getItem('headroom_access_token')); expect(tokenAfter, 'Token should persist after refresh').toBeTruthy(); // Fail the test to document the bug throw new Error('Bug: User was redirected to login after page refresh despite having valid token'); } // If we're still on dashboard, wait for client-side hydration to complete await expect(page).toHaveURL('/dashboard'); // Wait for SvelteKit to hydrate the page // The page might be SSR'd but needs client-side hydration to show content await page.waitForFunction(() => { // Check if the page has hydrated by looking for SvelteKit's hydration marker return document.querySelector('[data-sveltekit-hydrated]') !== null || document.body.innerHTML.includes('Dashboard'); }, { timeout: 10000 }); // Now verify content is visible await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible({ timeout: 5000 }); // Verify token is still there const tokenAfter = await page.evaluate(() => localStorage.getItem('headroom_access_token')); expect(tokenAfter).toBe(tokenBefore); }); test('sidebar state persists after page refresh', async ({ page }) => { await page.setViewportSize({ width: 1280, height: 900 }); await openDashboard(page); // Get initial sidebar state const initialState = await page.evaluate(() => document.documentElement.getAttribute('data-sidebar') ); // Refresh page await page.reload(); await page.waitForLoadState('networkidle'); // Verify sidebar is still visible const sidebar = page.locator('[data-testid="sidebar"]'); await expect(sidebar).toBeVisible(); // Verify state persisted const afterRefresh = await page.evaluate(() => document.documentElement.getAttribute('data-sidebar') ); expect(afterRefresh).toBe(initialState); }); });