import { expect, test, type Page } from '@playwright/test'; import fs from 'node:fs'; import path from 'node:path'; import { policyAuthorSession } from '../../src/app/testing'; const shellSession = { ...policyAuthorSession, scopes: [ ...new Set([ ...policyAuthorSession.scopes, 'ui.read', 'admin', 'orch:read', 'orch:operate', 'orch:quota', 'findings:read', 'vuln:view', 'vuln:investigate', 'vuln:operate', 'vuln:audit', 'authority:tenants.read', 'advisory:read', 'vex:read', 'exceptions:read', 'exceptions:approve', 'aoc:verify', ]), ], }; const mockConfig = { authority: { issuer: 'http://127.0.0.1:4400/authority', clientId: 'stella-ops-ui', authorizeEndpoint: 'http://127.0.0.1:4400/authority/connect/authorize', tokenEndpoint: 'http://127.0.0.1:4400/authority/connect/token', logoutEndpoint: 'http://127.0.0.1:4400/authority/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: 'http://127.0.0.1:4400/gateway', dpopAlgorithms: ['ES256'], refreshLeewaySeconds: 60, }, apiBaseUrls: { authority: '/authority', scanner: '/scanner', policy: '/policy', concelier: '/concelier', attestor: '/attestor', gateway: '/gateway', }, quickstartMode: true, setup: 'complete', }; const oidcConfig = { issuer: mockConfig.authority.issuer, authorization_endpoint: mockConfig.authority.authorizeEndpoint, token_endpoint: mockConfig.authority.tokenEndpoint, jwks_uri: 'http://127.0.0.1:4400/authority/.well-known/jwks.json', response_types_supported: ['code'], subject_types_supported: ['public'], id_token_signing_alg_values_supported: ['RS256'], }; interface PackExpectation { pack: string; path: string; text: RegExp; canonical?: RegExp; } const conformanceFilter = process.env.PACK_CONFORMANCE_FILTER?.trim(); const screenshotDir = process.env.PACK_SCREENSHOT_DIR?.trim(); const screenshotAbsDir = screenshotDir ? path.resolve(process.cwd(), screenshotDir) : null; const endpointMatrixFile = process.env.PACK_ENDPOINT_MATRIX_FILE?.trim(); const endpointMatrixAbsFile = endpointMatrixFile ? path.resolve(process.cwd(), endpointMatrixFile) : null; const EXPECTATIONS: PackExpectation[] = [ { pack: '22', path: '/mission-control/board', text: /Dashboard|Mission board/i, canonical: /\/mission-control\/board$/ }, { pack: '22', path: '/releases/overview', text: /Release Ops Overview|Release/i, canonical: /\/releases\/overview$/ }, { pack: '22', path: '/releases/versions', text: /Release Versions|Release list|Version/i, canonical: /\/releases\/versions$/ }, { pack: '22', path: '/releases/versions/new', text: /Create Release|Version/i, canonical: /\/releases\/versions\/new$/ }, { pack: '22', path: '/releases/runs', text: /Release Runs|Timeline|Run/i, canonical: /\/releases\/runs$/ }, { pack: '22', path: '/releases/approvals', text: /Approvals?/i, canonical: /\/releases\/approvals$/ }, { pack: '22', path: '/releases/hotfixes', text: /Hotfix/i, canonical: /\/releases\/hotfixes$/ }, { pack: '22', path: '/releases/promotion-queue', text: /Promotion Queue|Promotion/i, canonical: /\/releases\/promotion-queue$/ }, { pack: '22', path: '/releases/environments', text: /Environment|Region/i, canonical: /\/releases\/environments$/ }, { pack: '22', path: '/releases/deployments', text: /Deployment/i, canonical: /\/releases\/deployments$/ }, { pack: '22', path: '/security/posture', text: /Security|Risk|Posture/i, canonical: /\/security\/posture$/ }, { pack: '22', path: '/security/triage', text: /Findings|Triage/i, canonical: /\/security\/triage$/ }, { pack: '22', path: '/security/advisories-vex', text: /Advisories|VEX|Disposition/i, canonical: /\/security\/advisories-vex$/ }, { pack: '22', path: '/security/supply-chain-data', text: /SBOM|Supply-Chain|Component/i, canonical: /\/security\/supply-chain-data$/ }, { pack: '22', path: '/security/reachability', text: /Reachability/i, canonical: /\/security\/reachability$/ }, { pack: '22', path: '/security/reports', text: /Reports?/i, canonical: /\/security\/reports$/ }, { pack: '22', path: '/evidence/overview', text: /Evidence|Capsule|Verify/i, canonical: /\/evidence\/overview$/ }, { pack: '22', path: '/evidence/capsules', text: /Decision Capsule|Capsule|Evidence/i, canonical: /\/evidence\/capsules$/ }, { pack: '22', path: '/evidence/verify-replay', text: /Verify|Replay|Proof/i, canonical: /\/evidence\/verify-replay$/ }, { pack: '22', path: '/evidence/exports', text: /Export/i, canonical: /\/evidence\/exports/ }, { pack: '22', path: '/evidence/audit-log', text: /Audit Log|Events?/i, canonical: /\/evidence\/audit-log/ }, { pack: '22', path: '/ops', text: /Ops|Overview/i, canonical: /\/ops$/ }, { pack: '22', path: '/ops/operations', text: /Operations|Platform Ops/i, canonical: /\/ops\/operations$/ }, { pack: '22', path: '/ops/operations/data-integrity', text: /Data Integrity|Trust/i, canonical: /\/ops\/operations\/data-integrity/ }, { pack: '22', path: '/ops/operations/jobengine', text: /JobEngine/i, canonical: /\/ops\/operations\/jobengine$/ }, { pack: '22', path: '/ops/integrations', text: /Integration Hub|Integrations/i, canonical: /\/ops\/integrations$/ }, { pack: '22', path: '/ops/integrations/advisory-vex-sources', text: /Advisory|VEX|Source|FeedMirror|Integrations/i, canonical: /\/ops\/integrations\/advisory-vex-sources$/ }, { pack: '22', path: '/ops/policy', text: /Policy|Governance/i, canonical: /\/ops\/policy/ }, { pack: '22', path: '/ops/platform-setup', text: /Setup|Release Templates|Promotion Paths/i, canonical: /\/ops\/platform-setup/ }, { pack: '22', path: '/setup', text: /Setup|Identity|Notifications|Topology/i, canonical: /\/setup$/ }, { pack: '22', path: '/setup/topology/overview', text: /Topology Overview|Topology/i, canonical: /\/setup\/topology\/overview$/ }, { pack: '22', path: '/setup/topology/map', text: /Map|Topology|Target/i, canonical: /\/setup\/topology\/map$/ }, { pack: '22', path: '/setup/topology/targets', text: /Targets?|Topology/i, canonical: /\/setup\/topology\/targets$/ }, { pack: '22', path: '/setup/topology/hosts', text: /Hosts?|Topology/i, canonical: /\/setup\/topology\/hosts$/ }, { pack: '22', path: '/setup/topology/agents', text: /Agent|Topology/i, canonical: /\/setup\/topology\/agents$/ }, ]; const RUN_EXPECTATIONS = (() => { if (!conformanceFilter) { return EXPECTATIONS; } const rx = new RegExp(conformanceFilter, 'i'); return EXPECTATIONS.filter((item) => rx.test(`pack-${item.pack} ${item.path}`)); })(); if (screenshotAbsDir) { fs.mkdirSync(screenshotAbsDir, { recursive: true }); } if (endpointMatrixAbsFile) { fs.mkdirSync(path.dirname(endpointMatrixAbsFile), { recursive: true }); } function slugifyRoute(routePath: string): string { return routePath.replace(/^\/+/, '').replace(/[\/:]+/g, '-').replace(/[^a-zA-Z0-9._-]+/g, '-'); } async function setupShell(page: Page): Promise { await page.addInitScript((session) => { try { window.sessionStorage.clear(); } catch { // ignore storage access errors } (window as { __stellaopsTestSession?: unknown }).__stellaopsTestSession = session; }, shellSession); await page.route('**/platform/envsettings.json', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockConfig), }) ); await page.route('**/config.json', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockConfig), }) ); await page.route('**/authority/.well-known/openid-configuration', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(oidcConfig), }) ); await page.route('**/.well-known/openid-configuration', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(oidcConfig), }) ); await page.route('**/authority/.well-known/jwks.json', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ keys: [] }), }) ); await page.route('**/authority/connect/**', (route) => route.fulfill({ status: 400, contentType: 'application/json', body: JSON.stringify({ error: 'not-used-in-shell-e2e' }), }) ); } async function go(page: Page, path: string): Promise { await page.goto(path, { waitUntil: 'domcontentloaded' }); await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => null); } async function ensureShell(page: Page): Promise { await expect(page.locator('aside.sidebar')).toHaveCount(1, { timeout: 15000 }); } async function assertMainHasContent(page: Page): Promise { const main = page.locator('main'); await expect(main).toHaveCount(1); await expect(main).toBeVisible(); const text = ((await main.textContent()) ?? '').replace(/\s+/g, ''); const childNodes = await main.locator('*').count(); expect(text.length > 12 || childNodes > 4).toBe(true); } test.describe('Pack conformance from docs/modules/ui/v2-rewire/pack-01..21', () => { test.beforeEach(async ({ page }) => { await setupShell(page); }); test('canonical screens render and expose expected pack text markers', async ({ page }, testInfo) => { test.setTimeout(6 * 60_000); const failures: string[] = []; const screenshotIndex: Array<{ pack: string; route: string; file: string }> = []; const endpointRecords: Array<{ pack: string; route: string; method: string; status: number; path: string; resourceType: string; }> = []; let activeRecord: { pack: string; route: string } | null = null; const scoped = RUN_EXPECTATIONS; page.on('response', (response) => { if (!activeRecord) { return; } const request = response.request(); const resourceType = request.resourceType(); if (resourceType !== 'xhr' && resourceType !== 'fetch') { return; } let responsePath = response.url(); try { responsePath = new URL(response.url()).pathname; } catch { // Keep raw URL if parsing fails. } endpointRecords.push({ pack: activeRecord.pack, route: activeRecord.route, method: request.method(), status: response.status(), path: responsePath, resourceType, }); }); for (const item of scoped) { await test.step(`pack-${item.pack} ${item.path}`, async () => { activeRecord = { pack: item.pack, route: item.path }; try { try { await go(page, item.path); } catch (error) { failures.push(`[pack-${item.pack}] ${item.path} -> navigation failed: ${String(error)}`); return; } const shellCount = await page.locator('aside.sidebar').count(); if (shellCount !== 1) { failures.push(`[pack-${item.pack}] ${item.path} -> shell sidebar count=${shellCount}`); } const main = page.locator('main.shell__outlet').first(); const mainCount = await page.locator('main.shell__outlet').count(); if (mainCount !== 1) { failures.push(`[pack-${item.pack}] ${item.path} -> shell main count=${mainCount}`); return; } const isVisible = await main.isVisible().catch(() => false); if (!isVisible) { failures.push(`[pack-${item.pack}] ${item.path} -> main is not visible`); } const mainText = (await main.textContent().catch(() => '')) ?? ''; const compactText = mainText.replace(/\s+/g, ''); const childNodes = await main.locator('*').count().catch(() => 0); if (!(compactText.length > 12 || childNodes > 4)) { failures.push( `[pack-${item.pack}] ${item.path} -> main appears empty (text=${compactText.length}, children=${childNodes})` ); } if (item.canonical) { const currentUrl = page.url(); if (!item.canonical.test(currentUrl)) { failures.push( `[pack-${item.pack}] ${item.path} -> canonical mismatch, expected ${item.canonical}, got ${currentUrl}` ); } } if (!item.text.test(mainText)) { const preview = mainText.replace(/\s+/g, ' ').trim().slice(0, 220); failures.push( `[pack-${item.pack}] ${item.path} -> missing text ${item.text}, preview="${preview}"` ); } if (screenshotAbsDir) { const fileName = `pack-${item.pack}_${slugifyRoute(item.path)}.png`; const absFile = path.join(screenshotAbsDir, fileName); await page.screenshot({ path: absFile, fullPage: true }); screenshotIndex.push({ pack: item.pack, route: item.path, file: fileName }); } } finally { activeRecord = null; } }); } if (screenshotAbsDir) { const rows = ['pack,route,file', ...screenshotIndex.map((row) => `${row.pack},${row.route},${row.file}`)]; fs.writeFileSync(path.join(screenshotAbsDir, 'index.csv'), `${rows.join('\n')}\n`, 'utf8'); } if (endpointMatrixAbsFile) { const dedup = new Map(); for (const row of endpointRecords) { const key = `${row.pack}|${row.route}|${row.method}|${row.status}|${row.path}|${row.resourceType}`; const current = dedup.get(key); if (current) { current.count += 1; } else { dedup.set(key, { count: 1, row }); } } const csvRows = [ 'pack,route,method,status,path,resourceType,count', ...Array.from(dedup.values()) .sort((a, b) => `${a.row.pack} ${a.row.route} ${a.row.path}`.localeCompare( `${b.row.pack} ${b.row.route} ${b.row.path}` ) ) .map(({ row, count }) => `${row.pack},${row.route},${row.method},${row.status},${row.path},${row.resourceType},${count}` ), ]; fs.writeFileSync(endpointMatrixAbsFile, `${csvRows.join('\n')}\n`, 'utf8'); } const ledger = failures.length > 0 ? failures.join('\n') : 'All pack routes matched current expectations.'; await testInfo.attach('pack-conformance-ledger', { body: ledger, contentType: 'text/plain', }); if (failures.length > 0) { // Emit full list in test output for quick triage without opening traces. console.error(`Pack conformance mismatches (${failures.length})\n${ledger}`); } expect(failures, `Pack conformance mismatches (${failures.length})`).toEqual([]); }); });