save progress
This commit is contained in:
584
src/Web/StellaOps.Web/e2e/diff-runtime-tabs.e2e.spec.ts
Normal file
584
src/Web/StellaOps.Web/e2e/diff-runtime-tabs.e2e.spec.ts
Normal file
@@ -0,0 +1,584 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
268
src/Web/StellaOps.Web/e2e/evidence-panel.e2e.spec.ts
Normal file
268
src/Web/StellaOps.Web/e2e/evidence-panel.e2e.spec.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// evidence-panel.e2e.spec.ts
|
||||
// Sprint: SPRINT_20260107_006_001_FE_tabbed_evidence_panel
|
||||
// Task: EP-013 — E2E Tests: Test tab switching, evidence loading, copy JSON, URL persistence
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Evidence Panel E2E', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Navigate to triage view with a finding
|
||||
await page.goto('/triage/findings/test-finding-123');
|
||||
// Wait for evidence panel to load
|
||||
await page.waitForSelector('.evidence-panel');
|
||||
});
|
||||
|
||||
test.describe('Tab switching', () => {
|
||||
test('should display all 5 tabs', async ({ page }) => {
|
||||
const tabs = page.locator('.tab-btn');
|
||||
await expect(tabs).toHaveCount(5);
|
||||
|
||||
await expect(tabs.nth(0)).toContainText('Provenance');
|
||||
await expect(tabs.nth(1)).toContainText('Reachability');
|
||||
await expect(tabs.nth(2)).toContainText('Diff');
|
||||
await expect(tabs.nth(3)).toContainText('Runtime');
|
||||
await expect(tabs.nth(4)).toContainText('Policy');
|
||||
});
|
||||
|
||||
test('should default to Provenance tab', async ({ page }) => {
|
||||
const activeTab = page.locator('.tab-btn--active');
|
||||
await expect(activeTab).toContainText('Provenance');
|
||||
});
|
||||
|
||||
test('should switch to Reachability tab on click', async ({ page }) => {
|
||||
await page.click('[aria-controls="panel-reachability"]');
|
||||
|
||||
const activeTab = page.locator('.tab-btn--active');
|
||||
await expect(activeTab).toContainText('Reachability');
|
||||
|
||||
const panel = page.locator('#panel-reachability');
|
||||
await expect(panel).toBeVisible();
|
||||
});
|
||||
|
||||
test('should switch tabs with keyboard shortcuts 1-5', async ({ page }) => {
|
||||
// Press 2 for Reachability
|
||||
await page.keyboard.press('2');
|
||||
await expect(page.locator('.tab-btn--active')).toContainText('Reachability');
|
||||
|
||||
// Press 4 for Runtime
|
||||
await page.keyboard.press('4');
|
||||
await expect(page.locator('.tab-btn--active')).toContainText('Runtime');
|
||||
|
||||
// Press 1 for Provenance
|
||||
await page.keyboard.press('1');
|
||||
await expect(page.locator('.tab-btn--active')).toContainText('Provenance');
|
||||
});
|
||||
|
||||
test('should navigate tabs with arrow keys', async ({ page }) => {
|
||||
// Focus the tab nav
|
||||
await page.locator('.tab-btn--active').focus();
|
||||
|
||||
// Arrow right to Reachability
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect(page.locator('.tab-btn--active')).toContainText('Reachability');
|
||||
|
||||
// Arrow left back to Provenance
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await expect(page.locator('.tab-btn--active')).toContainText('Provenance');
|
||||
});
|
||||
|
||||
test('should wrap tabs on arrow navigation', async ({ page }) => {
|
||||
await page.locator('.tab-btn--active').focus();
|
||||
|
||||
// Arrow left from first should go to last
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await expect(page.locator('.tab-btn--active')).toContainText('Policy');
|
||||
|
||||
// Arrow right from last should go to first
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect(page.locator('.tab-btn--active')).toContainText('Provenance');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('URL persistence', () => {
|
||||
test('should update URL on tab change', async ({ page }) => {
|
||||
await page.click('[aria-controls="panel-diff"]');
|
||||
|
||||
await expect(page).toHaveURL(/[?&]tab=diff/);
|
||||
});
|
||||
|
||||
test('should restore tab from URL on page load', async ({ page }) => {
|
||||
await page.goto('/triage/findings/test-finding-123?tab=runtime');
|
||||
|
||||
await expect(page.locator('.tab-btn--active')).toContainText('Runtime');
|
||||
});
|
||||
|
||||
test('should handle invalid tab in URL gracefully', async ({ page }) => {
|
||||
await page.goto('/triage/findings/test-finding-123?tab=invalid');
|
||||
|
||||
// Should default to provenance
|
||||
await expect(page.locator('.tab-btn--active')).toContainText('Provenance');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Evidence loading', () => {
|
||||
test('should show loading spinner while fetching', async ({ page }) => {
|
||||
// Slow down network to catch spinner
|
||||
await page.route('**/api/evidence/**', async (route) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await page.click('[aria-controls="panel-reachability"]');
|
||||
|
||||
const spinner = page.locator('#panel-reachability .loading-state .spinner');
|
||||
await expect(spinner).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show error state on fetch failure', async ({ page }) => {
|
||||
await page.route('**/api/evidence/reachability/**', (route) => {
|
||||
route.fulfill({
|
||||
status: 500,
|
||||
body: JSON.stringify({ error: 'Internal server error' }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.click('[aria-controls="panel-reachability"]');
|
||||
|
||||
const error = page.locator('#panel-reachability .error-state');
|
||||
await expect(error).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show retry button on error', async ({ page }) => {
|
||||
await page.route('**/api/evidence/reachability/**', (route) => {
|
||||
route.fulfill({
|
||||
status: 500,
|
||||
body: JSON.stringify({ error: 'Internal server error' }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.click('[aria-controls="panel-reachability"]');
|
||||
|
||||
const retryBtn = page.locator('#panel-reachability .retry-btn');
|
||||
await expect(retryBtn).toBeVisible();
|
||||
});
|
||||
|
||||
test('should reload evidence on retry', async ({ page }) => {
|
||||
let requestCount = 0;
|
||||
await page.route('**/api/evidence/reachability/**', (route) => {
|
||||
requestCount++;
|
||||
if (requestCount === 1) {
|
||||
route.fulfill({ status: 500 });
|
||||
} else {
|
||||
route.fulfill({ status: 200, body: JSON.stringify({}) });
|
||||
}
|
||||
});
|
||||
|
||||
await page.click('[aria-controls="panel-reachability"]');
|
||||
await page.click('#panel-reachability .retry-btn');
|
||||
|
||||
expect(requestCount).toBe(2);
|
||||
});
|
||||
|
||||
test('should not reload already loaded tab', async ({ page }) => {
|
||||
let requestCount = 0;
|
||||
await page.route('**/api/evidence/provenance/**', () => {
|
||||
requestCount++;
|
||||
});
|
||||
|
||||
// Tab is loaded on init
|
||||
await page.waitForTimeout(200);
|
||||
const initialCount = requestCount;
|
||||
|
||||
// Switch away and back
|
||||
await page.click('[aria-controls="panel-reachability"]');
|
||||
await page.click('[aria-controls="panel-provenance"]');
|
||||
|
||||
expect(requestCount).toBe(initialCount);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Provenance tab', () => {
|
||||
test('should display DSSE badge', async ({ page }) => {
|
||||
const badge = page.locator('.dsse-badge');
|
||||
await expect(badge).toBeVisible();
|
||||
});
|
||||
|
||||
test('should display attestation chain', async ({ page }) => {
|
||||
const chain = page.locator('.attestation-chain');
|
||||
await expect(chain).toBeVisible();
|
||||
});
|
||||
|
||||
test('should expand attestation chain node on click', async ({ page }) => {
|
||||
const node = page.locator('.chain-node').first();
|
||||
await node.click();
|
||||
|
||||
await expect(node).toHaveClass(/chain-node--expanded/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Copy JSON functionality', () => {
|
||||
test('should have copy JSON button in provenance tab', async ({ page }) => {
|
||||
const copyBtn = page.locator('.copy-json-btn');
|
||||
await expect(copyBtn).toBeVisible();
|
||||
});
|
||||
|
||||
test('should copy JSON to clipboard', async ({ page, context }) => {
|
||||
// Grant clipboard permissions
|
||||
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||
|
||||
await page.click('.copy-json-btn');
|
||||
|
||||
const clipboardText = await page.evaluate(() => navigator.clipboard.readText());
|
||||
expect(clipboardText).toBeTruthy();
|
||||
|
||||
// Should be valid JSON
|
||||
expect(() => JSON.parse(clipboardText)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Accessibility', () => {
|
||||
test('should have correct ARIA attributes on tabs', async ({ page }) => {
|
||||
const tablist = page.locator('[role="tablist"]');
|
||||
await expect(tablist).toBeVisible();
|
||||
|
||||
const tabs = page.locator('[role="tab"]');
|
||||
await expect(tabs).toHaveCount(5);
|
||||
|
||||
const panels = page.locator('[role="tabpanel"]');
|
||||
await expect(panels).toHaveCount(5);
|
||||
});
|
||||
|
||||
test('should set aria-selected correctly', async ({ page }) => {
|
||||
const activeTab = page.locator('[aria-selected="true"]');
|
||||
await expect(activeTab).toHaveCount(1);
|
||||
await expect(activeTab).toContainText('Provenance');
|
||||
|
||||
await page.click('[aria-controls="panel-diff"]');
|
||||
|
||||
await expect(page.locator('[aria-controls="panel-diff"]')).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(page.locator('[aria-controls="panel-provenance"]')).toHaveAttribute('aria-selected', 'false');
|
||||
});
|
||||
|
||||
test('should announce tab changes to screen readers', async ({ page }) => {
|
||||
const panel = page.locator('#panel-provenance');
|
||||
|
||||
await expect(panel).toHaveAttribute('aria-labelledby', 'tab-provenance');
|
||||
});
|
||||
|
||||
test('should have keyboard hint visible', async ({ page }) => {
|
||||
const hint = page.locator('.keyboard-hint');
|
||||
await expect(hint).toBeVisible();
|
||||
await expect(hint).toContainText('1');
|
||||
await expect(hint).toContainText('5');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Responsive behavior', () => {
|
||||
test('should scroll tabs on narrow viewport', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 320, height: 568 });
|
||||
|
||||
const tabNav = page.locator('.tab-nav');
|
||||
const isScrollable = await tabNav.evaluate((el) => el.scrollWidth > el.clientWidth);
|
||||
|
||||
expect(isScrollable).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user