diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 9c45a50..ab92cf1 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -15,7 +15,21 @@ "Bash(echo \"EXIT:$?\")", "Bash(bash \"C:\\\\Users\\\\Hendrik\\\\.claude\\\\plugins\\\\cache\\\\claude-plugins-official\\\\superpowers\\\\5.0.4\\\\scripts\\\\start-server.sh\" --project-dir \"C:\\\\Users\\\\Hendrik\\\\Documents\\\\projects\\\\design-system\")", "Bash(echo \"EXIT_CODE=$?\")", - "Bash(echo \"EXIT=$?\")" + "Bash(echo \"EXIT=$?\")", + "mcp__gitea__actions_config_read", + "mcp__gitea__search_repos", + "WebFetch(domain:raw.githubusercontent.com)", + "Bash(node -e \"console.log\\(JSON.parse\\(require\\(''fs''\\).readFileSync\\(''package.json'',''utf8''\\)\\).devDependencies[''vite-plugin-dts'']\\)\")", + "Bash(npx vite:*)", + "Bash(cd:*)", + "mcp__gitea__actions_run_read", + "mcp__gitea__get_file_contents", + "WebFetch(domain:ui.shadcn.com)", + "Bash(bash \"C:\\\\Users\\\\Hendrik\\\\.claude\\\\plugins\\\\cache\\\\claude-plugins-official\\\\superpowers\\\\5.0.5\\\\skills\\\\brainstorming\\\\scripts\\\\start-server.sh\" --project-dir \"C:\\\\Users\\\\Hendrik\\\\Documents\\\\projects\\\\design-system\")", + "Bash(bash \"C:/Users/Hendrik/.claude/plugins/cache/claude-plugins-official/superpowers/5.0.5/skills/brainstorming/scripts/stop-server.sh\" \"C:/Users/Hendrik/Documents/projects/design-system/.superpowers/brainstorm/470-1774344716\")", + "Bash(npm test:*)", + "Bash(grep:*)", + "Bash(xargs cat:*)" ] } } diff --git a/.gitignore b/.gitignore index 1d1ef7e..cfe3fe4 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ node_modules/ dist/ .superpowers/ .worktrees/ +test-results/ +screenshots/ +.playwright-mcp/ diff --git a/e2e/admin.spec.ts b/e2e/admin.spec.ts new file mode 100644 index 0000000..1155f78 --- /dev/null +++ b/e2e/admin.spec.ts @@ -0,0 +1,165 @@ +import { test, expect } from '@playwright/test' + +test.describe('Admin - User Management (/admin/rbac)', () => { + test('renders admin tabs and user table', async ({ page }) => { + await page.goto('/admin/rbac') + + // Admin navigation tabs + await expect(page.getByRole('tab', { name: 'User Management' })).toBeVisible() + await expect(page.getByRole('tab', { name: 'Audit Log' })).toBeVisible() + await expect(page.getByRole('tab', { name: 'OIDC' })).toBeVisible() + + // User Management sub-tabs + await expect(page.getByRole('tab', { name: 'Users' })).toBeVisible() + await expect(page.getByRole('tab', { name: 'Groups' })).toBeVisible() + await expect(page.getByRole('tab', { name: 'Roles' })).toBeVisible() + + // TopBar breadcrumb + await expect(page.getByLabel('Breadcrumb').getByText('User Management')).toBeVisible() + }) + + test('switching between Users, Groups, and Roles tabs', async ({ page }) => { + await page.goto('/admin/rbac') + + // Default tab is Users + await expect(page.getByRole('tab', { name: 'Users' })).toBeVisible() + + // Switch to Groups tab + await page.getByRole('tab', { name: 'Groups' }).click() + await page.waitForTimeout(300) + + // Switch to Roles tab + await page.getByRole('tab', { name: 'Roles' }).click() + await page.waitForTimeout(300) + + // Switch back to Users + await page.getByRole('tab', { name: 'Users' }).click() + }) + + test('navigating between admin sections via tabs', async ({ page }) => { + await page.goto('/admin/rbac') + + // Click Audit Log tab + await page.getByRole('tab', { name: 'Audit Log' }).click() + await expect(page).toHaveURL(/\/admin\/audit/) + + // Click OIDC tab + await page.getByRole('tab', { name: 'OIDC' }).click() + await expect(page).toHaveURL(/\/admin\/oidc/) + + // Back to User Management + await page.getByRole('tab', { name: 'User Management' }).click() + await expect(page).toHaveURL(/\/admin\/rbac/) + }) +}) + +test.describe('Admin - Audit Log (/admin/audit)', () => { + test('renders audit table with filters', async ({ page }) => { + await page.goto('/admin/audit') + + // Table headers + await expect(page.getByRole('columnheader', { name: 'Timestamp' })).toBeVisible() + await expect(page.getByRole('columnheader', { name: 'User' })).toBeVisible() + await expect(page.getByRole('columnheader', { name: 'Category' })).toBeVisible() + await expect(page.getByRole('columnheader', { name: 'Action' })).toBeVisible() + await expect(page.getByRole('columnheader', { name: 'Result' })).toBeVisible() + + // Table has data + const rows = page.locator('table tbody tr') + expect(await rows.count()).toBeGreaterThan(0) + + // Filter inputs exist + await expect(page.getByPlaceholder('Filter by user...')).toBeVisible() + await expect(page.getByPlaceholder('Search action or target...')).toBeVisible() + }) + + test('filtering audit events by search', async ({ page }) => { + await page.goto('/admin/audit') + + const searchInput = page.getByPlaceholder('Search action or target...') + await searchInput.fill('deploy') + + // Table should update + await page.waitForTimeout(300) + const rows = page.locator('table tbody tr') + const count = await rows.count() + expect(count).toBeGreaterThanOrEqual(0) + }) +}) + +test.describe('Admin - OIDC Config (/admin/oidc)', () => { + test('renders OIDC form with all fields', async ({ page }) => { + await page.goto('/admin/oidc') + + // Section headers + await expect(page.getByText('Behavior')).toBeVisible() + await expect(page.getByText('Provider Settings')).toBeVisible() + await expect(page.getByText('Claim Mapping')).toBeVisible() + await expect(page.getByText('Default Roles')).toBeVisible() + await expect(page.getByText('Danger Zone')).toBeVisible() + + // Form fields by id + await expect(page.locator('#issuer')).toBeVisible() + await expect(page.locator('#client-id')).toBeVisible() + await expect(page.locator('#client-secret')).toBeVisible() + await expect(page.locator('#roles-claim')).toBeVisible() + await expect(page.locator('#name-claim')).toBeVisible() + + // Buttons + await expect(page.getByRole('button', { name: 'Test Connection' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Save' })).toBeVisible() + await expect(page.getByRole('button', { name: /Delete OIDC/i })).toBeVisible() + + // Default roles tags + await expect(page.getByText('USER').first()).toBeVisible() + await expect(page.getByText('VIEWER').first()).toBeVisible() + }) + + test('toggling Enabled switch', async ({ page }) => { + await page.goto('/admin/oidc') + + // The Toggle's checkbox is visually hidden — click the label wrapper instead + const enabledLabel = page.locator('label').filter({ hasText: 'Enabled' }) + await enabledLabel.click() + + // Should not crash; label still visible + await expect(enabledLabel).toBeVisible() + }) + + test('adding and removing a role tag', async ({ page }) => { + await page.goto('/admin/oidc') + + // Add a new role + const roleInput = page.getByPlaceholder('Add role...') + await roleInput.fill('EDITOR') + // Use the Add button next to the input (scoped to same row) + await roleInput.press('Enter') + + // New role tag should appear + await expect(page.getByText('EDITOR')).toBeVisible() + + // Remove it via aria-label on the tag's remove button + await page.getByRole('button', { name: 'Remove EDITOR' }).click() + + // EDITOR tag should be gone + await expect(page.getByText('EDITOR')).not.toBeVisible() + }) + + test('Save button shows success toast', async ({ page }) => { + await page.goto('/admin/oidc') + + await page.getByRole('button', { name: 'Save' }).click() + + // Toast notification + await expect(page.getByText('Settings saved')).toBeVisible() + }) + + test('Delete button shows confirmation dialog', async ({ page }) => { + await page.goto('/admin/oidc') + + await page.getByRole('button', { name: /Delete OIDC/i }).click() + + // Confirmation dialog should appear + await expect(page.getByText('Delete OIDC configuration?')).toBeVisible() + }) +}) diff --git a/e2e/agents.spec.ts b/e2e/agents.spec.ts new file mode 100644 index 0000000..91efc55 --- /dev/null +++ b/e2e/agents.spec.ts @@ -0,0 +1,80 @@ +import { test, expect } from '@playwright/test' + +test.describe('Agent Health (/agents)', () => { + test('renders stat cards and group cards', async ({ page }) => { + await page.goto('/agents') + + // Stat strip + await expect(page.getByText('Total Agents')).toBeVisible() + await expect(page.getByText('Total TPS')).toBeVisible() + + // Group cards for each application + await expect(page.getByText('order-service').first()).toBeVisible() + await expect(page.getByText('payment-svc').first()).toBeVisible() + await expect(page.getByText('notification-hub').first()).toBeVisible() + + // Instance tables have data + const instanceRows = page.locator('table tbody tr') + expect(await instanceRows.count()).toBeGreaterThan(0) + + // Instance table headers + await expect(page.getByRole('columnheader', { name: 'Instance' }).first()).toBeVisible() + await expect(page.getByRole('columnheader', { name: 'State' }).first()).toBeVisible() + await expect(page.getByRole('columnheader', { name: 'Uptime' }).first()).toBeVisible() + await expect(page.getByRole('columnheader', { name: 'TPS' }).first()).toBeVisible() + + // Timeline section + await expect(page.getByText('Timeline').first()).toBeVisible() + }) + + test('clicking an instance row opens the detail panel', async ({ page }) => { + await page.goto('/agents') + + // Click first instance row + const instanceRow = page.locator('table tbody tr').first() + await instanceRow.click() + + // Detail panel opens — look for detail-specific labels + await expect(page.getByText('Version').first()).toBeVisible() + await expect(page.getByText('Throughput').first()).toBeVisible() + }) + + test('detail panel has Performance tab with charts', async ({ page }) => { + await page.goto('/agents') + + // Click an instance to open detail panel + const instanceRow = page.locator('table tbody tr').first() + await instanceRow.click() + + // Wait for panel to open + await expect(page.getByText('Version').first()).toBeVisible() + + // DetailPanel tabs are plain buttons (not role="tab") + // Switch to Performance tab + const perfTab = page.getByRole('button', { name: 'Performance' }) + await perfTab.click() + + // Performance charts should render + await expect(page.getByText('Throughput (msg/s)').first()).toBeVisible() + await expect(page.getByText('Error Rate (err/h)').first()).toBeVisible() + }) + + test('app-scoped agents view', async ({ page }) => { + await page.goto('/agents/order-service') + + // Breadcrumb/scope shows app + await expect(page.getByLabel('Breadcrumb').getByText('Agents')).toBeVisible() + + // Only order-service agents should show + await expect(page.getByText('ord-1').first()).toBeVisible() + await expect(page.getByText('ord-2').first()).toBeVisible() + await expect(page.getByText('ord-3').first()).toBeVisible() + }) + + test('dead agent shows alert banner', async ({ page }) => { + await page.goto('/agents') + + // notification-hub has a dead instance, should show alert + await expect(page.getByText('Single point of failure')).toBeVisible() + }) +}) diff --git a/e2e/dashboard.spec.ts b/e2e/dashboard.spec.ts new file mode 100644 index 0000000..db638ff --- /dev/null +++ b/e2e/dashboard.spec.ts @@ -0,0 +1,90 @@ +import { test, expect } from '@playwright/test' + +/** Click the 7d time range preset so hardcoded mock data (March 18) is visible. */ +async function widenTimeRange(page: import('@playwright/test').Page) { + await page.getByRole('tab', { name: '7d' }).click() +} + +test.describe('Dashboard (/apps)', () => { + test('renders KPI stat cards and exchange table', async ({ page }) => { + await page.goto('/apps') + await widenTimeRange(page) + + // KPI health strip renders + await expect(page.getByText('Recent Exchanges')).toBeVisible() + + // Table headers + await expect(page.getByRole('columnheader', { name: 'Status' })).toBeVisible() + await expect(page.getByRole('columnheader', { name: 'Route' })).toBeVisible() + await expect(page.getByRole('columnheader', { name: 'Application' })).toBeVisible() + await expect(page.getByRole('columnheader', { name: 'Exchange ID' })).toBeVisible() + + // Table has data rows + const rows = page.locator('table tbody tr') + await expect(rows.first()).toBeVisible() + expect(await rows.count()).toBeGreaterThan(0) + + // Sidebar renders with app names + await expect(page.getByText('order-service').first()).toBeVisible() + await expect(page.getByText('payment-svc').first()).toBeVisible() + + // TopBar renders + await expect(page.getByLabel('Breadcrumb').getByText('Applications')).toBeVisible() + await expect(page.getByText('PRODUCTION')).toBeVisible() + + // Shortcuts bar + await expect(page.getByText('Ctrl+K').first()).toBeVisible() + }) + + test('clicking a table row opens the detail panel', async ({ page }) => { + await page.goto('/apps') + await widenTimeRange(page) + + // Click the first data row + const firstRow = page.locator('table tbody tr').first() + await expect(firstRow).toBeVisible() + await firstRow.click() + + // Detail panel should open — look for "Open full details" link + await expect(page.getByText('Open full details')).toBeVisible() + // Overview section + await expect(page.getByText('Correlation').first()).toBeVisible() + }) + + test('navigating to app-scoped dashboard filters exchanges', async ({ page }) => { + await page.goto('/apps/order-service') + + // Breadcrumb shows app scope + await expect(page.getByLabel('Breadcrumb').getByText('order-service')).toBeVisible() + + // Table should still render + await expect(page.getByText('Recent Exchanges')).toBeVisible() + }) + + test('sidebar navigation works', async ({ page }) => { + await page.goto('/apps') + + // Click on an app in the sidebar + const sidebarApp = page.getByText('order-service').first() + await sidebarApp.click() + + // URL should change to the app scope + await expect(page).toHaveURL(/\/apps\/order-service/) + }) + + test('inspect button navigates to exchange detail', async ({ page }) => { + await page.goto('/apps') + await widenTimeRange(page) + + // Wait for table rows to appear + const firstRow = page.locator('table tbody tr').first() + await expect(firstRow).toBeVisible() + + // Click the inspect button (↗) on first row + const inspectBtn = firstRow.locator('button[title="Inspect exchange"]') + await inspectBtn.click() + + // Should navigate to exchange detail page + await expect(page).toHaveURL(/\/exchanges\//) + }) +}) diff --git a/e2e/exchanges.spec.ts b/e2e/exchanges.spec.ts new file mode 100644 index 0000000..7c0a955 --- /dev/null +++ b/e2e/exchanges.spec.ts @@ -0,0 +1,60 @@ +import { test, expect } from '@playwright/test' + +test.describe('Exchange Detail (/exchanges/:id)', () => { + test('renders exchange header, timeline, and message panels', async ({ page }) => { + await page.goto('/exchanges/E-2026-03-18-00201') + + // Exchange header — use the one NOT in the breadcrumb + await expect(page.getByText('E-2026-03-18-00201').nth(1)).toBeVisible() + + // Header stats + await expect(page.getByText('Duration').first()).toBeVisible() + await expect(page.getByText('Processors').first()).toBeVisible() + + // Processor Timeline section + await expect(page.getByText('Processor Timeline').first()).toBeVisible() + + // Timeline/Flow toggle buttons + await expect(page.getByRole('button', { name: 'Timeline' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Flow' })).toBeVisible() + + // Message IN panel + await expect(page.getByText('Message IN')).toBeVisible() + await expect(page.getByText('Headers').first()).toBeVisible() + await expect(page.getByText('Body').first()).toBeVisible() + }) + + test('switching between Timeline and Flow view', async ({ page }) => { + await page.goto('/exchanges/E-2026-03-18-00201') + + // Default view is timeline (gantt) + const timelineBtn = page.getByRole('button', { name: 'Timeline' }) + const flowBtn = page.getByRole('button', { name: 'Flow' }) + + // Switch to Flow view + await flowBtn.click() + + // Flow view should render (RouteFlow component) + await expect(flowBtn).toHaveClass(/active|Active/) + + // Switch back to Timeline + await timelineBtn.click() + await expect(timelineBtn).toHaveClass(/active|Active/) + }) + + test('not-found exchange shows warning', async ({ page }) => { + await page.goto('/exchanges/nonexistent-id') + + await expect(page.getByText('not found', { exact: false })).toBeVisible() + }) + + test('breadcrumb navigation works', async ({ page }) => { + await page.goto('/exchanges/E-2026-03-18-00201') + + // Click Applications breadcrumb to go back + const appsBreadcrumb = page.getByRole('link', { name: 'Applications' }) + await appsBreadcrumb.click() + + await expect(page).toHaveURL(/\/apps/) + }) +}) diff --git a/e2e/routes.spec.ts b/e2e/routes.spec.ts new file mode 100644 index 0000000..b217a62 --- /dev/null +++ b/e2e/routes.spec.ts @@ -0,0 +1,63 @@ +import { test, expect } from '@playwright/test' + +test.describe('Routes (/routes)', () => { + test('renders KPI cards, route table, and charts', async ({ page }) => { + await page.goto('/routes') + + // KPI cards + await expect(page.getByText('Total Throughput')).toBeVisible() + await expect(page.getByText('System Error Rate')).toBeVisible() + await expect(page.getByText('Latency Percentiles')).toBeVisible() + await expect(page.getByText('Active Routes')).toBeVisible() + await expect(page.getByText('In-Flight Exchanges')).toBeVisible() + + // Route performance table + await expect(page.getByText('Per-Route Performance')).toBeVisible() + await expect(page.getByRole('columnheader', { name: 'Route' })).toBeVisible() + await expect(page.getByRole('columnheader', { name: 'Exchanges' })).toBeVisible() + await expect(page.getByRole('columnheader', { name: 'Success %' })).toBeVisible() + + const rows = page.locator('table tbody tr') + expect(await rows.count()).toBeGreaterThan(0) + + // Charts render + await expect(page.getByText('Throughput (msg/s)').first()).toBeVisible() + await expect(page.getByText('Latency (ms)')).toBeVisible() + await expect(page.getByText('Errors by Route')).toBeVisible() + await expect(page.getByText('Message Volume (msg/min)')).toBeVisible() + + // Auto-refresh indicator + await expect(page.getByText('Auto-refresh: 30s')).toBeVisible() + }) + + test('clicking a route row navigates to route detail', async ({ page }) => { + await page.goto('/routes') + + // Click first route row + const firstRow = page.locator('table tbody tr').first() + await firstRow.click() + + // Should navigate to route detail + await expect(page).toHaveURL(/\/routes\/[^/]+\/[^/]+/) + + // Route detail view: processor performance table + await expect(page.getByText('Processor Performance')).toBeVisible() + await expect(page.getByRole('columnheader', { name: 'Processor' })).toBeVisible() + await expect(page.getByRole('columnheader', { name: 'Type' })).toBeVisible() + await expect(page.getByRole('columnheader', { name: 'Invocations' })).toBeVisible() + + // Route Flow diagram + await expect(page.getByText('Route Flow')).toBeVisible() + }) + + test('app-scoped routes view filters data', async ({ page }) => { + await page.goto('/routes/order-service') + + // Breadcrumb shows scope + await expect(page.getByRole('link', { name: 'Routes' })).toBeVisible() + await expect(page.getByLabel('Breadcrumb').getByText('order-service')).toBeVisible() + + // Table still renders + await expect(page.getByText('Per-Route Performance')).toBeVisible() + }) +}) diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..d9f3454 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from '@playwright/test' + +export default defineConfig({ + testDir: './e2e', + timeout: 30_000, + retries: 0, + use: { + baseURL: 'http://localhost:5173', + headless: true, + viewport: { width: 1440, height: 900 }, + }, + projects: [ + { name: 'chromium', use: { browserName: 'chromium' } }, + ], + webServer: { + command: 'npm run dev', + port: 5173, + reuseExistingServer: true, + timeout: 15_000, + }, +}) diff --git a/src/design-system/index.ts b/src/design-system/index.ts index 48d3f73..9bf1d53 100644 --- a/src/design-system/index.ts +++ b/src/design-system/index.ts @@ -7,5 +7,7 @@ export * from './layout' export * from './providers/ThemeProvider' export * from './providers/CommandPaletteProvider' export * from './providers/GlobalFilterProvider' +export { BreadcrumbProvider, useBreadcrumb } from './providers/BreadcrumbProvider' +export type { BreadcrumbItem } from './providers/BreadcrumbProvider' export * from './utils/hashColor' export * from './utils/timePresets' diff --git a/src/design-system/layout/TopBar/TopBar.tsx b/src/design-system/layout/TopBar/TopBar.tsx index 9d00bd3..2b5b161 100644 --- a/src/design-system/layout/TopBar/TopBar.tsx +++ b/src/design-system/layout/TopBar/TopBar.tsx @@ -8,11 +8,8 @@ import { TimeRangeDropdown } from '../../primitives/TimeRangeDropdown/TimeRangeD import { useGlobalFilters } from '../../providers/GlobalFilterProvider' import { useCommandPalette } from '../../providers/CommandPaletteProvider' import { useTheme } from '../../providers/ThemeProvider' - -interface BreadcrumbItem { - label: string - href?: string -} +import { useBreadcrumbOverride } from '../../providers/BreadcrumbProvider' +import type { BreadcrumbItem } from '../../providers/BreadcrumbProvider' interface TopBarProps { breadcrumb: BreadcrumbItem[] @@ -39,11 +36,12 @@ export function TopBar({ const globalFilters = useGlobalFilters() const commandPalette = useCommandPalette() const { theme, toggleTheme } = useTheme() + const breadcrumbOverride = useBreadcrumbOverride() return (
{/* Left: Breadcrumb */} - + {/* Search trigger */}