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
+ });
+ });
+}