save development progress
This commit is contained in:
388
src/Web/StellaOps.Web/tests/e2e/quiet-triage-a11y.spec.ts
Normal file
388
src/Web/StellaOps.Web/tests/e2e/quiet-triage-a11y.spec.ts
Normal file
@@ -0,0 +1,388 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// quiet-triage-a11y.spec.ts
|
||||
// Sprint: SPRINT_9200_0001_0004_FE_quiet_triage_ui
|
||||
// Description: Accessibility tests for the Quiet-by-Design triage workflow.
|
||||
// Tests WCAG 2.0 AA compliance for gated buckets, VEX trust, replay command.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { policyAuthorSession } from '../../src/app/testing';
|
||||
|
||||
const shouldFail = process.env.FAIL_ON_A11Y === '1';
|
||||
const reportDir = path.join(process.cwd(), 'test-results');
|
||||
|
||||
const mockConfig = {
|
||||
authority: {
|
||||
issuer: 'https://authority.local',
|
||||
clientId: 'stellaops-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 authority:tenants.read advisory:read vex:read exceptions:read exceptions:approve aoc:verify findings:read orch:read vuln:view vuln:investigate vuln:operate vuln:audit',
|
||||
audience: 'https://scanner.local',
|
||||
dpopAlgorithms: ['ES256'],
|
||||
refreshLeewaySeconds: 60,
|
||||
},
|
||||
apiBaseUrls: {
|
||||
authority: 'https://authority.local',
|
||||
scanner: 'https://scanner.local',
|
||||
policy: 'https://scanner.local',
|
||||
concelier: 'https://concelier.local',
|
||||
attestor: 'https://attestor.local',
|
||||
},
|
||||
quickstartMode: true,
|
||||
};
|
||||
|
||||
// Mock responses for quiet triage components
|
||||
const mockGatedBuckets = {
|
||||
unreachableCount: 42,
|
||||
policyDismissedCount: 15,
|
||||
backportedCount: 8,
|
||||
vexNotAffectedCount: 23,
|
||||
supersededCount: 3,
|
||||
userMutedCount: 5,
|
||||
totalHiddenCount: 96,
|
||||
actionableCount: 12,
|
||||
};
|
||||
|
||||
const mockVexTrust = {
|
||||
status: 'not_affected',
|
||||
justification: 'vulnerable_code_not_in_execute_path',
|
||||
issuedBy: 'vendor.example',
|
||||
issuedAt: '2025-12-15T10:00:00Z',
|
||||
trustScore: 0.85,
|
||||
policyTrustThreshold: 0.80,
|
||||
meetsPolicyThreshold: true,
|
||||
trustBreakdown: {
|
||||
authority: 0.90,
|
||||
accuracy: 0.85,
|
||||
timeliness: 0.80,
|
||||
verification: 0.85,
|
||||
},
|
||||
};
|
||||
|
||||
const mockReplayCommand = {
|
||||
findingId: 'f-abc123',
|
||||
scanId: 'scan-xyz789',
|
||||
fullCommand: {
|
||||
type: 'full',
|
||||
command: 'stella scan replay --artifact sha256:a1b2c3... --manifest sha256:def456...',
|
||||
shell: 'bash',
|
||||
requiresNetwork: false,
|
||||
},
|
||||
shortCommand: {
|
||||
type: 'short',
|
||||
command: 'stella replay snapshot --verdict V-12345',
|
||||
shell: 'bash',
|
||||
requiresNetwork: false,
|
||||
},
|
||||
generatedAt: '2025-12-15T10:30:00Z',
|
||||
expectedVerdictHash: 'sha256:verdict123...',
|
||||
};
|
||||
|
||||
async function writeReport(filename: string, data: unknown) {
|
||||
fs.mkdirSync(reportDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(reportDir, filename), JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
async function runA11y(page: Page, selector?: string) {
|
||||
let builder = new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']);
|
||||
if (selector) {
|
||||
builder = builder.include(selector);
|
||||
}
|
||||
const results = await builder.analyze();
|
||||
const violations = [...results.violations].sort((a, b) => a.id.localeCompare(b.id));
|
||||
return violations;
|
||||
}
|
||||
|
||||
test.describe('quiet-triage-a11y', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript((session) => {
|
||||
try {
|
||||
window.sessionStorage.clear();
|
||||
} catch {
|
||||
// ignore storage errors in restricted contexts
|
||||
}
|
||||
(window as any).__stellaopsTestSession = session;
|
||||
}, policyAuthorSession);
|
||||
|
||||
await page.route('**/config.json', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockConfig),
|
||||
})
|
||||
);
|
||||
|
||||
await page.route('https://authority.local/**', (route) => route.abort());
|
||||
|
||||
// Mock gating API endpoints
|
||||
await page.route('**/api/v1/triage/findings/*/gated-buckets', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockGatedBuckets),
|
||||
})
|
||||
);
|
||||
|
||||
await page.route('**/api/v1/triage/findings/*/vex-trust', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockVexTrust),
|
||||
})
|
||||
);
|
||||
|
||||
await page.route('**/api/v1/triage/findings/*/replay-command', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockReplayCommand),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('gated buckets component: WCAG 2.0 AA compliance', async ({ page }, testInfo) => {
|
||||
await page.goto('/triage/artifacts/asset-web-prod');
|
||||
await expect(page.getByRole('heading', { name: 'Artifact triage' })).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const violations = await runA11y(page, '.gated-buckets');
|
||||
await writeReport('a11y-quiet_triage_gated_buckets.json', { url: page.url(), violations });
|
||||
|
||||
if (shouldFail) {
|
||||
expect(violations).toEqual([]);
|
||||
}
|
||||
|
||||
testInfo.annotations.push({
|
||||
type: 'a11y',
|
||||
description: `${violations.length} violations (gated buckets component)`,
|
||||
});
|
||||
|
||||
// Specific checks for gated buckets
|
||||
const gatedBuckets = page.locator('.gated-buckets');
|
||||
|
||||
// Check role and aria-label
|
||||
await expect(gatedBuckets).toHaveAttribute('role', 'group');
|
||||
await expect(gatedBuckets).toHaveAttribute('aria-label', 'Gated findings summary');
|
||||
|
||||
// Check bucket chips have aria-expanded
|
||||
const chips = gatedBuckets.locator('.bucket-chip');
|
||||
const chipCount = await chips.count();
|
||||
for (let i = 0; i < chipCount; i++) {
|
||||
await expect(chips.nth(i)).toHaveAttribute('aria-expanded');
|
||||
await expect(chips.nth(i)).toHaveAttribute('aria-label');
|
||||
}
|
||||
|
||||
// Check show all toggle has aria-pressed
|
||||
const showAllToggle = gatedBuckets.locator('.show-all-toggle');
|
||||
await expect(showAllToggle).toHaveAttribute('aria-pressed');
|
||||
});
|
||||
|
||||
test('VEX trust display: WCAG 2.0 AA compliance', async ({ page }, testInfo) => {
|
||||
await page.goto('/triage/artifacts/asset-web-prod/findings/f-abc123');
|
||||
await expect(page.getByRole('heading', { name: 'Finding detail' })).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const violations = await runA11y(page, '.vex-trust-display');
|
||||
await writeReport('a11y-quiet_triage_vex_trust.json', { url: page.url(), violations });
|
||||
|
||||
if (shouldFail) {
|
||||
expect(violations).toEqual([]);
|
||||
}
|
||||
|
||||
testInfo.annotations.push({
|
||||
type: 'a11y',
|
||||
description: `${violations.length} violations (VEX trust display)`,
|
||||
});
|
||||
|
||||
// Check color contrast for trust score
|
||||
const trustScore = page.locator('.vex-trust-display .trust-score');
|
||||
await expect(trustScore).toBeVisible();
|
||||
|
||||
// Check expand button has proper labeling
|
||||
const expandBtn = page.locator('.vex-trust-display').getByRole('button');
|
||||
await expect(expandBtn).toHaveAttribute('aria-expanded');
|
||||
});
|
||||
|
||||
test('replay command component: WCAG 2.0 AA compliance', async ({ page }, testInfo) => {
|
||||
await page.goto('/triage/artifacts/asset-web-prod/findings/f-abc123');
|
||||
await expect(page.getByRole('heading', { name: 'Finding detail' })).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const violations = await runA11y(page, '.replay-command');
|
||||
await writeReport('a11y-quiet_triage_replay_command.json', { url: page.url(), violations });
|
||||
|
||||
if (shouldFail) {
|
||||
expect(violations).toEqual([]);
|
||||
}
|
||||
|
||||
testInfo.annotations.push({
|
||||
type: 'a11y',
|
||||
description: `${violations.length} violations (replay command)`,
|
||||
});
|
||||
|
||||
// Check tabs have proper ARIA roles
|
||||
const tabs = page.locator('.replay-command .command-tabs');
|
||||
await expect(tabs).toHaveAttribute('role', 'tablist');
|
||||
|
||||
const tabButtons = tabs.locator('.tab');
|
||||
const tabCount = await tabButtons.count();
|
||||
for (let i = 0; i < tabCount; i++) {
|
||||
await expect(tabButtons.nth(i)).toHaveAttribute('role', 'tab');
|
||||
await expect(tabButtons.nth(i)).toHaveAttribute('aria-selected');
|
||||
}
|
||||
});
|
||||
|
||||
test('gating explainer modal: WCAG 2.0 AA compliance', async ({ page }, testInfo) => {
|
||||
await page.goto('/triage/artifacts/asset-web-prod/findings/f-abc123');
|
||||
await expect(page.getByRole('heading', { name: 'Finding detail' })).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Open the modal
|
||||
const whyHiddenLink = page.getByRole('button', { name: /Why hidden/ });
|
||||
await whyHiddenLink.click();
|
||||
|
||||
const modal = page.getByRole('dialog', { name: 'Gating explanation' });
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
const violations = await runA11y(page, '[role="dialog"]');
|
||||
await writeReport('a11y-quiet_triage_gating_modal.json', { url: page.url(), violations });
|
||||
|
||||
if (shouldFail) {
|
||||
expect(violations).toEqual([]);
|
||||
}
|
||||
|
||||
testInfo.annotations.push({
|
||||
type: 'a11y',
|
||||
description: `${violations.length} violations (gating explainer modal)`,
|
||||
});
|
||||
|
||||
// Check modal has proper ARIA attributes
|
||||
await expect(modal).toHaveAttribute('role', 'dialog');
|
||||
await expect(modal).toHaveAttribute('aria-modal', 'true');
|
||||
|
||||
// Check close button is accessible
|
||||
const closeBtn = modal.getByRole('button', { name: 'Close' });
|
||||
await expect(closeBtn).toBeVisible();
|
||||
await expect(closeBtn).toBeFocused();
|
||||
});
|
||||
|
||||
test('keyboard navigation: focus management', async ({ page }, testInfo) => {
|
||||
await page.goto('/triage/artifacts/asset-web-prod');
|
||||
await expect(page.getByRole('heading', { name: 'Artifact triage' })).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Tab through gated buckets
|
||||
const gatedBuckets = page.locator('.gated-buckets');
|
||||
const chips = gatedBuckets.locator('.bucket-chip');
|
||||
|
||||
// Focus first chip
|
||||
await page.keyboard.press('Tab');
|
||||
await page.keyboard.press('Tab');
|
||||
await page.keyboard.press('Tab');
|
||||
const firstChip = chips.first();
|
||||
await expect(firstChip).toBeFocused();
|
||||
|
||||
// Check focus indicator is visible (outline)
|
||||
const focusStyles = await firstChip.evaluate((el) => {
|
||||
const styles = window.getComputedStyle(el);
|
||||
return {
|
||||
outline: styles.outline,
|
||||
outlineOffset: styles.outlineOffset,
|
||||
};
|
||||
});
|
||||
|
||||
// Focus should have visible outline
|
||||
expect(focusStyles.outline).not.toBe('none');
|
||||
|
||||
testInfo.annotations.push({
|
||||
type: 'a11y',
|
||||
description: 'Focus indicators verified',
|
||||
});
|
||||
});
|
||||
|
||||
test('keyboard navigation: enter activates bucket chip', async ({ page }) => {
|
||||
await page.goto('/triage/artifacts/asset-web-prod');
|
||||
await expect(page.getByRole('heading', { name: 'Artifact triage' })).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const unreachableChip = page.getByRole('button', { name: /Show 42 unreachable findings/ });
|
||||
|
||||
// Focus the chip
|
||||
await unreachableChip.focus();
|
||||
await expect(unreachableChip).toBeFocused();
|
||||
|
||||
// Activate with Enter
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(unreachableChip).toHaveAttribute('aria-expanded', 'true');
|
||||
|
||||
// Deactivate with Enter
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(unreachableChip).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
test('keyboard navigation: space activates bucket chip', async ({ page }) => {
|
||||
await page.goto('/triage/artifacts/asset-web-prod');
|
||||
await expect(page.getByRole('heading', { name: 'Artifact triage' })).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const policyChip = page.getByRole('button', { name: /policy-dismissed/ });
|
||||
|
||||
// Focus the chip
|
||||
await policyChip.focus();
|
||||
await expect(policyChip).toBeFocused();
|
||||
|
||||
// Activate with Space
|
||||
await page.keyboard.press('Space');
|
||||
await expect(policyChip).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
test('screen reader: live region announces changes', async ({ page }, testInfo) => {
|
||||
await page.goto('/triage/artifacts/asset-web-prod');
|
||||
await expect(page.getByRole('heading', { name: 'Artifact triage' })).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Check for aria-live region for dynamic updates
|
||||
const liveRegion = page.locator('[aria-live]');
|
||||
const liveRegionCount = await liveRegion.count();
|
||||
|
||||
testInfo.annotations.push({
|
||||
type: 'a11y',
|
||||
description: `${liveRegionCount} aria-live regions found`,
|
||||
});
|
||||
|
||||
// At minimum, status updates should have aria-live
|
||||
expect(liveRegionCount).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
test('color contrast: bucket chips meet WCAG AA', async ({ page }) => {
|
||||
await page.goto('/triage/artifacts/asset-web-prod');
|
||||
await expect(page.getByRole('heading', { name: 'Artifact triage' })).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const violations = await runA11y(page, '.bucket-chips');
|
||||
|
||||
// Filter for color-contrast violations
|
||||
const contrastViolations = violations.filter((v) => v.id === 'color-contrast');
|
||||
|
||||
// No contrast violations should exist in bucket chips
|
||||
expect(contrastViolations.length).toBe(0);
|
||||
});
|
||||
|
||||
test('reduced motion: respects prefers-reduced-motion', async ({ page }) => {
|
||||
// Emulate reduced motion preference
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
|
||||
await page.goto('/triage/artifacts/asset-web-prod');
|
||||
await expect(page.getByRole('heading', { name: 'Artifact triage' })).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const chip = page.locator('.bucket-chip').first();
|
||||
|
||||
// Check transitions are instant or disabled
|
||||
const transitionDuration = await chip.evaluate((el) => {
|
||||
return window.getComputedStyle(el).transitionDuration;
|
||||
});
|
||||
|
||||
// With reduced motion, transitions should be instant (0s) or very fast
|
||||
const duration = parseFloat(transitionDuration);
|
||||
expect(duration).toBeLessThanOrEqual(0.01);
|
||||
});
|
||||
});
|
||||
335
src/Web/StellaOps.Web/tests/e2e/quiet-triage.spec.ts
Normal file
335
src/Web/StellaOps.Web/tests/e2e/quiet-triage.spec.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// quiet-triage.spec.ts
|
||||
// Sprint: SPRINT_9200_0001_0004_FE_quiet_triage_ui
|
||||
// Description: E2E tests for the Quiet-by-Design triage workflow.
|
||||
// Tests gated bucket chips, VEX trust display, replay command, and gating explainer.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { policyAuthorSession } from '../../src/app/testing';
|
||||
|
||||
const mockConfig = {
|
||||
authority: {
|
||||
issuer: 'https://authority.local',
|
||||
clientId: 'stellaops-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 authority:tenants.read advisory:read vex:read exceptions:read exceptions:approve aoc:verify findings:read orch:read vuln:view vuln:investigate vuln:operate vuln:audit',
|
||||
audience: 'https://scanner.local',
|
||||
dpopAlgorithms: ['ES256'],
|
||||
refreshLeewaySeconds: 60,
|
||||
},
|
||||
apiBaseUrls: {
|
||||
authority: 'https://authority.local',
|
||||
scanner: 'https://scanner.local',
|
||||
policy: 'https://scanner.local',
|
||||
concelier: 'https://concelier.local',
|
||||
attestor: 'https://attestor.local',
|
||||
},
|
||||
quickstartMode: true,
|
||||
};
|
||||
|
||||
// Mock gated buckets response
|
||||
const mockGatedBuckets = {
|
||||
unreachableCount: 42,
|
||||
policyDismissedCount: 15,
|
||||
backportedCount: 8,
|
||||
vexNotAffectedCount: 23,
|
||||
supersededCount: 3,
|
||||
userMutedCount: 5,
|
||||
totalHiddenCount: 96,
|
||||
actionableCount: 12,
|
||||
};
|
||||
|
||||
// Mock VEX trust response
|
||||
const mockVexTrust = {
|
||||
status: 'not_affected',
|
||||
justification: 'vulnerable_code_not_in_execute_path',
|
||||
issuedBy: 'vendor.example',
|
||||
issuedAt: '2025-12-15T10:00:00Z',
|
||||
trustScore: 0.85,
|
||||
policyTrustThreshold: 0.80,
|
||||
meetsPolicyThreshold: true,
|
||||
trustBreakdown: {
|
||||
authority: 0.90,
|
||||
accuracy: 0.85,
|
||||
timeliness: 0.80,
|
||||
verification: 0.85,
|
||||
},
|
||||
};
|
||||
|
||||
// Mock replay command response
|
||||
const mockReplayCommand = {
|
||||
findingId: 'f-abc123',
|
||||
scanId: 'scan-xyz789',
|
||||
fullCommand: {
|
||||
type: 'full',
|
||||
command: 'stella scan replay --artifact sha256:a1b2c3... --manifest sha256:def456...',
|
||||
shell: 'bash',
|
||||
requiresNetwork: false,
|
||||
},
|
||||
shortCommand: {
|
||||
type: 'short',
|
||||
command: 'stella replay snapshot --verdict V-12345',
|
||||
shell: 'bash',
|
||||
requiresNetwork: false,
|
||||
},
|
||||
generatedAt: '2025-12-15T10:30:00Z',
|
||||
expectedVerdictHash: 'sha256:verdict123...',
|
||||
};
|
||||
|
||||
test.describe('quiet-triage', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript((session) => {
|
||||
try {
|
||||
window.sessionStorage.clear();
|
||||
} catch {
|
||||
// ignore storage errors in restricted contexts
|
||||
}
|
||||
(window as any).__stellaopsTestSession = session;
|
||||
}, policyAuthorSession);
|
||||
|
||||
await page.route('**/config.json', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockConfig),
|
||||
})
|
||||
);
|
||||
|
||||
await page.route('https://authority.local/**', (route) => route.abort());
|
||||
|
||||
// Mock gating API endpoints
|
||||
await page.route('**/api/v1/triage/findings/*/gated-buckets', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockGatedBuckets),
|
||||
})
|
||||
);
|
||||
|
||||
await page.route('**/api/v1/triage/findings/*/vex-trust', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockVexTrust),
|
||||
})
|
||||
);
|
||||
|
||||
await page.route('**/api/v1/triage/findings/*/replay-command', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockReplayCommand),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('gated buckets: displays actionable count and hidden summary', async ({ page }) => {
|
||||
await page.goto('/triage/artifacts/asset-web-prod');
|
||||
await expect(page.getByRole('heading', { name: 'Artifact triage' })).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Check gated buckets component
|
||||
const gatedBuckets = page.locator('[role="group"][aria-label="Gated findings summary"]');
|
||||
await expect(gatedBuckets).toBeVisible();
|
||||
|
||||
// Check actionable count
|
||||
await expect(gatedBuckets.locator('.actionable-count')).toContainText('12');
|
||||
await expect(gatedBuckets.locator('.hidden-hint')).toContainText('96 hidden');
|
||||
});
|
||||
|
||||
test('gated buckets: bucket chips show correct counts', async ({ page }) => {
|
||||
await page.goto('/triage/artifacts/asset-web-prod');
|
||||
await expect(page.getByRole('heading', { name: 'Artifact triage' })).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Check unreachable chip
|
||||
const unreachableChip = page.getByRole('button', { name: /Show 42 unreachable findings/ });
|
||||
await expect(unreachableChip).toBeVisible();
|
||||
await expect(unreachableChip).toContainText('+42');
|
||||
await expect(unreachableChip).toContainText('unreachable');
|
||||
|
||||
// Check VEX chip
|
||||
const vexChip = page.getByRole('button', { name: /Show 23 VEX not-affected findings/ });
|
||||
await expect(vexChip).toBeVisible();
|
||||
await expect(vexChip).toContainText('+23');
|
||||
});
|
||||
|
||||
test('gated buckets: clicking chip expands bucket', async ({ page }) => {
|
||||
await page.goto('/triage/artifacts/asset-web-prod');
|
||||
await expect(page.getByRole('heading', { name: 'Artifact triage' })).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const unreachableChip = page.getByRole('button', { name: /Show 42 unreachable findings/ });
|
||||
|
||||
// Click to expand
|
||||
await unreachableChip.click();
|
||||
await expect(unreachableChip).toHaveAttribute('aria-expanded', 'true');
|
||||
await expect(unreachableChip).toHaveClass(/expanded/);
|
||||
|
||||
// Click again to collapse
|
||||
await unreachableChip.click();
|
||||
await expect(unreachableChip).toHaveAttribute('aria-expanded', 'false');
|
||||
await expect(unreachableChip).not.toHaveClass(/expanded/);
|
||||
});
|
||||
|
||||
test('gated buckets: show all toggle reveals hidden findings', async ({ page }) => {
|
||||
await page.goto('/triage/artifacts/asset-web-prod');
|
||||
await expect(page.getByRole('heading', { name: 'Artifact triage' })).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const showAllToggle = page.getByRole('button', { name: 'Show all' });
|
||||
await expect(showAllToggle).toBeVisible();
|
||||
await expect(showAllToggle).toHaveAttribute('aria-pressed', 'false');
|
||||
|
||||
// Click to show all
|
||||
await showAllToggle.click();
|
||||
await expect(showAllToggle).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(showAllToggle).toContainText('Hide gated');
|
||||
|
||||
// Click to hide again
|
||||
await showAllToggle.click();
|
||||
await expect(showAllToggle).toHaveAttribute('aria-pressed', 'false');
|
||||
await expect(showAllToggle).toContainText('Show all');
|
||||
});
|
||||
|
||||
test('VEX trust display: shows trust score and threshold', async ({ page }) => {
|
||||
await page.goto('/triage/artifacts/asset-web-prod/findings/f-abc123');
|
||||
await expect(page.getByRole('heading', { name: 'Finding detail' })).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Check VEX trust display
|
||||
const vexTrust = page.locator('.vex-trust-display');
|
||||
await expect(vexTrust).toBeVisible();
|
||||
|
||||
// Check trust score
|
||||
await expect(vexTrust.locator('.trust-score')).toContainText('0.85');
|
||||
await expect(vexTrust.locator('.threshold')).toContainText('0.80');
|
||||
await expect(vexTrust.locator('.meets-threshold')).toBeVisible();
|
||||
});
|
||||
|
||||
test('VEX trust display: shows trust breakdown on expand', async ({ page }) => {
|
||||
await page.goto('/triage/artifacts/asset-web-prod/findings/f-abc123');
|
||||
await expect(page.getByRole('heading', { name: 'Finding detail' })).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const expandBtn = page.getByRole('button', { name: 'Show trust breakdown' });
|
||||
await expandBtn.click();
|
||||
|
||||
const breakdown = page.locator('.trust-breakdown');
|
||||
await expect(breakdown).toBeVisible();
|
||||
await expect(breakdown).toContainText('Authority');
|
||||
await expect(breakdown).toContainText('0.90');
|
||||
await expect(breakdown).toContainText('Accuracy');
|
||||
await expect(breakdown).toContainText('0.85');
|
||||
});
|
||||
|
||||
test('replay command: displays command and copy button', async ({ page }) => {
|
||||
await page.goto('/triage/artifacts/asset-web-prod/findings/f-abc123');
|
||||
await expect(page.getByRole('heading', { name: 'Finding detail' })).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const replaySection = page.locator('.replay-command');
|
||||
await expect(replaySection).toBeVisible();
|
||||
|
||||
// Check command text
|
||||
await expect(replaySection.locator('.command-text')).toContainText('stella scan replay');
|
||||
|
||||
// Check copy button
|
||||
const copyBtn = replaySection.getByRole('button', { name: /Copy/ });
|
||||
await expect(copyBtn).toBeVisible();
|
||||
await expect(copyBtn).toBeEnabled();
|
||||
});
|
||||
|
||||
test('replay command: tab switching between full and short', async ({ page }) => {
|
||||
await page.goto('/triage/artifacts/asset-web-prod/findings/f-abc123');
|
||||
await expect(page.getByRole('heading', { name: 'Finding detail' })).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const replaySection = page.locator('.replay-command');
|
||||
|
||||
// Full tab active by default
|
||||
const fullTab = replaySection.getByRole('tab', { name: 'Full' });
|
||||
await expect(fullTab).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(replaySection.locator('.command-text')).toContainText('--artifact sha256');
|
||||
|
||||
// Switch to short tab
|
||||
const shortTab = replaySection.getByRole('tab', { name: 'Short' });
|
||||
await shortTab.click();
|
||||
await expect(shortTab).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(replaySection.locator('.command-text')).toContainText('--verdict V-12345');
|
||||
});
|
||||
|
||||
test('replay command: copy to clipboard', async ({ page, context }) => {
|
||||
// Grant clipboard permissions
|
||||
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||
|
||||
await page.goto('/triage/artifacts/asset-web-prod/findings/f-abc123');
|
||||
await expect(page.getByRole('heading', { name: 'Finding detail' })).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const replaySection = page.locator('.replay-command');
|
||||
const copyBtn = replaySection.getByRole('button', { name: /Copy/ });
|
||||
|
||||
await copyBtn.click();
|
||||
|
||||
// Button should show copied state
|
||||
await expect(copyBtn).toContainText('Copied!');
|
||||
|
||||
// Verify clipboard content
|
||||
const clipboardContent = await page.evaluate(() => navigator.clipboard.readText());
|
||||
expect(clipboardContent).toContain('stella scan replay');
|
||||
});
|
||||
|
||||
test('gating explainer: opens modal with explanation', async ({ page }) => {
|
||||
await page.goto('/triage/artifacts/asset-web-prod/findings/f-abc123');
|
||||
await expect(page.getByRole('heading', { name: 'Finding detail' })).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Click "Why hidden?" link
|
||||
const whyHiddenLink = page.getByRole('button', { name: /Why hidden/ });
|
||||
await whyHiddenLink.click();
|
||||
|
||||
// Modal should open
|
||||
const modal = page.getByRole('dialog', { name: 'Gating explanation' });
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
// Check content
|
||||
await expect(modal).toContainText('This finding is hidden because');
|
||||
await expect(modal.getByRole('button', { name: 'Close' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('keyboard navigation: bucket chips focusable', async ({ page }) => {
|
||||
await page.goto('/triage/artifacts/asset-web-prod');
|
||||
await expect(page.getByRole('heading', { name: 'Artifact triage' })).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Tab to first bucket chip
|
||||
await page.keyboard.press('Tab');
|
||||
await page.keyboard.press('Tab');
|
||||
await page.keyboard.press('Tab');
|
||||
|
||||
const unreachableChip = page.getByRole('button', { name: /Show 42 unreachable findings/ });
|
||||
await expect(unreachableChip).toBeFocused();
|
||||
|
||||
// Enter to activate
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(unreachableChip).toHaveAttribute('aria-expanded', 'true');
|
||||
|
||||
// Tab to next chip
|
||||
await page.keyboard.press('Tab');
|
||||
const policyChip = page.getByRole('button', { name: /policy-dismissed/ });
|
||||
await expect(policyChip).toBeFocused();
|
||||
});
|
||||
|
||||
test('screen reader: proper ARIA labels on components', async ({ page }) => {
|
||||
await page.goto('/triage/artifacts/asset-web-prod');
|
||||
await expect(page.getByRole('heading', { name: 'Artifact triage' })).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Check gated buckets group label
|
||||
const gatedBuckets = page.locator('[role="group"][aria-label="Gated findings summary"]');
|
||||
await expect(gatedBuckets).toHaveAttribute('aria-label', 'Gated findings summary');
|
||||
|
||||
// Check bucket chip labels
|
||||
const unreachableChip = page.getByRole('button', { name: /Show 42 unreachable findings/ });
|
||||
await expect(unreachableChip).toHaveAttribute('aria-label', /Show 42 unreachable findings/);
|
||||
|
||||
// Check show all toggle
|
||||
const showAllToggle = page.getByRole('button', { name: 'Show all' });
|
||||
await expect(showAllToggle).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user