// ----------------------------------------------------------------------------- // unified-search.e2e.spec.ts // E2E tests for the Unified Search feature. // Tests search input, entity cards, domain filters, keyboard navigation, // synthesis panel, empty state, and accessibility. // ----------------------------------------------------------------------------- import { expect, test, type Page } from '@playwright/test'; import AxeBuilder from '@axe-core/playwright'; import { policyAuthorSession } from '../../src/app/testing'; // --------------------------------------------------------------------------- // Shared mock data // --------------------------------------------------------------------------- const mockConfig = { authority: { issuer: 'https://authority.local', clientId: 'stella-ops-ui', authorizeEndpoint: 'https://authority.local/connect/authorize', tokenEndpoint: 'https://authority.local/connect/token', logoutEndpoint: 'https://authority.local/connect/logout', redirectUri: 'http://127.0.0.1:4400/auth/callback', postLogoutRedirectUri: 'http://127.0.0.1:4400/', scope: 'openid profile email ui.read advisory:search advisory:read search:read findings:read', audience: 'https://scanner.local', dpopAlgorithms: ['ES256'], refreshLeewaySeconds: 60, }, apiBaseUrls: { authority: 'https://authority.local', scanner: 'https://scanner.local', policy: 'https://policy.local', concelier: 'https://concelier.local', attestor: 'https://attestor.local', gateway: 'https://gateway.local', }, quickstartMode: true, setup: 'complete', }; const oidcConfig = { issuer: mockConfig.authority.issuer, authorization_endpoint: mockConfig.authority.authorizeEndpoint, token_endpoint: mockConfig.authority.tokenEndpoint, jwks_uri: 'https://authority.local/.well-known/jwks.json', response_types_supported: ['code'], subject_types_supported: ['public'], id_token_signing_alg_values_supported: ['RS256'], }; const shellSession = { ...policyAuthorSession, scopes: [ ...new Set([ ...policyAuthorSession.scopes, 'ui.read', 'admin', 'advisory:search', 'advisory:read', 'search:read', 'findings:read', 'vex:read', ]), ], }; // --------------------------------------------------------------------------- // Mock API response fixtures // --------------------------------------------------------------------------- const searchResultsResponse = { query: 'CVE-2024-21626', topK: 10, cards: [ { entityKey: 'cve:CVE-2024-21626', entityType: 'finding', domain: 'findings', title: 'CVE-2024-21626: Container Escape via runc', snippet: 'A container escape vulnerability in runc allows...', score: 0.95, severity: 'critical', actions: [ { label: 'View Finding', actionType: 'navigate', route: '/security/triage?q=CVE-2024-21626', isPrimary: true, }, { label: 'Copy CVE', actionType: 'copy', command: 'CVE-2024-21626', isPrimary: false, }, ], sources: ['findings'], }, { entityKey: 'vex:CVE-2024-21626', entityType: 'vex_statement', domain: 'vex', title: 'VEX: CVE-2024-21626 - Not Affected', snippet: 'Product not affected by CVE-2024-21626...', score: 0.82, actions: [ { label: 'View VEX', actionType: 'navigate', route: '/security/advisories-vex?q=CVE-2024-21626', isPrimary: true, }, ], sources: ['vex'], }, { entityKey: 'docs:container-deployment', entityType: 'docs', domain: 'knowledge', title: 'Container Deployment Guide', snippet: 'Guide for deploying containers securely...', score: 0.65, actions: [ { label: 'Open', actionType: 'navigate', route: '/docs/deploy.md#overview', isPrimary: true, }, ], sources: ['knowledge'], }, ], synthesis: { summary: 'Results for CVE-2024-21626: 1 finding, 1 VEX statement, 1 knowledge result. CRITICAL severity finding detected.', template: 'cve_summary', confidence: 'high', sourceCount: 3, domainsCovered: ['findings', 'vex', 'knowledge'], }, diagnostics: { ftsMatches: 5, vectorMatches: 3, entityCardCount: 3, durationMs: 42, usedVector: true, mode: 'hybrid', }, }; const emptySearchResponse = { query: 'xyznonexistent999', topK: 10, cards: [], synthesis: { summary: 'No results found for the given query.', template: 'empty', confidence: 'high', sourceCount: 0, domainsCovered: [], }, diagnostics: { ftsMatches: 0, vectorMatches: 0, entityCardCount: 0, durationMs: 5, usedVector: true, mode: 'hybrid', }, }; // --------------------------------------------------------------------------- // Test helpers // --------------------------------------------------------------------------- async function setupBasicMocks(page: Page) { page.on('console', (message) => { if (message.type() === 'error') { console.log('[browser:error]', message.text()); } }); await page.route('**/config.json', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockConfig), }), ); await page.route('**/platform/envsettings.json', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockConfig), }), ); await page.route('https://authority.local/**', (route) => { const url = route.request().url(); if (url.includes('/.well-known/openid-configuration')) { return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(oidcConfig), }); } if (url.includes('/.well-known/jwks.json')) { return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ keys: [] }), }); } if (url.includes('authorize')) { return route.abort(); } return route.fulfill({ status: 400, body: 'blocked' }); }); } async function setupAuthenticatedSession(page: Page) { await page.addInitScript((stubSession) => { (window as any).__stellaopsTestSession = stubSession; }, shellSession); } /** * Intercepts POST requests to the unified search endpoint and replies with * the provided fixture payload. */ async function mockSearchApi(page: Page, responseBody: unknown) { await page.route('**/search/query**', (route) => { if (route.request().method() === 'POST') { return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(responseBody), }); } return route.continue(); }); } /** * Focuses the global search input and types a query. * Returns the search input locator for further assertions. */ async function typeInSearchInput(page: Page, query: string) { const searchInput = page.locator('app-global-search input[type="text"]'); await searchInput.focus(); await searchInput.fill(query); return searchInput; } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- test.describe('Unified Search', () => { test.beforeEach(async ({ page }) => { await setupBasicMocks(page); await setupAuthenticatedSession(page); }); // ------------------------------------------------------------------------- // 1. Search input is visible and focusable // ------------------------------------------------------------------------- test('search input is visible and focusable', async ({ page }) => { await page.goto('/'); await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 }); const searchInput = page.locator('app-global-search input[type="text"]'); await expect(searchInput).toBeVisible({ timeout: 10_000 }); await expect(searchInput).toHaveAttribute('placeholder', /search everything/i); await searchInput.focus(); await expect(searchInput).toBeFocused(); }); // ------------------------------------------------------------------------- // 2. Typing a query shows entity cards // ------------------------------------------------------------------------- test('typing a query shows entity cards with correct titles', async ({ page }) => { await mockSearchApi(page, searchResultsResponse); await page.goto('/'); await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 }); await typeInSearchInput(page, 'CVE-2024-21626'); const resultsContainer = page.locator('.search__results'); await expect(resultsContainer).toBeVisible({ timeout: 10_000 }); // Verify entity cards render const entityCards = page.locator('app-entity-card'); await expect(entityCards).toHaveCount(3, { timeout: 10_000 }); // Verify card titles match the mock data const cardTitles = await entityCards.allTextContents(); const combinedText = cardTitles.join(' '); expect(combinedText).toContain('CVE-2024-21626'); expect(combinedText).toContain('Container Escape via runc'); expect(combinedText).toContain('VEX'); expect(combinedText).toContain('Container Deployment Guide'); }); // ------------------------------------------------------------------------- // 3. Domain filter chips work // ------------------------------------------------------------------------- test('domain filter chips are present and clickable', async ({ page }) => { await mockSearchApi(page, searchResultsResponse); await page.goto('/'); await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 }); await typeInSearchInput(page, 'CVE-2024-21626'); const resultsContainer = page.locator('.search__results'); await expect(resultsContainer).toBeVisible({ timeout: 10_000 }); await expect(page.locator('app-entity-card')).toHaveCount(3, { timeout: 10_000 }); // Domain filter chips should be rendered (findings, vex, knowledge) const filterChips = resultsContainer.locator('[data-role="domain-filter"], .search__filters .search__filter, .search__filter, .chip'); const chipCount = await filterChips.count(); expect(chipCount).toBeGreaterThanOrEqual(1); // Click the first chip and verify it toggles (gets an active/selected class) const firstChip = filterChips.first(); await firstChip.click(); // After clicking a filter chip, results should still be visible // (the component filters client-side or re-fetches) await expect(resultsContainer).toBeVisible(); }); // ------------------------------------------------------------------------- // 4. Keyboard navigation // ------------------------------------------------------------------------- test('keyboard navigation: ArrowDown moves selection, Escape closes results', async ({ page, }) => { await mockSearchApi(page, searchResultsResponse); await page.goto('/'); await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 }); const searchInput = await typeInSearchInput(page, 'CVE-2024-21626'); const resultsContainer = page.locator('.search__results'); await expect(resultsContainer).toBeVisible({ timeout: 10_000 }); // Press ArrowDown to move selection to the first entity card await searchInput.press('ArrowDown'); // First card should receive a visual active/selected indicator const firstCard = page.locator('app-entity-card').first(); await expect(firstCard).toBeVisible(); // Press ArrowDown again to move to the second card await searchInput.press('ArrowDown'); // Press Escape to close the results panel await page.keyboard.press('Escape'); await expect(resultsContainer).toBeHidden({ timeout: 5_000 }); }); test('keyboard navigation: Enter on selected card triggers primary action', async ({ page, }) => { await mockSearchApi(page, searchResultsResponse); await page.goto('/'); await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 }); const searchInput = await typeInSearchInput(page, 'CVE-2024-21626'); const resultsContainer = page.locator('.search__results'); await expect(resultsContainer).toBeVisible({ timeout: 10_000 }); await expect(page.locator('app-entity-card')).toHaveCount(3, { timeout: 10_000 }); // Navigate to the first card await searchInput.press('ArrowDown'); const initialUrl = page.url(); await page.keyboard.press('Enter'); // Enter should trigger the selected card primary route. await expect.poll(() => page.url(), { timeout: 10_000 }).not.toBe(initialUrl); }); // ------------------------------------------------------------------------- // 5. Synthesis panel renders // ------------------------------------------------------------------------- test('synthesis panel renders summary text after search', async ({ page }) => { await mockSearchApi(page, searchResultsResponse); await page.goto('/'); await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 }); await typeInSearchInput(page, 'CVE-2024-21626'); const resultsContainer = page.locator('.search__results'); await expect(resultsContainer).toBeVisible({ timeout: 10_000 }); // Synthesis panel should render const synthesisPanel = page.locator('app-synthesis-panel'); await expect(synthesisPanel).toBeVisible({ timeout: 10_000 }); // Verify the synthesis summary text is present const synthesisText = await synthesisPanel.textContent(); expect(synthesisText).toContain('CVE-2024-21626'); expect(synthesisText).toContain('CRITICAL'); // Verify domain coverage indicators expect(synthesisText).toContain('finding'); expect(synthesisText).toContain('VEX'); }); // ------------------------------------------------------------------------- // 6. Empty state // ------------------------------------------------------------------------- test('empty state shows "No results" message when API returns zero cards', async ({ page, }) => { await mockSearchApi(page, emptySearchResponse); await page.goto('/'); await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 }); await typeInSearchInput(page, 'xyznonexistent999'); const resultsContainer = page.locator('.search__results'); await expect(resultsContainer).toBeVisible({ timeout: 10_000 }); // No entity cards should be rendered const entityCards = page.locator('app-entity-card'); await expect(entityCards).toHaveCount(0, { timeout: 5_000 }); // A "No results" or empty state message should be visible const noResultsText = resultsContainer.getByText(/no results/i); await expect(noResultsText).toBeVisible({ timeout: 5_000 }); }); // ------------------------------------------------------------------------- // 7. Accessibility (axe-core) // ------------------------------------------------------------------------- test('search results pass axe-core accessibility checks', async ({ page }) => { await mockSearchApi(page, searchResultsResponse); await page.goto('/'); await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 }); await typeInSearchInput(page, 'CVE-2024-21626'); const resultsContainer = page.locator('.search__results'); await expect(resultsContainer).toBeVisible({ timeout: 10_000 }); // Wait for entity cards to be fully rendered before scanning await expect(page.locator('app-entity-card')).toHaveCount(3, { timeout: 10_000 }); // Run axe-core against the search results region const a11yResults = await new AxeBuilder({ page }) .withTags(['wcag2a', 'wcag2aa']) .include('.search') .analyze(); const violations = a11yResults.violations; // Log violations for debugging but allow the test to surface issues if (violations.length > 0) { console.log( '[a11y] Unified search violations:', JSON.stringify( violations.map((v) => ({ id: v.id, impact: v.impact, description: v.description, nodes: v.nodes.length, })), null, 2, ), ); } // Fail on serious or critical a11y violations const serious = violations.filter( (v) => v.impact === 'critical' || v.impact === 'serious', ); expect( serious, `Expected no critical/serious a11y violations but found ${serious.length}: ${serious.map((v) => v.id).join(', ')}`, ).toHaveLength(0); }); });