128 lines
5.1 KiB
TypeScript
128 lines
5.1 KiB
TypeScript
|
|
import { test, expect, type Request } from '@playwright/test';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* M0 — Bootstrap smoke checks.
|
||
|
|
* Validates what M0 actually delivers:
|
||
|
|
* 1. The 3-container stack is reachable (front + api proxy).
|
||
|
|
* 2. The home page renders the RTOps design system primitives.
|
||
|
|
* 3. Self-hosted webfonts (no Google Fonts CDN — spec §7).
|
||
|
|
* 4. No JS console errors on first load.
|
||
|
|
* 5. API health endpoint returns the expected JSON.
|
||
|
|
*/
|
||
|
|
|
||
|
|
test.describe('M0 — bootstrap smoke', () => {
|
||
|
|
const consoleErrors: string[] = [];
|
||
|
|
const externalRequests: string[] = [];
|
||
|
|
|
||
|
|
test.beforeEach(({ page }) => {
|
||
|
|
consoleErrors.length = 0;
|
||
|
|
externalRequests.length = 0;
|
||
|
|
|
||
|
|
page.on('console', (msg) => {
|
||
|
|
if (msg.type() === 'error') consoleErrors.push(msg.text());
|
||
|
|
});
|
||
|
|
page.on('pageerror', (err) => consoleErrors.push(`pageerror: ${err.message}`));
|
||
|
|
page.on('request', (req: Request) => {
|
||
|
|
const url = req.url();
|
||
|
|
if (
|
||
|
|
url.includes('fonts.googleapis.com') ||
|
||
|
|
url.includes('fonts.gstatic.com') ||
|
||
|
|
url.includes('cdn.jsdelivr.net') ||
|
||
|
|
url.includes('unpkg.com')
|
||
|
|
) {
|
||
|
|
externalRequests.push(url);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
test('home page loads and renders the RTOps header', async ({ page }) => {
|
||
|
|
const resp = await page.goto('/');
|
||
|
|
expect(resp?.status(), 'home page should respond 200').toBe(200);
|
||
|
|
await expect(page).toHaveTitle(/Metamorph/);
|
||
|
|
|
||
|
|
const h1 = page.getByRole('heading', { level: 1 });
|
||
|
|
await expect(h1).toContainText('Metamorph');
|
||
|
|
await expect(h1).toContainText('Purple Team Platform');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('API health card eventually shows OK', async ({ page }) => {
|
||
|
|
await page.goto('/');
|
||
|
|
// The "API" card binds the health probe; wait for the green-accent state.
|
||
|
|
const apiCard = page.locator('h3', { hasText: /^API$/ }).locator('..');
|
||
|
|
await expect(apiCard).toContainText(/version\s+\d+/i, { timeout: 10_000 });
|
||
|
|
await expect(apiCard).toContainText('ok');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('design system primitives render with the expected accent classes', async ({ page }) => {
|
||
|
|
await page.goto('/');
|
||
|
|
// Tags from the demo row.
|
||
|
|
await expect(page.getByText('EVASION', { exact: true })).toBeVisible();
|
||
|
|
await expect(page.getByText('C2', { exact: true })).toBeVisible();
|
||
|
|
await expect(page.getByText('LATERAL', { exact: true })).toBeVisible();
|
||
|
|
// Flow nodes.
|
||
|
|
await expect(page.getByText('recon', { exact: true })).toBeVisible();
|
||
|
|
await expect(page.getByText('impact', { exact: true })).toBeVisible();
|
||
|
|
// Buttons.
|
||
|
|
await expect(page.getByRole('button', { name: /primary/i })).toBeVisible();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('body uses self-hosted IBM Plex Sans, no Google Fonts requests', async ({ page }) => {
|
||
|
|
await page.goto('/');
|
||
|
|
// Wait for fonts to settle.
|
||
|
|
await page.evaluate(() => document.fonts.ready);
|
||
|
|
|
||
|
|
const bodyFont = await page.evaluate(() =>
|
||
|
|
window.getComputedStyle(document.body).fontFamily.toLowerCase(),
|
||
|
|
);
|
||
|
|
expect(bodyFont).toContain('ibm plex sans');
|
||
|
|
|
||
|
|
// Header is mono.
|
||
|
|
const h1Font = await page.evaluate(() => {
|
||
|
|
const h1 = document.querySelector('h1');
|
||
|
|
return h1 ? window.getComputedStyle(h1).fontFamily.toLowerCase() : '';
|
||
|
|
});
|
||
|
|
expect(h1Font).toContain('jetbrains mono');
|
||
|
|
|
||
|
|
// No request must hit Google Fonts or any other CDN — see spec §7.
|
||
|
|
expect(externalRequests, `unexpected CDN traffic: ${externalRequests.join(', ')}`).toEqual([]);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('background uses the RTOps deep navy token', async ({ page }) => {
|
||
|
|
await page.goto('/');
|
||
|
|
const bg = await page.evaluate(() => window.getComputedStyle(document.body).backgroundColor);
|
||
|
|
// tasks/design.md: --bg = #0a0e1a → rgb(10, 14, 26)
|
||
|
|
expect(bg).toBe('rgb(10, 14, 26)');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('no JS console errors on first load', async ({ page }) => {
|
||
|
|
await page.goto('/');
|
||
|
|
await page.waitForLoadState('networkidle');
|
||
|
|
// The auth provider attempts a silent /auth/refresh at mount; without a
|
||
|
|
// refresh cookie the server returns 401 and the browser logs a generic
|
||
|
|
// "Failed to load resource" warning. That's expected for unauthenticated
|
||
|
|
// visitors and doesn't constitute a real error.
|
||
|
|
const realErrors = consoleErrors.filter(
|
||
|
|
(e) => !/Failed to load resource.*401/i.test(e),
|
||
|
|
);
|
||
|
|
expect(realErrors, `console errors: ${realErrors.join(' | ')}`).toEqual([]);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('API health endpoint returns the expected JSON shape', async ({ request }) => {
|
||
|
|
const resp = await request.get('/api/v1/health');
|
||
|
|
expect(resp.status()).toBe(200);
|
||
|
|
const body = (await resp.json()) as { status: string; version: string };
|
||
|
|
expect(body.status).toBe('ok');
|
||
|
|
expect(body.version).toMatch(/^\d+\.\d+\.\d+/);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('CORS headers are set when the SPA origin asks for them', async ({ request }) => {
|
||
|
|
const resp = await request.get('/api/v1/health', {
|
||
|
|
headers: { Origin: 'http://localhost:8080' },
|
||
|
|
});
|
||
|
|
expect(resp.status()).toBe(200);
|
||
|
|
// flask-cors echoes back the configured origin when allowed.
|
||
|
|
const allowed = resp.headers()['access-control-allow-origin'];
|
||
|
|
expect(allowed === 'http://localhost:8080' || allowed === '*').toBeTruthy();
|
||
|
|
});
|
||
|
|
});
|