- Create PageHeader component with title, description, and action slots - Create StatCard component with trend indicators and icons - Update dashboard with KPI cards, Quick Actions, and Allocation Preview - Polish login page with branding and centered layout - Fix auth redirect: authenticated users accessing /login go to dashboard - Fix page refresh: auth state persists, no blank page - Fix sidebar: visible after login, toggle works, state persists - Fix CSS import: add app.css to layout, fix DaisyUI import path - Fix breadcrumbs: home icon links to /dashboard - Add comprehensive E2E and unit tests Refs: openspec/changes/p03-dashboard-enhancement Closes: p03-dashboard-enhancement
283 lines
10 KiB
TypeScript
283 lines
10 KiB
TypeScript
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);
|
|
});
|
|
});
|