Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26de5ec58f | ||
|
|
d26dc6a8a5 | ||
|
|
c0b1cbdc5b | ||
|
|
d101d883a9 | ||
|
|
2a020c1e15 |
@@ -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:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -2,3 +2,6 @@ node_modules/
|
||||
dist/
|
||||
.superpowers/
|
||||
.worktrees/
|
||||
test-results/
|
||||
screenshots/
|
||||
.playwright-mcp/
|
||||
|
||||
165
e2e/admin.spec.ts
Normal file
165
e2e/admin.spec.ts
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
80
e2e/agents.spec.ts
Normal file
80
e2e/agents.spec.ts
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
90
e2e/dashboard.spec.ts
Normal file
90
e2e/dashboard.spec.ts
Normal file
@@ -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\//)
|
||||
})
|
||||
})
|
||||
60
e2e/exchanges.spec.ts
Normal file
60
e2e/exchanges.spec.ts
Normal file
@@ -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/)
|
||||
})
|
||||
})
|
||||
63
e2e/routes.spec.ts
Normal file
63
e2e/routes.spec.ts
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
21
playwright.config.ts
Normal file
21
playwright.config.ts
Normal file
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -1,4 +1,21 @@
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
z-index: 99;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 100vh;
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
transition: width 0.25s ease, opacity 0.2s ease;
|
||||
@@ -7,13 +24,15 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-surface);
|
||||
flex-shrink: 0;
|
||||
z-index: 100;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.panel.open {
|
||||
width: 400px;
|
||||
opacity: 1;
|
||||
border-left-color: var(--border);
|
||||
box-shadow: var(--shadow-lg);
|
||||
animation: slideInRight 0.25s ease-out both;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,51 +23,54 @@ export function DetailPanel({ open, onClose, title, tabs, children, actions, cla
|
||||
|
||||
const activeContent = tabs?.find((t) => t.value === activeTab)?.content
|
||||
|
||||
const panel = (
|
||||
<aside
|
||||
className={`${styles.panel} ${open ? styles.open : ''} ${className ?? ''}`}
|
||||
aria-hidden={!open}
|
||||
>
|
||||
<div className={styles.header}>
|
||||
<span className={styles.title}>{title}</span>
|
||||
<button
|
||||
className={styles.closeBtn}
|
||||
onClick={onClose}
|
||||
aria-label="Close panel"
|
||||
type="button"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tabs && tabs.length > 0 && (
|
||||
<div className={styles.tabs}>
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.value}
|
||||
className={`${styles.tab} ${tab.value === activeTab ? styles.activeTab : ''}`}
|
||||
onClick={() => setActiveTab(tab.value)}
|
||||
type="button"
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
const content = (
|
||||
<>
|
||||
{open && <div className={styles.backdrop} onClick={onClose} aria-hidden="true" />}
|
||||
<aside
|
||||
className={`${styles.panel} ${open ? styles.open : ''} ${className ?? ''}`}
|
||||
aria-hidden={!open}
|
||||
>
|
||||
<div className={styles.header}>
|
||||
<span className={styles.title}>{title}</span>
|
||||
<button
|
||||
className={styles.closeBtn}
|
||||
onClick={onClose}
|
||||
aria-label="Close panel"
|
||||
type="button"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.body}>
|
||||
{children ?? activeContent}
|
||||
</div>
|
||||
{tabs && tabs.length > 0 && (
|
||||
<div className={styles.tabs}>
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.value}
|
||||
className={`${styles.tab} ${tab.value === activeTab ? styles.activeTab : ''}`}
|
||||
onClick={() => setActiveTab(tab.value)}
|
||||
type="button"
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actions && (
|
||||
<div className={styles.actions}>
|
||||
{actions}
|
||||
<div className={styles.body}>
|
||||
{children ?? activeContent}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{actions && (
|
||||
<div className={styles.actions}>
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</>
|
||||
)
|
||||
|
||||
// Portal to AppShell level if target exists, otherwise render in place
|
||||
const portalTarget = document.getElementById('cameleer-detail-panel-root')
|
||||
return portalTarget ? createPortal(panel, portalTarget) : panel
|
||||
return portalTarget ? createPortal(content, portalTarget) : content
|
||||
}
|
||||
|
||||
@@ -81,25 +81,25 @@ export function EventFeed({ events, maxItems = 200, className }: EventFeedProps)
|
||||
.filter((e) => activeFilters.size === 0 || activeFilters.has(e.severity))
|
||||
.filter((e) => !searchLower || getSearchableText(e).toLowerCase().includes(searchLower))
|
||||
|
||||
// Auto-scroll to bottom
|
||||
const scrollToBottom = useCallback(() => {
|
||||
// Auto-scroll to top (newest entries are at top in desc sort)
|
||||
const scrollToTop = useCallback(() => {
|
||||
const el = scrollRef.current
|
||||
if (el) {
|
||||
el.scrollTop = el.scrollHeight
|
||||
el.scrollTop = 0
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPaused) {
|
||||
scrollToBottom()
|
||||
scrollToTop()
|
||||
}
|
||||
}, [events, isPaused, scrollToBottom])
|
||||
}, [events, isPaused, scrollToTop])
|
||||
|
||||
function handleScroll() {
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 8
|
||||
setIsPaused(!atBottom)
|
||||
const atTop = el.scrollTop < 8
|
||||
setIsPaused(!atTop)
|
||||
}
|
||||
|
||||
function toggleFilter(severity: SeverityFilter) {
|
||||
@@ -196,10 +196,10 @@ export function EventFeed({ events, maxItems = 200, className }: EventFeedProps)
|
||||
className={styles.resumeBtn}
|
||||
onClick={() => {
|
||||
setIsPaused(false)
|
||||
scrollToBottom()
|
||||
scrollToTop()
|
||||
}}
|
||||
>
|
||||
↓ Resume auto-scroll
|
||||
↑ Scroll to latest
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
.container {
|
||||
overflow-y: auto;
|
||||
background: var(--bg-inset);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 8px 0;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
@@ -35,18 +35,18 @@ function formatTime(iso: string): string {
|
||||
|
||||
export function LogViewer({ entries, maxHeight = 400, className }: LogViewerProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const isAtBottomRef = useRef(true)
|
||||
const isAtTopRef = useRef(true)
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
isAtBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 20
|
||||
isAtTopRef.current = el.scrollTop < 20
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current
|
||||
if (el && isAtBottomRef.current) {
|
||||
el.scrollTop = el.scrollHeight
|
||||
if (el && isAtTopRef.current) {
|
||||
el.scrollTop = 0
|
||||
}
|
||||
}, [entries])
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.main {
|
||||
|
||||
@@ -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 (
|
||||
<header className={`${styles.topbar} ${className ?? ''}`}>
|
||||
{/* Left: Breadcrumb */}
|
||||
<Breadcrumb items={breadcrumb} className={styles.breadcrumb} />
|
||||
<Breadcrumb items={breadcrumbOverride ?? breadcrumb} className={styles.breadcrumb} />
|
||||
|
||||
{/* Search trigger */}
|
||||
<button
|
||||
|
||||
44
src/design-system/providers/BreadcrumbProvider.tsx
Normal file
44
src/design-system/providers/BreadcrumbProvider.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { createContext, useContext, useState, useEffect } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
label: string
|
||||
href?: string
|
||||
}
|
||||
|
||||
interface BreadcrumbContextValue {
|
||||
override: BreadcrumbItem[] | null
|
||||
setOverride: (items: BreadcrumbItem[] | null) => void
|
||||
}
|
||||
|
||||
const BreadcrumbContext = createContext<BreadcrumbContextValue>({
|
||||
override: null,
|
||||
setOverride: () => {},
|
||||
})
|
||||
|
||||
export function BreadcrumbProvider({ children }: { children: ReactNode }) {
|
||||
const [override, setOverride] = useState<BreadcrumbItem[] | null>(null)
|
||||
return (
|
||||
<BreadcrumbContext.Provider value={{ override, setOverride }}>
|
||||
{children}
|
||||
</BreadcrumbContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Override the TopBar breadcrumb with page-specific semantic items.
|
||||
* Pass `null` to clear (or let unmount handle it).
|
||||
* Callers should `useMemo` the items array to avoid unnecessary re-renders.
|
||||
*/
|
||||
export function useBreadcrumb(items: BreadcrumbItem[] | null) {
|
||||
const { setOverride } = useContext(BreadcrumbContext)
|
||||
useEffect(() => {
|
||||
setOverride(items)
|
||||
return () => setOverride(null)
|
||||
}, [items, setOverride])
|
||||
}
|
||||
|
||||
/** Internal — used by TopBar to read the current override. */
|
||||
export function useBreadcrumbOverride(): BreadcrumbItem[] | null {
|
||||
return useContext(BreadcrumbContext).override
|
||||
}
|
||||
Reference in New Issue
Block a user