feat(web): close sprint 006 onboarding ux
This commit is contained in:
221
src/Web/StellaOps.Web/e2e/onboarding-ux.e2e.spec.ts
Normal file
221
src/Web/StellaOps.Web/e2e/onboarding-ux.e2e.spec.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { test, expect } from './fixtures/auth.fixture';
|
||||
import type { Page, Route } from '@playwright/test';
|
||||
|
||||
function collectCriticalErrors(page: Page): string[] {
|
||||
const errors: string[] = [];
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') {
|
||||
errors.push(msg.text());
|
||||
}
|
||||
});
|
||||
page.on('pageerror', (error) => {
|
||||
errors.push(error.message);
|
||||
});
|
||||
return errors;
|
||||
}
|
||||
|
||||
function expectNoCriticalErrors(errors: string[]): void {
|
||||
const critical = errors.filter((error) => /NG0|TypeError|ReferenceError/.test(error));
|
||||
expect(critical, critical.join('\n')).toHaveLength(0);
|
||||
}
|
||||
|
||||
async function go(page: Page, path: string): Promise<void> {
|
||||
await page.goto(path, { waitUntil: 'networkidle', timeout: 60_000 });
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
}
|
||||
|
||||
async function stubIntegrationCounts(page: Page): Promise<void> {
|
||||
await page.route('**/v1/integrations**', async (route: Route) => {
|
||||
const requestUrl = new URL(route.request().url());
|
||||
const type = Number.parseInt(requestUrl.searchParams.get('type') ?? '0', 10);
|
||||
const counts: Record<number, number> = {
|
||||
1: 2,
|
||||
2: 1,
|
||||
3: 0,
|
||||
4: 1,
|
||||
5: 4,
|
||||
6: 3,
|
||||
};
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
items: [],
|
||||
totalCount: counts[type] ?? 0,
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
totalPages: 1,
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function openCommandPalette(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'k',
|
||||
ctrlKey: true,
|
||||
bubbles: true,
|
||||
}));
|
||||
});
|
||||
|
||||
await expect(page.locator('.cp__input')).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe('Onboarding UX sprint closure', () => {
|
||||
test('dashboard shows first-visit guidance, metric hints, status chip tooltips, and sidebar recommendation', async ({ authenticatedPage: page }) => {
|
||||
const errors = collectCriticalErrors(page);
|
||||
|
||||
await go(page, '/');
|
||||
await page.evaluate(() => {
|
||||
localStorage.removeItem('stellaops.helper.preferences');
|
||||
});
|
||||
await page.reload({ waitUntil: 'networkidle' });
|
||||
|
||||
const banner = page.locator('[aria-label="Dashboard welcome"]');
|
||||
await expect(banner).toBeVisible();
|
||||
await expect(banner).toContainText('Welcome to Stella Ops');
|
||||
await expect(banner).toContainText('Severity guide');
|
||||
await expect(banner).toContainText('Run diagnostics');
|
||||
await expect(page.getByRole('button', { name: /SBOM: show glossary definition/i })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /Feed: show glossary definition/i })).toBeVisible();
|
||||
|
||||
await expect(page.locator('app-live-event-stream-chip a')).toHaveAttribute('title', /Events:/);
|
||||
await expect(page.locator('app-policy-baseline-chip a')).toHaveAttribute('title', /Policy:/);
|
||||
await expect(page.locator('app-evidence-mode-chip a')).toHaveAttribute('title', /Evidence:/);
|
||||
await expect(page.locator('app-feed-snapshot-chip a')).toHaveAttribute('title', /Feed:/);
|
||||
await expect(page.locator('app-offline-status-chip a')).toHaveAttribute('title', /Offline:/);
|
||||
|
||||
const sidebar = page.locator('app-sidebar');
|
||||
await expect(sidebar).toContainText('Start here');
|
||||
await expect(sidebar).toContainText('Diagnostics');
|
||||
|
||||
await page.getByRole('button', { name: /dismiss dashboard welcome/i }).click();
|
||||
await page.reload({ waitUntil: 'networkidle' });
|
||||
await expect(banner).toBeHidden();
|
||||
|
||||
expectNoCriticalErrors(errors);
|
||||
});
|
||||
|
||||
test('page help panels explain routes, persist state, and lead into docs routes', async ({ authenticatedPage: page }) => {
|
||||
const errors = collectCriticalErrors(page);
|
||||
|
||||
await go(page, '/security/reachability');
|
||||
|
||||
const panel = page.locator('[data-page-help="reachability"]');
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(panel).toContainText('About this page');
|
||||
await expect(panel).toContainText('Reachability');
|
||||
await expect(panel).toContainText('Key concepts');
|
||||
await expect(panel).toContainText('Coverage is confidence');
|
||||
await expect(panel.locator('.page-help__body')).toBeVisible();
|
||||
|
||||
await panel.locator('.page-help__toggle').click();
|
||||
await expect(panel.locator('.page-help__body')).toHaveCount(0);
|
||||
|
||||
await page.reload({ waitUntil: 'networkidle' });
|
||||
await expect(panel.locator('.page-help__toggle')).toHaveAttribute('aria-expanded', 'false');
|
||||
|
||||
await panel.locator('.page-help__toggle').click();
|
||||
await expect(panel.locator('.page-help__body')).toBeVisible();
|
||||
|
||||
await panel.getByRole('link', { name: 'Operator guide' }).click();
|
||||
await expect(page).toHaveURL(/\/docs\/UI_GUIDE\.md$/);
|
||||
await expect(page.locator('.docs-viewer__header')).toContainText('Documentation');
|
||||
await expect(page.locator('.docs-viewer__path')).toContainText('UI_GUIDE.md');
|
||||
|
||||
const docsPanel = page.locator('[data-page-help="default"]');
|
||||
await expect(docsPanel).toBeVisible();
|
||||
await expect(docsPanel).toContainText('Documentation');
|
||||
await expect(docsPanel).toContainText('Operator guide');
|
||||
|
||||
expectNoCriticalErrors(errors);
|
||||
});
|
||||
|
||||
test('vex page exposes glossary-enhanced help content for the hardest concepts', async ({ authenticatedPage: page }) => {
|
||||
const errors = collectCriticalErrors(page);
|
||||
|
||||
await go(page, '/ops/policy/vex');
|
||||
|
||||
const panel = page.locator('[data-page-help="vex"]');
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(panel).toContainText('VEX and Exceptions');
|
||||
await expect(panel).toContainText('Quick mental model');
|
||||
|
||||
const glossaryButton = page.getByRole('button', { name: /VEX: show glossary definition/i }).first();
|
||||
await glossaryButton.hover();
|
||||
await glossaryButton.focus();
|
||||
const tooltip = page.locator('.glossary-tooltip');
|
||||
await expect(tooltip).toBeVisible();
|
||||
await expect(tooltip).toContainText(/VEX|vulnerability/i);
|
||||
|
||||
expectNoCriticalErrors(errors);
|
||||
});
|
||||
|
||||
test('decision capsules and findings explorer explain capsules and baselines inline', async ({ authenticatedPage: page }) => {
|
||||
const errors = collectCriticalErrors(page);
|
||||
|
||||
await go(page, '/evidence/capsules');
|
||||
|
||||
const capsulesPanel = page.locator('[data-page-help="decision-capsules"]');
|
||||
await expect(capsulesPanel).toBeVisible();
|
||||
await expect(capsulesPanel).toContainText('Decision Capsules');
|
||||
await expect(capsulesPanel).toContainText('What is inside a capsule');
|
||||
await expect(capsulesPanel).toContainText('Replay and verification');
|
||||
|
||||
await go(page, '/security/findings');
|
||||
|
||||
const findingsPanel = page.locator('[data-page-help="findings"]');
|
||||
await expect(findingsPanel).toBeVisible();
|
||||
await expect(findingsPanel).toContainText('Findings Explorer');
|
||||
await expect(findingsPanel).toContainText('Baselines reduce noise');
|
||||
await expect(findingsPanel).toContainText('Baseline workflow');
|
||||
|
||||
expectNoCriticalErrors(errors);
|
||||
});
|
||||
|
||||
test('integrations hub shows guided setup order with live done and pending state', async ({ authenticatedPage: page }) => {
|
||||
const errors = collectCriticalErrors(page);
|
||||
await stubIntegrationCounts(page);
|
||||
|
||||
await go(page, '/setup/integrations');
|
||||
|
||||
const setupOrder = page.getByRole('region', { name: 'Suggested Setup Order' });
|
||||
await expect(page.getByRole('heading', { name: 'Suggested Setup Order' })).toBeVisible();
|
||||
await expect(page.locator('.hub-summary')).toContainText('14');
|
||||
await expect(setupOrder.getByRole('link', { name: 'Source Control', exact: true })).toBeVisible();
|
||||
await expect(setupOrder).toContainText(/Fresh advisory and VEX sources are what make security posture/i);
|
||||
await expect(page.locator('.setup-step-card__status--done')).toHaveCount(4);
|
||||
await expect(page.locator('.setup-step-card__status--pending')).toHaveCount(1);
|
||||
await expect(page.locator('.activity')).toContainText('Use the activity timeline for connector event history');
|
||||
|
||||
expectNoCriticalErrors(errors);
|
||||
});
|
||||
|
||||
test('command palette serves glossary help and guided workflows inline', async ({ authenticatedPage: page }) => {
|
||||
const errors = collectCriticalErrors(page);
|
||||
|
||||
await go(page, '/');
|
||||
await openCommandPalette(page);
|
||||
|
||||
const input = page.locator('.cp__input');
|
||||
const dialog = page.locator('.cp__dialog');
|
||||
|
||||
await input.fill('help: sbom');
|
||||
await expect(dialog).toContainText('Help & Guides');
|
||||
await expect(dialog).toContainText('Help: SBOM');
|
||||
await expect(dialog).toContainText('A list of all the parts');
|
||||
|
||||
await input.fill('guide: scan image');
|
||||
await expect(dialog).toContainText('Guide: Scan Image');
|
||||
await expect(dialog).toContainText('Step 1');
|
||||
await expect(dialog).toContainText('Step 2');
|
||||
|
||||
await input.fill('guide: first setup');
|
||||
await page.getByRole('button', { name: /Guide: First Setup/i }).click();
|
||||
await expect(page).toHaveURL(/\/setup-wizard\/wizard$/);
|
||||
|
||||
expectNoCriticalErrors(errors);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user