375 lines
15 KiB
TypeScript
375 lines
15 KiB
TypeScript
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<void> {
|
|
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<void> {
|
|
await page.goto(path, { waitUntil: 'domcontentloaded' });
|
|
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => null);
|
|
}
|
|
|
|
async function ensureShell(page: Page): Promise<void> {
|
|
await expect(page.locator('aside.sidebar')).toHaveCount(1, { timeout: 15000 });
|
|
}
|
|
|
|
async function assertMainHasContent(page: Page): Promise<void> {
|
|
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<string, { count: number; row: typeof endpointRecords[number] }>();
|
|
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([]);
|
|
});
|
|
});
|