Public source validation / validate (push) Failing after 3m8s
131 lines
7.3 KiB
TypeScript
131 lines
7.3 KiB
TypeScript
import AxeBuilder from '@axe-core/playwright';
|
|
import { expect, test, type Page } from '@playwright/test';
|
|
import path from 'node:path';
|
|
|
|
const observedAt = new Date().toISOString();
|
|
|
|
async function mockSignalFlow(page: Page): Promise<void> {
|
|
await page.route('**/api/v1/**', async (route) => {
|
|
const pathname = new URL(route.request().url()).pathname;
|
|
const body = pathname === '/api/v1/system/status' ? {
|
|
version: '1', generatedAt: observedAt, overallState: 'degraded',
|
|
components: [
|
|
{ id: 'database', state: 'healthy', reason: 'ok' },
|
|
{ id: 'worker', state: 'healthy', reason: 'ok' },
|
|
{ id: 'prometheus', state: 'degraded', reason: 'source_stale' },
|
|
{ id: 'unraid', state: 'healthy', reason: 'ok' },
|
|
{ id: 'storage', state: 'healthy', reason: 'ok' },
|
|
],
|
|
backup: { state: 'healthy', reason: 'ok', ageSeconds: 3600 },
|
|
sourceLag: [{ sourceId: 'prometheus', state: 'degraded', reason: 'source_stale', ageSeconds: 420 }],
|
|
} : pathname === '/api/v1/host' ? {
|
|
identity: { name: 'tower' }, cpu: { totalPercent: 68.4, perCore: [72, 64, 66, 71] },
|
|
memory: { utilizationPercent: 71.2 }, source: { state: 'healthy', freshness: 'fresh' },
|
|
} : pathname === '/api/v1/containers' ? {
|
|
source: { state: 'healthy', freshness: 'fresh' }, total: 6,
|
|
containers: [
|
|
{ id: 'one', state: 'running', health: 'healthy' }, { id: 'two', state: 'running', health: 'healthy' },
|
|
{ id: 'three', state: 'running', health: 'healthy' }, { id: 'four', state: 'running', health: 'healthy' },
|
|
{ id: 'five', state: 'running', health: 'healthy' }, { id: 'six', state: 'restarting', health: 'unknown' },
|
|
],
|
|
} : pathname === '/api/v1/pools' ? {
|
|
source: { state: 'healthy', freshness: 'fresh' }, total: 2,
|
|
pools: [
|
|
{ id: 'cache', name: 'Cache', state: 'healthy', capacitySeverity: 'normal', utilizationPercent: 63.8 },
|
|
{ id: 'array', name: 'Array', state: 'healthy', capacitySeverity: 'attention', utilizationPercent: 86.1 },
|
|
],
|
|
} : pathname === '/api/v1/services' ? {
|
|
capabilityState: 'available', configurationState: 'configured', total: 3,
|
|
services: [
|
|
{ id: 'proxy', name: 'Reverse proxy', state: 'up' },
|
|
{ id: 'auth', name: 'Authentik', state: 'up' },
|
|
{ id: 'media', name: 'Media', state: 'degraded' },
|
|
],
|
|
} : pathname === '/api/v1/incidents' ? {
|
|
items: [{ id: 'incident-1', title: 'Prometheus-bron loopt achter', severity: 'warning', startedAt: observedAt }],
|
|
} : pathname === '/api/v1/dashboards' ? { items: [] } : {};
|
|
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
|
|
});
|
|
}
|
|
|
|
test.beforeEach(async ({ page }) => mockSignalFlow(page));
|
|
|
|
test('operational signal path is diagnostic, keyboard reachable and responsive', async ({ page }, testInfo) => {
|
|
test.skip(testInfo.project.name === 'wallboard-chromium', 'The wallboard keeps its dedicated bounded composition.');
|
|
const runtimeErrors: string[] = [];
|
|
page.on('pageerror', (error) => runtimeErrors.push(error.message));
|
|
page.on('console', (message) => { if (message.type() === 'error') runtimeErrors.push(message.text()); });
|
|
await page.goto('/');
|
|
const panel = page.locator('.signal-path-panel');
|
|
await expect(panel).toBeVisible();
|
|
await expect(panel.getByRole('heading', { name: 'Signaalpad' })).toBeVisible();
|
|
const stages = panel.locator('.signal-path-stage button');
|
|
await expect(stages).toHaveCount(6);
|
|
await expect(stages.first()).toHaveAttribute('aria-pressed', 'true');
|
|
await stages.nth(1).focus();
|
|
await page.keyboard.press('Enter');
|
|
await expect(stages.nth(1)).toHaveAttribute('aria-pressed', 'true');
|
|
await expect(panel.locator('.signal-path-inspector')).toContainText('68,4%');
|
|
await expect(panel.locator('.signal-path-inspector')).toContainText('71,2%');
|
|
await expect(panel.getByRole('button', { name: 'Open host' })).toBeVisible();
|
|
|
|
const stageHeights = await stages.evaluateAll((items) => items.map((item) => item.getBoundingClientRect().height));
|
|
expect(stageHeights.every((height) => height >= 44)).toBe(true);
|
|
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
|
|
if (testInfo.project.name === 'desktop-chromium') {
|
|
const severe = (await new AxeBuilder({ page }).analyze()).violations.filter((violation) => violation.impact === 'critical' || violation.impact === 'serious');
|
|
expect(severe, severe.map((violation) => `${violation.id}: ${violation.help}`).join('\n')).toEqual([]);
|
|
}
|
|
|
|
if (process.env.PULSE_SOL_ULTRA_CAPTURE) {
|
|
await page.evaluate(() => { window.scrollTo(0, 0); (document.activeElement as HTMLElement | null)?.blur(); });
|
|
const mobile = testInfo.project.name === 'mobile-chromium';
|
|
await page.screenshot({ path: path.resolve('../../artifacts/evidence/SOL-ULTRA/visual', `overview-${testInfo.project.name}.png`), fullPage: !mobile });
|
|
if (mobile) {
|
|
await page.addStyleTag({ content: '.context-bar, .mobile-navigation { display: none !important; }' });
|
|
await panel.screenshot({ path: path.resolve('../../artifacts/evidence/SOL-ULTRA/visual/overview-mobile-chromium-signal.png') });
|
|
}
|
|
}
|
|
expect(runtimeErrors).toEqual([]);
|
|
if (testInfo.project.name === 'desktop-chromium') {
|
|
await panel.getByRole('button', { name: 'Open host' }).click();
|
|
await expect(page).toHaveURL(/\/host$/);
|
|
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
|
|
}
|
|
});
|
|
|
|
test('failed overview resources remain unknown and recover through the shared retry', async ({ page }, testInfo) => {
|
|
test.skip(testInfo.project.name !== 'desktop-chromium', 'The data-state contract is viewport independent.');
|
|
let poolCalls = 0;
|
|
let allowPoolRecovery = false;
|
|
await page.route('**/api/v1/pools?**', async (route) => {
|
|
poolCalls += 1;
|
|
if (!allowPoolRecovery) {
|
|
await route.fulfill({ status: 503, contentType: 'application/problem+json', body: JSON.stringify({ code: 'SOURCE_UNAVAILABLE' }) });
|
|
return;
|
|
}
|
|
await route.fallback();
|
|
});
|
|
await page.goto('/');
|
|
const storage = page.locator('.signal-path-stage').filter({ hasText: 'Opslag' });
|
|
await expect(storage).toHaveAttribute('data-tone', 'unknown');
|
|
await expect(storage).toContainText('Niet beschikbaar');
|
|
await expect(page.locator('.overview-kpi').filter({ hasText: 'Hoogste poolgebruik' }).locator('strong')).toHaveText('—');
|
|
await expect(page.locator('.capacity-plane')).toContainText('Deze overzichtsbron kon niet worden geladen');
|
|
await expect(page.getByRole('heading', { level: 1, name: 'Aandacht vereist' })).toBeVisible();
|
|
|
|
allowPoolRecovery = true;
|
|
await page.getByRole('button', { name: 'Opnieuw laden' }).click();
|
|
await expect(storage).toContainText('Aandacht');
|
|
await expect(storage).not.toContainText('Niet beschikbaar');
|
|
expect(poolCalls).toBeGreaterThanOrEqual(2);
|
|
});
|
|
|
|
test('signal animation yields to reduced-motion preference', async ({ page }, testInfo) => {
|
|
test.skip(testInfo.project.name !== 'desktop-chromium', 'Reduced-motion CSS is viewport independent.');
|
|
await page.emulateMedia({ reducedMotion: 'reduce' });
|
|
await page.goto('/');
|
|
const animation = await page.locator('.signal-path-stage--healthy').first().evaluate((element) => getComputedStyle(element, '::before').animationDuration);
|
|
expect(Number.parseFloat(animation)).toBeLessThanOrEqual(0.001);
|
|
});
|