Files
git.stella-ops.org/src/Web/StellaOps.Web/e2e/diff-runtime-tabs.e2e.spec.ts
2026-01-09 18:27:46 +02:00

585 lines
21 KiB
TypeScript

// -----------------------------------------------------------------------------
// diff-runtime-tabs.e2e.spec.ts
// Sprint: SPRINT_20260107_006_002_FE_diff_runtime_tabs
// Task: DR-013 — E2E tests for Diff and Runtime tabs
// -----------------------------------------------------------------------------
import { test, expect, Page } from '@playwright/test';
test.describe('Diff Tab', () => {
let page: Page;
test.beforeEach(async ({ browser }) => {
page = await browser.newPage();
// Navigate to a finding detail page with diff evidence
await page.goto('/triage/findings/test-finding-001');
// Wait for evidence panel to load
await page.waitForSelector('[data-testid="evidence-panel"]');
});
test.afterEach(async () => {
await page.close();
});
test('should display Diff tab in evidence panel', async () => {
const diffTab = page.locator('[data-testid="tab-diff"]');
await expect(diffTab).toBeVisible();
await expect(diffTab).toHaveText(/Diff/i);
});
test('should switch to Diff tab on click', async () => {
const diffTab = page.locator('[data-testid="tab-diff"]');
await diffTab.click();
const diffPanel = page.locator('[data-testid="panel-diff"]');
await expect(diffPanel).toBeVisible();
});
test('should switch to Diff tab with keyboard shortcut', async () => {
// Press '4' to switch to Diff tab (assuming it's the 4th tab)
await page.keyboard.press('4');
const diffPanel = page.locator('[data-testid="panel-diff"]');
await expect(diffPanel).toBeVisible();
});
test('should display backport verdict badge', async () => {
await page.locator('[data-testid="tab-diff"]').click();
await page.waitForSelector('[data-testid="backport-verdict-badge"]');
const badge = page.locator('[data-testid="backport-verdict-badge"]');
await expect(badge).toBeVisible();
// Should show one of the verdict states
await expect(badge).toHaveAttribute('data-status', /verified|unverified|unknown|partial/);
});
test('should display confidence percentage', async () => {
await page.locator('[data-testid="tab-diff"]').click();
await page.waitForSelector('[data-testid="backport-verdict-badge"]');
const confidence = page.locator('[data-testid="verdict-confidence"]');
await expect(confidence).toBeVisible();
// Should show percentage
await expect(confidence).toHaveText(/%/);
});
test('should show tooltip with tier info on badge hover', async () => {
await page.locator('[data-testid="tab-diff"]').click();
await page.waitForSelector('[data-testid="backport-verdict-badge"]');
const badge = page.locator('[data-testid="backport-verdict-badge"]');
await badge.hover();
const tooltip = page.locator('[data-testid="verdict-tooltip"]');
await expect(tooltip).toBeVisible();
await expect(tooltip).toHaveText(/Tier/i);
});
test('should display version comparison section', async () => {
await page.locator('[data-testid="tab-diff"]').click();
await page.waitForSelector('[data-testid="version-compare"]');
const upstream = page.locator('[data-testid="version-upstream"]');
const distro = page.locator('[data-testid="version-distro"]');
await expect(upstream).toBeVisible();
await expect(distro).toBeVisible();
});
test('should display upstream commit link', async () => {
await page.locator('[data-testid="tab-diff"]').click();
await page.waitForSelector('[data-testid="commit-link"]');
const commitLink = page.locator('[data-testid="commit-link"]');
await expect(commitLink).toBeVisible();
await expect(commitLink).toHaveAttribute('href', /github\.com|gitlab\.com/);
await expect(commitLink).toHaveAttribute('target', '_blank');
});
test('should display patch diff viewer', async () => {
await page.locator('[data-testid="tab-diff"]').click();
await page.waitForSelector('[data-testid="patch-diff-viewer"]');
const diffViewer = page.locator('[data-testid="patch-diff-viewer"]');
await expect(diffViewer).toBeVisible();
});
test('should expand and collapse diff hunks', async () => {
await page.locator('[data-testid="tab-diff"]').click();
await page.waitForSelector('[data-testid="patch-diff-viewer"]');
const hunkHeader = page.locator('[data-testid="hunk-header"]').first();
const hunkContent = page.locator('[data-testid="hunk-content"]').first();
// Initially expanded
await expect(hunkContent).toBeVisible();
// Click to collapse
await hunkHeader.click();
await expect(hunkContent).not.toBeVisible();
// Click to expand again
await hunkHeader.click();
await expect(hunkContent).toBeVisible();
});
test('should display line numbers in diff', async () => {
await page.locator('[data-testid="tab-diff"]').click();
await page.waitForSelector('[data-testid="patch-diff-viewer"]');
const lineNumbers = page.locator('[data-testid="line-number"]');
await expect(lineNumbers.first()).toBeVisible();
});
test('should highlight additions in green', async () => {
await page.locator('[data-testid="tab-diff"]').click();
await page.waitForSelector('[data-testid="patch-diff-viewer"]');
const additionLine = page.locator('[data-testid="diff-line-addition"]').first();
await expect(additionLine).toBeVisible();
// Check for green background styling
await expect(additionLine).toHaveClass(/addition/);
});
test('should highlight deletions in red', async () => {
await page.locator('[data-testid="tab-diff"]').click();
await page.waitForSelector('[data-testid="patch-diff-viewer"]');
const deletionLine = page.locator('[data-testid="diff-line-deletion"]').first();
await expect(deletionLine).toBeVisible();
await expect(deletionLine).toHaveClass(/deletion/);
});
test('should copy diff to clipboard', async () => {
await page.locator('[data-testid="tab-diff"]').click();
await page.waitForSelector('[data-testid="patch-diff-viewer"]');
const copyBtn = page.locator('[data-testid="copy-diff-btn"]');
await copyBtn.click();
// Check clipboard content (requires clipboard permissions)
const clipboardText = await page.evaluate(() => navigator.clipboard.readText());
expect(clipboardText).toContain('@@');
});
test('should copy hunk signature', async () => {
await page.locator('[data-testid="tab-diff"]').click();
await page.waitForSelector('[data-testid="hunk-signature"]');
const copyHashBtn = page.locator('[data-testid="copy-hash-btn"]');
await copyHashBtn.click();
const clipboardText = await page.evaluate(() => navigator.clipboard.readText());
expect(clipboardText).toMatch(/sha256:/);
});
test('should show empty state when no diff evidence', async () => {
// Navigate to finding without diff evidence
await page.goto('/triage/findings/no-diff-finding');
await page.waitForSelector('[data-testid="evidence-panel"]');
await page.locator('[data-testid="tab-diff"]').click();
const emptyState = page.locator('[data-testid="diff-empty-state"]');
await expect(emptyState).toBeVisible();
await expect(emptyState).toHaveText(/No backport evidence/i);
});
test('should handle loading state', async () => {
// Slow down network to observe loading state
await page.route('**/api/v1/findings/**/backport', async (route) => {
await new Promise((resolve) => setTimeout(resolve, 1000));
await route.continue();
});
await page.locator('[data-testid="tab-diff"]').click();
const loadingState = page.locator('[data-testid="diff-loading"]');
await expect(loadingState).toBeVisible();
});
test('should handle error state with retry', async () => {
// Mock API error
await page.route('**/api/v1/findings/**/backport', (route) => {
route.fulfill({ status: 500, body: 'Server Error' });
});
await page.locator('[data-testid="tab-diff"]').click();
const errorState = page.locator('[data-testid="diff-error"]');
await expect(errorState).toBeVisible();
const retryBtn = page.locator('[data-testid="diff-retry-btn"]');
await expect(retryBtn).toBeVisible();
});
});
test.describe('Runtime Tab', () => {
let page: Page;
test.beforeEach(async ({ browser }) => {
page = await browser.newPage();
await page.goto('/triage/findings/test-finding-001');
await page.waitForSelector('[data-testid="evidence-panel"]');
});
test.afterEach(async () => {
await page.close();
});
test('should display Runtime tab in evidence panel', async () => {
const runtimeTab = page.locator('[data-testid="tab-runtime"]');
await expect(runtimeTab).toBeVisible();
await expect(runtimeTab).toHaveText(/Runtime/i);
});
test('should switch to Runtime tab on click', async () => {
const runtimeTab = page.locator('[data-testid="tab-runtime"]');
await runtimeTab.click();
const runtimePanel = page.locator('[data-testid="panel-runtime"]');
await expect(runtimePanel).toBeVisible();
});
test('should switch to Runtime tab with keyboard shortcut', async () => {
await page.keyboard.press('5');
const runtimePanel = page.locator('[data-testid="panel-runtime"]');
await expect(runtimePanel).toBeVisible();
});
test('should display live indicator when collection active', async () => {
// Mock active collection response
await page.route('**/api/v1/findings/**/runtime/traces', (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
findingId: 'test-finding-001',
collectionActive: true,
collectionStarted: new Date().toISOString(),
summary: { totalHits: 100, uniquePaths: 3 },
traces: [],
}),
});
});
await page.locator('[data-testid="tab-runtime"]').click();
await page.waitForSelector('[data-testid="live-indicator"]');
const liveIndicator = page.locator('[data-testid="live-indicator"]');
await expect(liveIndicator).toBeVisible();
await expect(liveIndicator).toHaveClass(/active/);
await expect(liveIndicator).toHaveText(/Live/i);
});
test('should show offline indicator when collection stopped', async () => {
await page.route('**/api/v1/findings/**/runtime/traces', (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
findingId: 'test-finding-001',
collectionActive: false,
summary: { totalHits: 100, uniquePaths: 3 },
traces: [],
}),
});
});
await page.locator('[data-testid="tab-runtime"]').click();
await page.waitForSelector('[data-testid="live-indicator"]');
const liveIndicator = page.locator('[data-testid="live-indicator"]');
await expect(liveIndicator).toHaveClass(/inactive/);
await expect(liveIndicator).toHaveText(/Offline/i);
});
test('should show tooltip on live indicator hover', async () => {
await page.locator('[data-testid="tab-runtime"]').click();
await page.waitForSelector('[data-testid="live-indicator"]');
const indicator = page.locator('[data-testid="live-indicator"]');
await indicator.hover();
const tooltip = page.locator('[data-testid="live-tooltip"]');
await expect(tooltip).toBeVisible();
});
test('should display summary stats', async () => {
await page.locator('[data-testid="tab-runtime"]').click();
await page.waitForSelector('[data-testid="summary-stats"]');
const totalHits = page.locator('[data-testid="stat-total-hits"]');
const uniquePaths = page.locator('[data-testid="stat-unique-paths"]');
const containers = page.locator('[data-testid="stat-containers"]');
await expect(totalHits).toBeVisible();
await expect(uniquePaths).toBeVisible();
await expect(containers).toBeVisible();
});
test('should display RTS score', async () => {
await page.locator('[data-testid="tab-runtime"]').click();
await page.waitForSelector('[data-testid="rts-score-display"]');
const rtsScore = page.locator('[data-testid="rts-score-display"]');
await expect(rtsScore).toBeVisible();
const scoreValue = page.locator('[data-testid="rts-value"]');
await expect(scoreValue).toHaveText(/%/);
});
test('should display posture badge', async () => {
await page.locator('[data-testid="tab-runtime"]').click();
await page.waitForSelector('[data-testid="posture-badge"]');
const postureBadge = page.locator('[data-testid="posture-badge"]');
await expect(postureBadge).toBeVisible();
await expect(postureBadge).toHaveText(/Excellent|Good|Limited|None/i);
});
test('should expand RTS score breakdown', async () => {
await page.locator('[data-testid="tab-runtime"]').click();
await page.waitForSelector('[data-testid="rts-score-display"]');
const breakdownToggle = page.locator('[data-testid="breakdown-toggle"]');
await breakdownToggle.click();
const breakdownContent = page.locator('[data-testid="breakdown-content"]');
await expect(breakdownContent).toBeVisible();
});
test('should display function traces list', async () => {
await page.locator('[data-testid="tab-runtime"]').click();
await page.waitForSelector('[data-testid="traces-list"]');
const tracesList = page.locator('[data-testid="traces-list"]');
await expect(tracesList).toBeVisible();
const traceItems = page.locator('[data-testid="function-trace"]');
await expect(traceItems.first()).toBeVisible();
});
test('should expand function trace to show call stack', async () => {
await page.locator('[data-testid="tab-runtime"]').click();
await page.waitForSelector('[data-testid="function-trace"]');
const traceHeader = page.locator('[data-testid="trace-header"]').first();
await traceHeader.click();
const callPath = page.locator('[data-testid="call-path"]').first();
await expect(callPath).toBeVisible();
});
test('should display stack frames with file:line links', async () => {
await page.locator('[data-testid="tab-runtime"]').click();
await page.waitForSelector('[data-testid="function-trace"]');
// Expand first trace
await page.locator('[data-testid="trace-header"]').first().click();
const frameLocation = page.locator('[data-testid="frame-location"]').first();
await expect(frameLocation).toBeVisible();
await expect(frameLocation).toHaveText(/:\d+/); // file:line format
});
test('should highlight vulnerable function in stack', async () => {
await page.locator('[data-testid="tab-runtime"]').click();
await page.waitForSelector('[data-testid="function-trace"]');
await page.locator('[data-testid="trace-header"]').first().click();
const vulnFrame = page.locator('[data-testid="stack-frame-vulnerable"]');
await expect(vulnFrame).toBeVisible();
await expect(vulnFrame).toHaveClass(/vulnerable/);
});
test('should copy stack trace', async () => {
await page.locator('[data-testid="tab-runtime"]').click();
await page.waitForSelector('[data-testid="function-trace"]');
await page.locator('[data-testid="trace-header"]').first().click();
const copyBtn = page.locator('[data-testid="copy-stack-btn"]').first();
await copyBtn.click();
const clipboardText = await page.evaluate(() => navigator.clipboard.readText());
expect(clipboardText).toContain('at ');
});
test('should sort traces by hits', async () => {
await page.locator('[data-testid="tab-runtime"]').click();
await page.waitForSelector('[data-testid="traces-list"]');
const sortByHits = page.locator('[data-testid="sort-by-hits"]');
await sortByHits.click();
// First trace should have highest hit count
const firstHitCount = await page.locator('[data-testid="hit-count"]').first().textContent();
const secondHitCount = await page.locator('[data-testid="hit-count"]').nth(1).textContent();
// Parse hit counts (handle K/M suffixes)
const parseCount = (str: string | null) => {
if (!str) return 0;
const num = parseFloat(str.replace(/[KM]/g, ''));
if (str.includes('K')) return num * 1000;
if (str.includes('M')) return num * 1000000;
return num;
};
expect(parseCount(firstHitCount)).toBeGreaterThanOrEqual(parseCount(secondHitCount));
});
test('should sort traces by recency', async () => {
await page.locator('[data-testid="tab-runtime"]').click();
await page.waitForSelector('[data-testid="traces-list"]');
const sortByRecent = page.locator('[data-testid="sort-by-recent"]');
await sortByRecent.click();
// Verify sorting changed
await expect(sortByRecent).toHaveClass(/active/);
});
test('should load more traces on button click', async () => {
await page.locator('[data-testid="tab-runtime"]').click();
await page.waitForSelector('[data-testid="traces-list"]');
const initialCount = await page.locator('[data-testid="function-trace"]').count();
const loadMoreBtn = page.locator('[data-testid="load-more-btn"]');
if (await loadMoreBtn.isVisible()) {
await loadMoreBtn.click();
const newCount = await page.locator('[data-testid="function-trace"]').count();
expect(newCount).toBeGreaterThan(initialCount);
}
});
test('should show direct path indicator', async () => {
await page.locator('[data-testid="tab-runtime"]').click();
await page.waitForSelector('[data-testid="indicator-direct-path"]');
const directPathIndicator = page.locator('[data-testid="indicator-direct-path"]');
await expect(directPathIndicator).toBeVisible();
});
test('should show production traffic indicator', async () => {
await page.locator('[data-testid="tab-runtime"]').click();
await page.waitForSelector('[data-testid="indicator-production"]');
const prodIndicator = page.locator('[data-testid="indicator-production"]');
await expect(prodIndicator).toBeVisible();
});
test('should show empty state when no runtime data', async () => {
await page.goto('/triage/findings/no-runtime-finding');
await page.waitForSelector('[data-testid="evidence-panel"]');
await page.locator('[data-testid="tab-runtime"]').click();
const emptyState = page.locator('[data-testid="runtime-empty-state"]');
await expect(emptyState).toBeVisible();
await expect(emptyState).toHaveText(/No runtime observations/i);
});
test('should poll for updates when live', async () => {
let requestCount = 0;
await page.route('**/api/v1/findings/**/runtime/traces', (route) => {
requestCount++;
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
findingId: 'test-finding-001',
collectionActive: true,
summary: { totalHits: 100 + requestCount * 10, uniquePaths: 3 },
traces: [],
}),
});
});
await page.locator('[data-testid="tab-runtime"]').click();
// Wait for initial request
await page.waitForTimeout(100);
const initialCount = requestCount;
// Wait for poll interval (assuming 30s, we'll use a shorter timeout in test)
await page.waitForTimeout(35000);
expect(requestCount).toBeGreaterThan(initialCount);
});
});
test.describe('Tab Persistence', () => {
test('should persist selected tab in URL', async ({ page }) => {
await page.goto('/triage/findings/test-finding-001');
await page.waitForSelector('[data-testid="evidence-panel"]');
await page.locator('[data-testid="tab-diff"]').click();
// Check URL contains tab parameter
expect(page.url()).toContain('tab=diff');
});
test('should restore tab from URL on page load', async ({ page }) => {
await page.goto('/triage/findings/test-finding-001?tab=runtime');
await page.waitForSelector('[data-testid="evidence-panel"]');
const runtimePanel = page.locator('[data-testid="panel-runtime"]');
await expect(runtimePanel).toBeVisible();
});
});
test.describe('Accessibility', () => {
test('should have proper ARIA roles for Diff tab', async ({ page }) => {
await page.goto('/triage/findings/test-finding-001');
await page.waitForSelector('[data-testid="evidence-panel"]');
await page.locator('[data-testid="tab-diff"]').click();
const diffPanel = page.locator('[data-testid="panel-diff"]');
await expect(diffPanel).toHaveAttribute('role', 'tabpanel');
const badge = page.locator('[data-testid="backport-verdict-badge"]');
await expect(badge).toHaveAttribute('role', 'status');
});
test('should have proper ARIA roles for Runtime tab', async ({ page }) => {
await page.goto('/triage/findings/test-finding-001');
await page.waitForSelector('[data-testid="evidence-panel"]');
await page.locator('[data-testid="tab-runtime"]').click();
const runtimePanel = page.locator('[data-testid="panel-runtime"]');
await expect(runtimePanel).toHaveAttribute('role', 'tabpanel');
const indicator = page.locator('[data-testid="live-indicator"]');
await expect(indicator).toHaveAttribute('role', 'status');
});
test('should be keyboard navigable', async ({ page }) => {
await page.goto('/triage/findings/test-finding-001');
await page.waitForSelector('[data-testid="evidence-panel"]');
// Tab to diff tab
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
// Check diff tab is focused
const focusedElement = await page.locator(':focus');
await expect(focusedElement).toHaveAttribute('data-testid', 'tab-diff');
// Press Enter to activate
await page.keyboard.press('Enter');
const diffPanel = page.locator('[data-testid="panel-diff"]');
await expect(diffPanel).toBeVisible();
});
});