diff --git a/src/dashboard_v2/.gitignore b/src/dashboard_v2/.gitignore index f37bbbd2..81d1fd5a 100644 --- a/src/dashboard_v2/.gitignore +++ b/src/dashboard_v2/.gitignore @@ -4,3 +4,9 @@ build/ .env .env.* !.env.example + + +# Playwright +tests/visual-baseline/ +test-results/ +playwright-report/ diff --git a/src/dashboard_v2/package-lock.json b/src/dashboard_v2/package-lock.json index 3722e200..0cfc119d 100644 --- a/src/dashboard_v2/package-lock.json +++ b/src/dashboard_v2/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "devDependencies": { "@eslint/js": "^10.0.1", + "@playwright/test": "^1.61.1", "@sveltejs/adapter-static": "^3.0.8", "@sveltejs/kit": "^2.20.6", "@sveltejs/vite-plugin-svelte": "^5.1.1", @@ -1158,6 +1159,22 @@ "node": ">= 8" } }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -1921,6 +1938,72 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz", @@ -5071,6 +5154,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.16", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", diff --git a/src/dashboard_v2/package.json b/src/dashboard_v2/package.json index 238260ea..bc735430 100644 --- a/src/dashboard_v2/package.json +++ b/src/dashboard_v2/package.json @@ -12,10 +12,15 @@ "lint": "eslint src/", "lint:css": "stylelint \"src/**/*.svelte\"", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:visual": "playwright test --grep @visual", + "test:visual:update": "playwright test --grep @visual --update-snapshots" }, "devDependencies": { "@eslint/js": "^10.0.1", + "@playwright/test": "^1.61.1", "@sveltejs/adapter-static": "^3.0.8", "@sveltejs/kit": "^2.20.6", "@sveltejs/vite-plugin-svelte": "^5.1.1", diff --git a/src/dashboard_v2/playwright.config.ts b/src/dashboard_v2/playwright.config.ts new file mode 100644 index 00000000..925fcb9e --- /dev/null +++ b/src/dashboard_v2/playwright.config.ts @@ -0,0 +1,26 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests', + timeout: 30_000, + retries: 0, + use: { + baseURL: 'http://127.0.0.1:7777', + screenshot: 'only-on-failure', + }, + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + viewport: { width: 1440, height: 900 }, + }, + }, + ], + webServer: { + command: 'npm run build && npm run preview -- --port 7777', + url: 'http://127.0.0.1:7777', + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}); diff --git a/src/dashboard_v2/tests/home.spec.ts b/src/dashboard_v2/tests/home.spec.ts new file mode 100644 index 00000000..d8067a69 --- /dev/null +++ b/src/dashboard_v2/tests/home.spec.ts @@ -0,0 +1,27 @@ +/** + * Home page E2E tests + * @see LKPR-137 ACs: health ring, stat tiles, activity feed + */ +import { test, expect } from '@playwright/test'; + +test.describe('Home page', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + }); + + test('loads health ring section', async ({ page }) => { + await expect(page.locator('[aria-labelledby="health-card-title"]')).toBeVisible({ timeout: 10_000 }); + }); + + test('loads activity section', async ({ page }) => { + await expect(page.locator('[aria-labelledby="activity-heading"]')).toBeVisible({ timeout: 10_000 }); + }); + + test('stat tiles are present', async ({ page }) => { + await page.waitForLoadState('networkidle'); + // Home renders 4 .stat-link tile wrappers — assert their presence directly + await expect(page.locator('.stat-link').first()).toBeVisible({ timeout: 10_000 }); + const count = await page.locator('.stat-link').count(); + expect(count).toBeGreaterThanOrEqual(4); + }); +}); diff --git a/src/dashboard_v2/tests/links.spec.ts b/src/dashboard_v2/tests/links.spec.ts new file mode 100644 index 00000000..d1e8c962 --- /dev/null +++ b/src/dashboard_v2/tests/links.spec.ts @@ -0,0 +1,60 @@ +/** + * Links page E2E tests + * @see LKPR-137 ACs: table loads, relationship drawer opens, delete confirms + */ +import { test, expect } from '@playwright/test'; + +test.describe('Links page', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/links'); + }); + + test('loads links table', async ({ page }) => { + await page.waitForLoadState('networkidle'); + const content = page.locator('[aria-label="Memory links table"], .empty-state, table'); + await expect(content.first()).toBeVisible({ timeout: 10_000 }); + }); + + test('opens relationship drawer on row click', async ({ page }) => { + await page.waitForLoadState('networkidle'); + const rowLink = page.locator('[aria-label^="Open link:"]').first(); + if (await rowLink.count() === 0) { test.skip(); return; } + await rowLink.click(); + await expect(page.getByRole('dialog', { name: 'Relationship' })).toBeVisible({ timeout: 5_000 }); + }); + + test('relationship drawer delete requires confirmation', async ({ page }) => { + await page.waitForLoadState('networkidle'); + const rowLink = page.locator('[aria-label^="Open link:"]').first(); + if (await rowLink.count() === 0) { test.skip(); return; } + await rowLink.click(); + const drawer = page.getByRole('dialog', { name: 'Relationship' }); + await expect(drawer).toBeVisible({ timeout: 5_000 }); + + // "Delete link" button must exist in an open drawer + const deleteBtn = drawer.locator('button').filter({ hasText: /delete link/i }).first(); + await expect(deleteBtn).toBeVisible({ timeout: 3_000 }); + await deleteBtn.click(); + // Confirmation text should appear + await expect(drawer.locator('button').filter({ hasText: /delete this link/i })).toBeVisible({ timeout: 3_000 }); + }); + + test('relationship drawer cancel restores normal state', async ({ page }) => { + await page.waitForLoadState('networkidle'); + const rowLink = page.locator('[aria-label^="Open link:"]').first(); + if (await rowLink.count() === 0) { test.skip(); return; } + await rowLink.click(); + const drawer = page.getByRole('dialog', { name: 'Relationship' }); + await expect(drawer).toBeVisible({ timeout: 5_000 }); + + const deleteBtn = drawer.locator('button').filter({ hasText: /delete link/i }).first(); + await expect(deleteBtn).toBeVisible({ timeout: 3_000 }); + await deleteBtn.click(); + // Cancel button appears + const cancelBtn = drawer.getByRole('button', { name: /cancel/i }); + await expect(cancelBtn).toBeVisible({ timeout: 3_000 }); + await cancelBtn.click(); + // Back to normal — "Delete link" button (not confirmation) + await expect(drawer.locator('button').filter({ hasText: /^delete link$/i })).toBeVisible(); + }); +}); diff --git a/src/dashboard_v2/tests/memories.spec.ts b/src/dashboard_v2/tests/memories.spec.ts new file mode 100644 index 00000000..6093dca6 --- /dev/null +++ b/src/dashboard_v2/tests/memories.spec.ts @@ -0,0 +1,65 @@ +/** + * Memories page E2E tests + * @see LKPR-137 ACs: data table loads, sort, paginate, row click → drawer, edit mode + */ +import { test, expect } from '@playwright/test'; + +test.describe('Memories page', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/memories'); + }); + + test('loads data table', async ({ page }) => { + // Toolbar is a plain
with no role="group" + await expect(page.locator('[aria-label="Memory toolbar"]')).toBeVisible({ timeout: 10_000 }); + // Table renders — either rows or skeleton/empty state + const table = page.locator('table, [aria-label="Loading memories"], [aria-label*="memory"]'); + await expect(table.first()).toBeVisible({ timeout: 10_000 }); + }); + + test('sorts by column header click', async ({ page }) => { + // Click "Title" column header to sort + const titleHeader = page.getByRole('columnheader', { name: /Title/i }); + await expect(titleHeader).toBeVisible({ timeout: 10_000 }); + await titleHeader.click(); + await expect(page).toHaveURL(/sort=title/); + }); + + test('pagination controls render when data present', async ({ page }) => { + await page.waitForLoadState('networkidle'); + const hasRows = await page.locator('tbody tr').count(); + if (hasRows === 0) { + // No data — just confirm empty state is shown + await expect(page.locator('table, .empty-state')).toBeVisible({ timeout: 10_000 }); + return; + } + // Pagination component should be visible when rows exist + const pagination = page.locator('.pagination, [aria-label*="Pagination"], [aria-label*="page"]'); + await expect(pagination.first()).toBeVisible({ timeout: 10_000 }); + }); + + test('row click opens Memory detail drawer', async ({ page }) => { + await page.waitForLoadState('networkidle'); + const firstRow = page.locator('tbody tr').first(); + if (await firstRow.count() === 0) { + test.skip(); // no data in test environment + return; + } + await firstRow.click(); + await expect(page.getByRole('dialog', { name: 'Memory detail' })).toBeVisible({ timeout: 5_000 }); + }); + + test('memory detail drawer switches to edit mode', async ({ page }) => { + await page.waitForLoadState('networkidle'); + const firstRow = page.locator('tbody tr').first(); + if (await firstRow.count() === 0) { test.skip(); return; } + await firstRow.click(); + const drawer = page.getByRole('dialog', { name: 'Memory detail' }); + await expect(drawer).toBeVisible({ timeout: 5_000 }); + // Edit mode reveals title input field — require edit button to be present + const editBtn = drawer.locator('button').filter({ hasText: /edit/i }).first(); + await expect(editBtn).toBeVisible({ timeout: 3_000 }); + await editBtn.click(); + await expect(drawer.locator('#drawer-title')).toBeVisible(); + }); +}); diff --git a/src/dashboard_v2/tests/metrics.spec.ts b/src/dashboard_v2/tests/metrics.spec.ts new file mode 100644 index 00000000..8556d910 --- /dev/null +++ b/src/dashboard_v2/tests/metrics.spec.ts @@ -0,0 +1,26 @@ +/** + * Metrics page E2E tests + * @see LKPR-137 ACs: heatmap renders, tooltip shows on hover + */ +import { test, expect } from '@playwright/test'; + +test.describe('Metrics page', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/metrics'); + }); + + test('heatmap grid renders', async ({ page }) => { + await page.waitForLoadState('networkidle'); + const heatmap = page.locator('.heatmap-grid, .heatmap-card'); + await expect(heatmap.first()).toBeVisible({ timeout: 10_000 }); + }); + + test('heatmap cell tooltip shows on hover', async ({ page }) => { + await page.waitForLoadState('networkidle'); + // Find first heatmap cell with data (role="button") + const cell = page.locator('.hm-cell[role="button"]').first(); + if (await cell.count() === 0) { test.skip(); return; } + await cell.hover(); + await expect(page.locator('[role="tooltip"]')).toBeVisible({ timeout: 3_000 }); + }); +}); diff --git a/src/dashboard_v2/tests/query.spec.ts b/src/dashboard_v2/tests/query.spec.ts new file mode 100644 index 00000000..ef661ae5 --- /dev/null +++ b/src/dashboard_v2/tests/query.spec.ts @@ -0,0 +1,35 @@ +/** + * Query page E2E tests + * @see LKPR-137 ACs: query runs, result list updates, inspector shows + */ +import { test, expect } from '@playwright/test'; + +test.describe('Query page', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/query'); + }); + + test('renders query input and run button', async ({ page }) => { + await expect(page.locator('.query-input')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Run query' })).toBeVisible(); + }); + + test('running a query updates result list', async ({ page }) => { + await page.locator('.query-input').fill('memory'); + await page.getByRole('button', { name: 'Run query' }).click(); + // Wait for results or empty state — confirms the query actually ran and returned + const resultArea = page.locator('[aria-label="Query results"], .result-item, .empty-state, [role="listbox"]'); + await expect(resultArea.first()).toBeVisible({ timeout: 10_000 }); + // If results list is present, at least one item or empty-state must be rendered + const resultCount = await page.locator('[aria-label="Query results"] li, .result-item').count(); + const emptyState = await page.locator('.empty-state').count(); + expect(resultCount + emptyState).toBeGreaterThan(0); + }); + + test('Enter key triggers query', async ({ page }) => { + await page.locator('.query-input').fill('test'); + await page.locator('.query-input').press('Enter'); + const resultArea = page.locator('[aria-label="Query results"], .empty-state'); + await expect(resultArea.first()).toBeVisible({ timeout: 10_000 }); + }); +}); diff --git a/src/dashboard_v2/tests/review.spec.ts b/src/dashboard_v2/tests/review.spec.ts new file mode 100644 index 00000000..4cc72cf1 --- /dev/null +++ b/src/dashboard_v2/tests/review.spec.ts @@ -0,0 +1,42 @@ +/** + * Review page E2E tests + * @see LKPR-137 ACs: both tabs load, bulk select + accept works + */ +import { test, expect } from '@playwright/test'; + +test.describe('Review page', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/review'); + }); + + test('renders both tabs (Pending + Reviewed)', async ({ page }) => { + await expect(page.getByRole('tab', { name: /pending/i })).toBeVisible(); + await expect(page.getByRole('tab', { name: /reviewed/i })).toBeVisible(); + }); + + test('Pending tab is active by default', async ({ page }) => { + const pendingTab = page.getByRole('tab', { name: /pending/i }); + await expect(pendingTab).toHaveAttribute('aria-selected', 'true'); + }); + + test('clicking Reviewed tab switches tab', async ({ page }) => { + await page.getByRole('tab', { name: /reviewed/i }).click(); + await expect(page.getByRole('tab', { name: /reviewed/i })).toHaveAttribute('aria-selected', 'true'); + }); + + test('bulk select + accept works', async ({ page }) => { + await page.waitForLoadState('networkidle'); + // Select the header checkbox (select all) + const selectAllCheckbox = page.locator('thead input[type="checkbox"]').first(); + if (await selectAllCheckbox.count() === 0) { test.skip(); return; } + await selectAllCheckbox.click(); + // Accept button becomes enabled — then click it and verify operation completes + const acceptBtn = page.getByRole('button', { name: 'Accept' }); + await expect(acceptBtn).toBeEnabled({ timeout: 3_000 }); + await acceptBtn.click(); + // After accepting, either a toast appears or the list empties (suggestions accepted) + await expect( + page.locator('[role="status"], .empty-state, [aria-live="polite"]').first() + ).toBeVisible({ timeout: 5_000 }); + }); +}); diff --git a/src/dashboard_v2/tests/sessions.spec.ts b/src/dashboard_v2/tests/sessions.spec.ts new file mode 100644 index 00000000..cf382071 --- /dev/null +++ b/src/dashboard_v2/tests/sessions.spec.ts @@ -0,0 +1,36 @@ +/** + * Sessions page E2E tests + * @see LKPR-137 ACs: timeline loads, session drawer opens, stacked drawer works + */ +import { test, expect } from '@playwright/test'; + +test.describe('Sessions page', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/sessions'); + }); + + test('loads sessions timeline / table', async ({ page }) => { + await page.waitForLoadState('networkidle'); + const content = page.locator('table, .timeline, .session-row, .empty-state'); + await expect(content.first()).toBeVisible({ timeout: 10_000 }); + }); + + test('opens session drawer on row click', async ({ page }) => { + await page.waitForLoadState('networkidle'); + const sessionLink = page.locator('[aria-label^="Open session:"]').first(); + if (await sessionLink.count() === 0) { test.skip(); return; } + await sessionLink.click(); + await expect(page.getByRole('dialog', { name: 'Session detail' })).toBeVisible({ timeout: 5_000 }); + }); + + test('session drawer closes on close button', async ({ page }) => { + await page.waitForLoadState('networkidle'); + const sessionLink = page.locator('[aria-label^="Open session:"]').first(); + if (await sessionLink.count() === 0) { test.skip(); return; } + await sessionLink.click(); + const drawer = page.getByRole('dialog', { name: 'Session detail' }); + await expect(drawer).toBeVisible({ timeout: 5_000 }); + await drawer.getByRole('button', { name: 'Close drawer' }).click(); + await expect(drawer).not.toBeVisible(); + }); +}); diff --git a/src/dashboard_v2/tests/settings.spec.ts b/src/dashboard_v2/tests/settings.spec.ts new file mode 100644 index 00000000..568f9453 --- /dev/null +++ b/src/dashboard_v2/tests/settings.spec.ts @@ -0,0 +1,47 @@ +/** + * Settings page E2E tests + * @see LKPR-137 ACs: sections render, field change shows unsaved indicator, save works + */ +import { test, expect } from '@playwright/test'; + +test.describe('Settings page', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/settings'); + }); + + test('renders 4 core settings sections', async ({ page }) => { + await page.waitForLoadState('networkidle'); + await expect(page.getByRole('region', { name: /Search Weights/i })).toBeVisible({ timeout: 10_000 }); + await expect(page.getByRole('region', { name: /Scoring/i })).toBeVisible(); + await expect(page.getByRole('region', { name: /Search & Links/i })).toBeVisible(); + await expect(page.getByRole('region', { name: /Memory Lifecycle/i })).toBeVisible(); + }); + + test('field change shows unsaved indicator', async ({ page }) => { + await page.waitForLoadState('networkidle'); + // Modify any number input in the page + const firstNumberInput = page.locator('input[type="number"]').first(); + await expect(firstNumberInput).toBeVisible({ timeout: 10_000 }); + const currentVal = await firstNumberInput.inputValue(); + // Type a tiny change + await firstNumberInput.fill(String(parseFloat(currentVal) + 0.01)); + await firstNumberInput.blur(); + // Unsaved banner should appear + await expect(page.locator('[role="status"]').filter({ hasText: /unsaved/i })).toBeVisible({ timeout: 3_000 }); + }); + + test('save button triggers success toast', async ({ page }) => { + await page.waitForLoadState('networkidle'); + // Dirty a field first + const firstNumberInput = page.locator('input[type="number"]').first(); + await expect(firstNumberInput).toBeVisible({ timeout: 10_000 }); + const currentVal = await firstNumberInput.inputValue(); + await firstNumberInput.fill(String(parseFloat(currentVal) + 0.01)); + await firstNumberInput.blur(); + // Click Save in any section + const saveBtn = page.getByRole('button', { name: 'Save' }).first(); + await saveBtn.click(); + // Toast fires on success — filter to avoid strict-mode violation with multiple [role="status"] elements + await expect(page.locator('[role="status"]').filter({ hasText: /saved|success/i })).toBeVisible({ timeout: 5_000 }); + }); +}); diff --git a/src/dashboard_v2/tests/shell.spec.ts b/src/dashboard_v2/tests/shell.spec.ts new file mode 100644 index 00000000..8ffe3a12 --- /dev/null +++ b/src/dashboard_v2/tests/shell.spec.ts @@ -0,0 +1,104 @@ +/** + * Shell E2E tests — NavRail, TopBar, Command Palette, Toast, ConfirmDialog + * @see LKPR-137 ACs: Nav rail, TopBar breadcrumb, Command Palette, Toast, Confirm Dialog + */ +import { test, expect } from '@playwright/test'; + +const NAV_ITEMS = [ + { label: 'Home', href: '/' }, + { label: 'Memories', href: '/memories' }, + { label: 'Links', href: '/links' }, + { label: 'Query', href: '/query' }, + { label: 'Review', href: '/review' }, + { label: 'Sessions', href: '/sessions' }, + { label: 'Metrics', href: '/metrics' }, +]; + +test.describe('NavRail', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + }); + + test('renders all 7 nav items + Settings', async ({ page }) => { + for (const item of NAV_ITEMS) { + await expect(page.getByRole('link', { name: item.label })).toBeVisible(); + } + await expect(page.getByRole('link', { name: 'Settings' })).toBeVisible(); + }); + + test('active item is highlighted on home', async ({ page }) => { + const homeLink = page.getByRole('link', { name: 'Home' }); + await expect(homeLink).toHaveAttribute('aria-current', 'page'); + }); +}); + +test.describe('Navigation', () => { + for (const item of NAV_ITEMS) { + test(`clicking ${item.label} navigates to ${item.href}`, async ({ page }) => { + await page.goto('/'); + await page.getByRole('link', { name: item.label }).click(); + const expectedURL = + item.href === '/' + ? new RegExp('^http://[^/]+/$') + : new RegExp(`${item.href}$`); + await expect(page).toHaveURL(expectedURL); + }); + } + + test('clicking Settings navigates to /settings', async ({ page }) => { + await page.goto('/'); + await page.getByRole('link', { name: 'Settings' }).click(); + await expect(page).toHaveURL(/\/settings$/); + }); +}); + +test.describe('TopBar breadcrumb', () => { + const pages = [ + { href: '/', label: 'Home' }, + { href: '/memories', label: 'Memories' }, + { href: '/links', label: 'Links' }, + { href: '/query', label: 'Query' }, + { href: '/review', label: 'Review' }, + { href: '/sessions', label: 'Sessions' }, + { href: '/metrics', label: 'Metrics' }, + { href: '/settings', label: 'Settings' }, + ]; + + for (const p of pages) { + test(`${p.href} shows "${p.label}" breadcrumb`, async ({ page }) => { + await page.goto(p.href); + // TopBar renders: + await expect(page.locator('.breadcrumb-current')).toContainText(p.label); + }); + } +}); + +test.describe('Command Palette', () => { + test('opens on Cmd+K', async ({ page }) => { + await page.goto('/'); + await page.keyboard.press('Meta+k'); + await expect(page.getByRole('dialog', { name: /command palette/i })).toBeVisible(); + }); + + test('keyboard navigation works in palette', async ({ page }) => { + await page.goto('/'); + await page.keyboard.press('Meta+k'); + const palette = page.getByRole('dialog', { name: /command palette/i }); + await expect(palette).toBeVisible(); + + // CommandPalette uses aria-activedescendant — DOM focus stays on the search input. + // Arrow down advances activeIndex from 0 → 1; verify via aria-activedescendant update. + const searchInput = palette.locator('input[type="search"], input[role="combobox"], input'); + await page.keyboard.press('ArrowDown'); + // After ArrowDown the active-descendant attribute must reference an option element. + await expect(searchInput).toHaveAttribute('aria-activedescendant', /.+/); + }); + + test('closes on Escape', async ({ page }) => { + await page.goto('/'); + await page.keyboard.press('Meta+k'); + await expect(page.getByRole('dialog', { name: /command palette/i })).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(page.getByRole('dialog', { name: /command palette/i })).not.toBeVisible(); + }); +}); diff --git a/src/dashboard_v2/tests/visual.spec.ts b/src/dashboard_v2/tests/visual.spec.ts new file mode 100644 index 00000000..b701db94 --- /dev/null +++ b/src/dashboard_v2/tests/visual.spec.ts @@ -0,0 +1,35 @@ +/** + * Visual regression tests — screenshot each page against baseline + * Fail CI on >0.1% pixel diff. + * @tag @visual + * @see LKPR-137 AC: "Visual regression: Screenshot each page … CI fails on >0.1% pixel diff" + * + * First run: npx playwright test --grep @visual --update-snapshots + * Subsequent: npx playwright test --grep @visual + */ +import { test, expect } from '@playwright/test'; + +const PAGES = [ + { name: 'home', url: '/', readyLocator: '.stat-link' }, + { name: 'memories', url: '/memories', readyLocator: 'table, .empty-state' }, + { name: 'sessions', url: '/sessions', readyLocator: '.timeline, .empty-state' }, + { name: 'review', url: '/review', readyLocator: '[role="tab"]' }, + { name: 'links', url: '/links', readyLocator: 'table, .empty-state' }, + { name: 'query', url: '/query', readyLocator: '.query-input' }, + { name: 'metrics', url: '/metrics', readyLocator: '.heatmap, .empty-state, [aria-label*="heatmap"]' }, + { name: 'settings', url: '/settings', readyLocator: '[role="region"]' }, +]; + +for (const p of PAGES) { + test(`@visual ${p.name} page matches baseline`, async ({ page }) => { + const response = await page.goto(p.url); + // Fail immediately if navigation itself returned an error page + expect(response?.ok(), `Navigation to ${p.url} failed with status ${response?.status()}`).toBe(true); + // Wait for a route-specific ready element instead of arbitrary timing + await expect(page.locator(p.readyLocator).first()).toBeVisible({ timeout: 15_000 }); + await expect(page).toHaveScreenshot(`${p.name}.png`, { + maxDiffPixelRatio: 0.001, // 0.1% + fullPage: false, // viewport only — 1440×900 per config + }); + }); +}