- Fix DataTable reactivity: use $derived with getters for data/columns props - Fix auth.spec.js: open user dropdown before clicking Logout button - Fix dashboard.spec.ts: scope selectors to layout-content, use exact matches - Fix layout.spec.ts: clear localStorage before breakpoint tests, wait for focus - Fix projects/team-members.spec.ts: wait for table rows to be visible Root causes: 1. DataTable options object captured initial empty array, not reactive updates 2. Selectors matched multiple elements (sidebar, user menu, main content) 3. Dropdown menus need to be opened before clicking items 4. Keyboard shortcuts need element focus All 94 tests now pass (47 chromium + 47 firefox)
290 lines
10 KiB
TypeScript
290 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 });
|
|
// Clear localStorage to test default breakpoint behavior
|
|
await page.evaluate(() => localStorage.removeItem('headroom_sidebar_state'));
|
|
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);
|
|
|
|
// Wait for sidebar to be fully mounted with event listeners
|
|
await expect(page.locator('[data-testid="sidebar"]')).toBeVisible();
|
|
await page.waitForTimeout(100);
|
|
|
|
const before = await page.evaluate(() => document.documentElement.getAttribute('data-sidebar'));
|
|
|
|
// Focus on the page body to ensure keyboard events are captured
|
|
await page.locator('body').click();
|
|
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);
|
|
});
|
|
});
|