Publish curated ITWorx-Pulse source from 0e0633c8ac91cb71312a26837f791035a78d81b8
Public source validation / validate (push) Failing after 2m10s
Public source validation / validate (push) Failing after 2m10s
This commit is contained in:
1 parent
a4956c2af9
commit
db8093b608
88 files changed
+3685
-469
No files matched your search
@@ -18,6 +18,11 @@ function json(value: unknown, status = 200): Response {
|
||||
|
||||
const healthySource = { state: 'healthy', freshness: 'fresh' };
|
||||
|
||||
function signalStage(name: RegExp): HTMLElement {
|
||||
const heading = screen.getByRole('heading', { name: 'Signaalpad' });
|
||||
return within(heading.closest('article')!).getByRole('button', { name });
|
||||
}
|
||||
|
||||
describe('Stitch command overview', () => {
|
||||
it('shows real source values while failed sources remain explicitly unavailable', async () => {
|
||||
window.history.replaceState({}, '', '/');
|
||||
@@ -51,13 +56,13 @@ describe('Stitch command overview', () => {
|
||||
expect(document.querySelector('.focus-panel')).toBeInTheDocument();
|
||||
expect(document.querySelector('.context-inspector')).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Signaalpad' })).toBeVisible();
|
||||
const unavailableStorage = screen.getByRole('button', { name: /Opslag: Niet beschikbaar/ });
|
||||
const unavailableStorage = signalStage(/Opslag: Niet beschikbaar/);
|
||||
expect(unavailableStorage.closest('.signal-path-stage')).toHaveAttribute('data-tone', 'unknown');
|
||||
const staleSource = screen.getByRole('button', { name: /Bronnen: Aandacht.*0\/1/ });
|
||||
const staleSource = signalStage(/Bronnen: Aandacht.*0\/1/);
|
||||
expect(staleSource.closest('.signal-path-stage')).toHaveAttribute('data-tone', 'attention');
|
||||
expect(screen.getAllByText('62,3%')[0]).toBeVisible();
|
||||
expect(screen.getByText('tower-lab')).toBeVisible();
|
||||
expect(screen.getByRole('button', { name: /Workloads: Kritiek.*1\/2/ })).toBeVisible();
|
||||
expect(signalStage(/Workloads: Kritiek.*1\/2/)).toBeVisible();
|
||||
expect(screen.queryByText('0 pools')).not.toBeInTheDocument();
|
||||
const storageCard = screen.getByRole('heading', { name: 'Capaciteit en toestand' }).closest('article');
|
||||
expect(storageCard).not.toBeNull();
|
||||
@@ -128,7 +133,7 @@ describe('Stitch command overview', () => {
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByRole('button', { name: /Incidenten: Niet beschikbaar/ })).toBeVisible();
|
||||
await waitFor(() => expect(signalStage(/Incidenten: Niet beschikbaar/)).toBeVisible());
|
||||
const incidentCard = screen.getByRole('heading', { name: 'Incidenten' }).closest('article');
|
||||
expect(incidentCard).not.toBeNull();
|
||||
expect(within(incidentCard!).getByText(/Deze overzichtsbron kon niet worden geladen/)).toBeVisible();
|
||||
@@ -150,8 +155,8 @@ describe('Stitch command overview', () => {
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByRole('button', { name: /Bronnen: Geen toegang/ })).toBeVisible();
|
||||
expect(await screen.findByRole('button', { name: /Opslag: Geen toegang/ })).toBeVisible();
|
||||
await waitFor(() => expect(signalStage(/Bronnen: Geen toegang/)).toBeVisible());
|
||||
expect(signalStage(/Opslag: Geen toegang/)).toBeVisible();
|
||||
expect(screen.getByText('Opslag: Geen toegang')).toBeVisible();
|
||||
expect(screen.getAllByText(/account heeft geen toegang tot deze overzichtsbron/).length).toBeGreaterThan(0);
|
||||
expect(screen.queryByRole('link', { name: /Aanmelden/ })).not.toBeInTheDocument();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import App from '../../src/App';
|
||||
import { DashboardManagerPage } from '../../src/DashboardManagerPage';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
@@ -21,9 +21,9 @@ describe('dashboard request caching', () => {
|
||||
return new Response(JSON.stringify({ error: 'not configured' }), { status: 503, headers: { 'Content-Type': 'application/json' } });
|
||||
}));
|
||||
|
||||
render(<App />);
|
||||
render(<DashboardManagerPage />);
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Jouw dashboards' })).toBeVisible();
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Dashboardbeheer' })).toBeVisible();
|
||||
const dashboardRequest = requests.find((request) => request.url.startsWith('/api/v1/dashboards'));
|
||||
expect(dashboardRequest?.init?.cache).toBe('no-store');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { DashboardEditor, validateDashboardLayouts, validateWidget, type EditorWidget } from '../../src/DashboardEditor';
|
||||
|
||||
afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
|
||||
|
||||
function layouts(x: number, y: number, w = 6, h = 4) {
|
||||
return {
|
||||
desktop: { x, y, w, h, visible: true }, tablet: { x: Math.min(x, 2), y, w: Math.min(w, 8), h, visible: true },
|
||||
mobile: { x: 0, y, w: 1, h, visible: true }, wallboard: { x, y, w, h, visible: true },
|
||||
};
|
||||
}
|
||||
|
||||
function widget(id: string, title: string, x: number, y: number, locked = false): EditorWidget {
|
||||
return { id, type: 'stat', title, data: { sourceType: 'inventory', limit: 10 }, visualization: { decimals: 0, thresholds: [] }, behavior: { locked, hidden: false, liveIntervalSeconds: 30 }, layouts: layouts(x, y) };
|
||||
}
|
||||
|
||||
const document = { schemaVersion: 2, id: '00000000-0000-4000-8000-000000000001', slug: 'test', name: 'Test', scope: 'personal', variables: [], widgets: [widget('00000000-0000-4000-8000-000000000011', 'CPU', 0, 0), widget('00000000-0000-4000-8000-000000000012', 'Geheugen', 10, 8)], settings: { defaultTimeRange: '1h' } };
|
||||
|
||||
describe('DashboardEditor spatial composition', () => {
|
||||
it('keeps viewport layouts independent and saves x/y/w/h, thresholds and an internal link', async () => {
|
||||
let savedBody: Record<string, unknown> | undefined;
|
||||
vi.stubGlobal('fetch', vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
savedBody = JSON.parse(String(init?.body));
|
||||
return new Response(JSON.stringify({ dashboard: { revision: 2 } }), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}));
|
||||
const onSaved = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<DashboardEditor dashboardId="dashboard-1" revision={1} document={document} onExit={() => undefined} onSaved={onSaved} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Widget naar rechts: CPU' }));
|
||||
expect(screen.getByLabelText('Kolom (x) (Desktop)')).toHaveValue(1);
|
||||
fireEvent.change(screen.getByLabelText('Hoogte in rijen'), { target: { value: '6' } });
|
||||
await user.selectOptions(screen.getByLabelText('Layout'), 'tablet');
|
||||
expect(screen.getByLabelText('Kolom (x) (Tablet)')).toHaveValue(0);
|
||||
fireEvent.change(screen.getByLabelText('Kolom (x) (Tablet)'), { target: { value: '2' } });
|
||||
await user.selectOptions(screen.getByLabelText('Layout'), 'desktop');
|
||||
expect(screen.getByLabelText('Kolom (x) (Desktop)')).toHaveValue(1);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Drempel toevoegen' }));
|
||||
const threshold = screen.getByLabelText('Drempels').querySelector<HTMLElement>('.config-threshold-row')!;
|
||||
fireEvent.change(within(threshold).getByLabelText('Waarde'), { target: { value: '85' } });
|
||||
fireEvent.change(within(threshold).getByLabelText('Kleur'), { target: { value: '#ff8800' } });
|
||||
await user.type(screen.getByLabelText('Interne drilldownlink'), '/incidents');
|
||||
await user.click(screen.getByRole('button', { name: 'Opslaan' }));
|
||||
|
||||
await waitFor(() => expect(onSaved).toHaveBeenCalled());
|
||||
const widgets = savedBody?.widgets as EditorWidget[];
|
||||
expect((widgets[0].layouts?.desktop as Record<string, unknown>)).toMatchObject({ x: 1, y: 0, w: 6, h: 6 });
|
||||
expect((widgets[0].layouts?.tablet as Record<string, unknown>)).toMatchObject({ x: 2, y: 0, w: 6, h: 4 });
|
||||
expect(widgets[0].visualization?.thresholds).toEqual([{ value: 85, color: '#ff8800', label: '' }]);
|
||||
expect(widgets[0].behavior?.link).toBe('/incidents');
|
||||
}, 15_000);
|
||||
|
||||
it('prevents every spatial control from mutating a locked widget', () => {
|
||||
const lockedDocument = { ...document, widgets: [widget('00000000-0000-4000-8000-000000000021', 'Vergrendeld', 2, 3, true)] };
|
||||
render(<DashboardEditor dashboardId="dashboard-1" revision={1} document={lockedDocument} onExit={() => undefined} onSaved={() => undefined} />);
|
||||
expect(screen.getByRole('button', { name: 'Widget naar links: Vergrendeld' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Widget omhoog: Vergrendeld' })).toBeDisabled();
|
||||
expect(screen.getByRole('slider', { name: 'Breedte aanpassen: Vergrendeld' })).toBeDisabled();
|
||||
expect(screen.getByLabelText('Kolom (x) (Desktop)')).toBeDisabled();
|
||||
expect(screen.getByLabelText('Hoogte in rijen')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('rejects unsafe links, malformed thresholds and colliding persisted layouts', () => {
|
||||
expect(validateWidget({ ...widget('x', 'Onveilig', 0, 0), visualization: { thresholds: [{ value: 80, color: 'red' }] }, behavior: { link: 'https://example.test', liveIntervalSeconds: 30 } })).toMatchObject({ 'visualization.thresholds': expect.any(String), 'behavior.link': expect.any(String) });
|
||||
expect(validateDashboardLayouts([widget('a', 'A', 0, 0), widget('b', 'B', 0, 0)])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { DashboardManagerPage } from '../../src/DashboardManagerPage';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function json(value: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(value), { status, headers: { 'Content-Type': status >= 400 ? 'application/problem+json' : 'application/json' } });
|
||||
}
|
||||
|
||||
const editorSession = { subject: 'editor-1', role: 'editor', environment: 'test', permissions: { view: true, operate: true, edit: true, admin: false } };
|
||||
const viewerSession = { subject: 'viewer-1', role: 'viewer', environment: 'test', permissions: { view: true, operate: false, edit: false, admin: false } };
|
||||
const dashboard = { id: 'dashboard-1', slug: 'operations', name: 'Operations', description: 'Dagelijkse operatie', ownerId: 'editor-1', scope: 'shared', revision: 1, currentVersion: 2 };
|
||||
|
||||
describe('DashboardManagerPage', () => {
|
||||
it('creates a validated dashboard from a selected template', async () => {
|
||||
const requests: Array<{ url: string; init?: RequestInit }> = [];
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input); requests.push({ url, init });
|
||||
if (url === '/api/v1/session') return json(editorSession);
|
||||
if (url.startsWith('/api/v1/dashboards?')) return json({ items: [] });
|
||||
if (url === '/api/v1/dashboards' && init?.method === 'POST') return json({ dashboard: { ...dashboard, id: 'created-1', slug: 'nachtzicht', name: 'Nachtzicht', description: '', revision: 1, currentVersion: 1 } }, 201);
|
||||
return json({ code: 'NOT_FOUND' }, 404);
|
||||
}));
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<DashboardManagerPage />);
|
||||
await user.click(await screen.findByRole('button', { name: 'Nieuw dashboard' }));
|
||||
await user.type(screen.getByLabelText('Naam'), 'Nachtzicht');
|
||||
await user.selectOptions(screen.getByLabelText('Template'), 'blank');
|
||||
await user.click(screen.getByRole('button', { name: 'Dashboard maken' }));
|
||||
|
||||
expect(await screen.findByText('Nachtzicht')).toBeVisible();
|
||||
const create = requests.find((request) => request.url === '/api/v1/dashboards' && request.init?.method === 'POST');
|
||||
expect(create).toBeDefined();
|
||||
expect(JSON.parse(String(create?.init?.body))).toMatchObject({ schemaVersion: 2, name: 'Nachtzicht', widgets: [] });
|
||||
});
|
||||
|
||||
it('renames, restores and archives with the current revision', async () => {
|
||||
const requests: Array<{ url: string; init?: RequestInit }> = [];
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input); requests.push({ url, init });
|
||||
if (url === '/api/v1/session') return json(editorSession);
|
||||
if (url.startsWith('/api/v1/dashboards?')) return json({ items: [dashboard] });
|
||||
if (url === '/api/v1/dashboards/dashboard-1' && init?.method === 'PATCH') return json({ dashboard: { ...dashboard, name: 'Operations EU', revision: 2 } });
|
||||
if (url.endsWith('/versions')) return json({ items: [
|
||||
{ id: 'version-2', dashboardId: 'dashboard-1', number: 2, schemaVersion: 2, document: {}, changeSummary: 'metadata', createdBy: 'editor-1', createdAt: '2026-09-01T10:00:00Z' },
|
||||
{ id: 'version-1', dashboardId: 'dashboard-1', number: 1, schemaVersion: 2, document: {}, changeSummary: 'initial', createdBy: 'editor-1', createdAt: '2026-09-01T09:00:00Z' },
|
||||
] });
|
||||
if (url.endsWith('/restore/1') && init?.method === 'POST') return json({ dashboard: { ...dashboard, name: 'Operations EU', revision: 3, currentVersion: 3 } });
|
||||
if (url === '/api/v1/dashboards/dashboard-1' && init?.method === 'DELETE') return json({ dashboard: { ...dashboard, name: 'Operations EU', revision: 4, currentVersion: 3, archivedAt: '2026-09-02T00:00:00Z' } });
|
||||
return json({ code: 'NOT_FOUND' }, 404);
|
||||
}));
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<DashboardManagerPage />);
|
||||
let row = (await screen.findByText('Operations')).closest('li');
|
||||
expect(row).not.toBeNull();
|
||||
await user.click(within(row!).getByRole('button', { name: 'Beheren' }));
|
||||
const name = within(row!).getByLabelText('Naam');
|
||||
await user.clear(name); await user.type(name, 'Operations EU');
|
||||
await user.click(within(row!).getByRole('button', { name: 'Naam en beschrijving opslaan' }));
|
||||
row = (await screen.findByText('Operations EU')).closest('li');
|
||||
await user.click(within(row!).getByRole('button', { name: 'Beheren' }));
|
||||
await user.click(within(row!).getByRole('button', { name: 'Versies bekijken' }));
|
||||
await user.click(await within(row!).findByRole('button', { name: 'Herstellen als huidige versie' }));
|
||||
await waitFor(() => expect(requests.some((request) => request.url.endsWith('/restore/1') && new Headers(request.init?.headers).get('If-Match') === '2')).toBe(true));
|
||||
await user.click(within(row!).getByRole('button', { name: 'Archiveren' }));
|
||||
await user.click(within(row!).getByRole('button', { name: 'Ja, archiveren' }));
|
||||
await waitFor(() => expect(requests.some((request) => request.init?.method === 'DELETE' && new Headers(request.init.headers).get('If-Match') === '3')).toBe(true));
|
||||
expect(await screen.findByText('Geen actieve dashboards beschikbaar.')).toBeVisible();
|
||||
});
|
||||
|
||||
it('hides write controls for viewers and explains the boundary', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => String(input) === '/api/v1/session' ? json(viewerSession) : json({ items: [dashboard] })));
|
||||
render(<DashboardManagerPage />);
|
||||
expect(await screen.findByText('Alleen bekijken')).toBeVisible();
|
||||
expect(screen.queryByRole('button', { name: 'Nieuw dashboard' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Beheren' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reloads and gives actionable feedback after a revision conflict', async () => {
|
||||
let lists = 0;
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url === '/api/v1/session') return json(editorSession);
|
||||
if (url.startsWith('/api/v1/dashboards?')) { lists += 1; return json({ items: [{ ...dashboard, revision: lists }] }); }
|
||||
if (url === '/api/v1/dashboards/dashboard-1' && init?.method === 'PATCH') return json({ code: 'REVISION_CONFLICT', detail: 'conflict' }, 409);
|
||||
return json({}, 404);
|
||||
}));
|
||||
const user = userEvent.setup();
|
||||
render(<DashboardManagerPage />);
|
||||
const row = (await screen.findByText('Operations')).closest('li')!;
|
||||
await user.click(within(row).getByRole('button', { name: 'Beheren' }));
|
||||
await user.click(within(row).getByRole('button', { name: 'Naam en beschrijving opslaan' }));
|
||||
expect(await screen.findByText(/intussen gewijzigd/)).toBeVisible();
|
||||
await waitFor(() => expect(lists).toBe(2));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DashboardRuntimeWidget, type RuntimeWidget } from '../../src/DashboardRuntimeWidget';
|
||||
|
||||
afterEach(() => { cleanup(); vi.useRealTimers(); vi.unstubAllGlobals(); });
|
||||
|
||||
const layout = { desktop: { x: 0, y: 0, w: 6, h: 4, visible: true } };
|
||||
function widget(type: string, sourceType: string, extra: Record<string, unknown> = {}): RuntimeWidget {
|
||||
return { id: type + '-1', type, title: 'Test ' + type, data: { sourceType, limit: 1000, ...extra }, behavior: { liveIntervalSeconds: 1 }, layouts: layout };
|
||||
}
|
||||
function mount(value: RuntimeWidget) {
|
||||
return render(<DashboardRuntimeWidget widget={value} viewport="desktop" document={{}} timeRange="1h" onState={() => undefined} onFilter={() => undefined} />);
|
||||
}
|
||||
|
||||
describe('DashboardRuntimeWidget adapters', () => {
|
||||
it('renders bounded alerts and clamps the URL to one hundred rows', async () => {
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.startsWith('/api/v1/alerts')) return new Response(JSON.stringify({ items: [{ id: 'a1', ruleName: 'Disk warm', state: 'firing', severity: 'critical', entityName: 'disk1', reason: 'temperatuur', updatedAt: '2026-09-01T10:00:00Z' }] }), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
return new Response(JSON.stringify({ components: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
mount(widget('alert-summary', 'alerts'));
|
||||
expect(await screen.findByText('Disk warm')).toBeInTheDocument();
|
||||
expect(fetchMock.mock.calls.some(([input]) => String(input) === '/api/v1/alerts?limit=100')).toBe(true);
|
||||
expect(screen.getAllByText('1', { selector: 'dd' })).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('renders incident rows and safe text without interpreting HTML', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => String(input).startsWith('/api/v1/incidents')
|
||||
? new Response(JSON.stringify({ items: [{ id: 'i1', title: 'Opslagincident', status: 'open', severity: 'warning', summary: 'Controle nodig', updatedAt: '2026-09-01T10:00:00Z' }] }), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
||||
: new Response(JSON.stringify({ components: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } })));
|
||||
const view = mount(widget('table', 'incidents'));
|
||||
expect(await screen.findByText('Opslagincident')).toBeInTheDocument();
|
||||
view.unmount();
|
||||
mount(widget('text', 'text', { text: '# Runbook\n[Open incident](/incidents)\n<script>alert(1)</script>' }));
|
||||
expect(screen.getByRole('link', { name: 'Open incident' })).toHaveAttribute('href', '/incidents');
|
||||
expect(screen.queryByRole('script')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('<script>alert(1)</script>')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('contains an unknown type locally and clears its bounded polling timer', async () => {
|
||||
vi.useFakeTimers();
|
||||
const clear = vi.spyOn(window, 'clearInterval');
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => String(input).startsWith('/api/v1/events')
|
||||
? new Response(JSON.stringify({ items: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
||||
: new Response(JSON.stringify({ components: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } })));
|
||||
const view = mount(widget('made-up', 'events'));
|
||||
expect(screen.getByText('Onbekend widgettype')).toBeInTheDocument();
|
||||
expect(screen.getByText('made-up')).toBeInTheDocument();
|
||||
view.unmount();
|
||||
expect(clear).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ExploreMetricsPage } from '../../src/ExploreMetricsPage';
|
||||
import { resetOperatorContextForTests, setOperatorCustomRange } from '../../src/operatorContext';
|
||||
|
||||
const metric = (name: string, maxRangeSeconds = 2_592_000) => ({ semanticName: name, description: 'Goedgekeurde metric voor operators', unit: 'percent', valueKind: 'gauge', defaultAggregation: 'avg', allowedVisualizations: ['timeseries'], limits: { maxRangeSeconds, maxSeries: 20, maxPoints: 4000, timeoutSeconds: 5 } });
|
||||
const catalog = { version: 'v1', metrics: [metric('host.cpu.utilization'), metric('host.memory.utilization', 3600), metric('storage.pool.utilization'), metric('service.availability'), metric('service.response_time')], bindings: ['host.cpu.utilization', 'host.memory.utilization', 'storage.pool.utilization', 'service.availability', 'service.response_time'].map((semanticName) => ({ semanticName, state: 'supported' })) };
|
||||
const response = { status: 'success', data: { result: [{ metric: { host: 'tower' }, values: [[1_777_000_000, '42'], [1_777_000_060, '43']] }] }, provenance: { source: 'fixture', metric: 'test', catalogVersion: 'v1', cacheKey: 'safe' }, sourceObservedAt: '2026-09-01T10:00:00Z', receivedAt: '2026-09-01T10:00:01Z', freshness: 'fresh', cacheHit: false };
|
||||
|
||||
afterEach(() => { cleanup(); vi.unstubAllGlobals(); resetOperatorContextForTests(); window.localStorage.clear(); window.history.replaceState({}, '', '/explore'); });
|
||||
|
||||
function mockAPI() {
|
||||
const calls: Array<Record<string, unknown>> = [];
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith('/api/v1/metrics/catalog')) return new Response(JSON.stringify(catalog), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
if (url.includes('/api/v1/incidents') || url.includes('/api/v1/alerts')) return new Response(JSON.stringify({ items: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
if (url.endsWith('/api/v1/metrics/query-range')) { calls.push(JSON.parse(String(init?.body))); return new Response(JSON.stringify(response), { status: 200, headers: { 'Content-Type': 'application/json' } }); }
|
||||
return new Response('', { status: 404 });
|
||||
}));
|
||||
return calls;
|
||||
}
|
||||
|
||||
describe('ExploreMetricsPage', () => {
|
||||
it('finds and opens a semantic metric without exposing PromQL', async () => {
|
||||
const calls = mockAPI(); const user = userEvent.setup();
|
||||
render(<ExploreMetricsPage />);
|
||||
await user.type(await screen.findByPlaceholderText('CPU, opslag, responstijd…'), 'cpu');
|
||||
await user.click(screen.getByRole('button', { name: /host.cpu.utilization/ }));
|
||||
await waitFor(() => expect(calls).toHaveLength(1));
|
||||
expect(calls[0]).toMatchObject({ metric: 'host.cpu.utilization', maxSeries: 20, maxPoints: 4000 });
|
||||
expect(document.body).not.toHaveTextContent('rate(');
|
||||
});
|
||||
|
||||
it('caps compare at four metrics and blocks an over-budget range locally', async () => {
|
||||
const calls = mockAPI(); const user = userEvent.setup();
|
||||
render(<ExploreMetricsPage />);
|
||||
await user.click(await screen.findByRole('tab', { name: 'Vergelijken' }));
|
||||
for (const name of ['host.cpu.utilization', 'storage.pool.utilization', 'service.availability', 'service.response_time']) await user.click(screen.getByRole('button', { name: new RegExp(name.replaceAll('.', '\\.')) }));
|
||||
expect(screen.getByText('Maximaal vier metrics kunnen tegelijk worden vergeleken.')).toBeVisible();
|
||||
expect(screen.getByRole('button', { name: /host.memory.utilization/ })).not.toHaveAttribute('aria-pressed', 'true');
|
||||
await user.click(screen.getByRole('button', { name: /host.cpu.utilization/ }));
|
||||
setOperatorCustomRange('2026-09-01T00:00', '2026-09-02T00:00');
|
||||
await user.click(screen.getByRole('button', { name: /host.memory.utilization/ }));
|
||||
await waitFor(() => expect(screen.getByText('Serverbudget overschreden')).toBeVisible());
|
||||
expect(calls.some((call) => call.metric === 'host.memory.utilization')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
@@ -64,6 +64,22 @@ describe('interactive widgets', () => {
|
||||
expect(document.body).not.toHaveTextContent(rawKey);
|
||||
});
|
||||
|
||||
it('supports series toggles, bounded zoom, a pointer crosshair and annotations', async () => {
|
||||
const user = userEvent.setup();
|
||||
const view = render(<MetricWidget {...props({ kind: 'timeseries', series: [
|
||||
{ key: 'tower', points: [{ ...freshPoint, timestamp: now - 30_000 }, freshPoint] },
|
||||
{ key: 'backup', points: [{ ...freshPoint, timestamp: now - 30_000, labels: { host: 'backup' } }, { ...freshPoint, labels: { host: 'backup' } }] },
|
||||
], annotations: [{ id: 'incident-1', label: 'Opslagincident', timestamp: now, tone: 'critical' }] })} />);
|
||||
const widget = within(view.container);
|
||||
const backup = widget.getByRole('button', { name: 'backup' });
|
||||
expect(backup).toHaveAttribute('aria-pressed', 'true');
|
||||
await user.click(backup);
|
||||
expect(backup).toHaveAttribute('aria-pressed', 'false');
|
||||
await user.click(widget.getByRole('button', { name: 'Zoom op recente helft' }));
|
||||
expect(widget.getByRole('button', { name: 'Volledige periode' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(widget.getByRole('list', { name: 'Incident- en meldingsannotaties' })).toHaveTextContent('Opslagincident');
|
||||
});
|
||||
|
||||
it('supports keyboard activation of ranked rows', async () => {
|
||||
const onSelect = vi.fn();
|
||||
render(<RankedListWidget items={[{ id: 'disk-1', label: 'Disk 1', value: '78%' }]} onSelect={onSelect} />);
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ApiProblemError } from '../../src/apiClient';
|
||||
import { RouteErrorBoundary } from '../../src/RouteErrorBoundary';
|
||||
|
||||
function BrokenRoute(): never { throw new ApiProblemError(500, { title: 'Fout' }, 'corr-route-7'); }
|
||||
|
||||
describe('RouteErrorBoundary', () => {
|
||||
it('keeps the shell alive and exposes correlation context', () => {
|
||||
render(<><nav aria-label="Applicatieshell">Navigatie</nav><RouteErrorBoundary resetKey="/broken"><BrokenRoute /></RouteErrorBoundary></>);
|
||||
expect(screen.getByRole('navigation', { name: 'Applicatieshell' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Deze pagina kon niet worden weergegeven' })).toBeInTheDocument();
|
||||
expect(screen.getByText(/corr-route-7/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ApiProblemError, requestJSON } from '../../src/apiClient';
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe('requestJSON', () => {
|
||||
it('preserves safe problem and correlation context', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ title: 'Begrensd', detail: 'Probeer een kortere periode.' }), { status: 422, headers: { 'content-type': 'application/problem+json', 'x-correlation-id': 'corr-123' } })));
|
||||
await expect(requestJSON('/api/v1/example')).rejects.toMatchObject({ status: 422, correlationId: 'corr-123', message: 'Probeer een kortere periode.' } satisfies Partial<ApiProblemError>);
|
||||
});
|
||||
|
||||
it('retries safe GET requests only within the configured bound', async () => {
|
||||
const fetcher = vi.fn().mockResolvedValueOnce(new Response('{}', { status: 503 })).mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'content-type': 'application/json' } }));
|
||||
vi.stubGlobal('fetch', fetcher);
|
||||
await expect(requestJSON<{ ok: boolean }>('/api/v1/example', { retries: 1 })).resolves.toEqual({ ok: true });
|
||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('never retries mutations', async () => {
|
||||
const fetcher = vi.fn().mockResolvedValue(new Response('{}', { status: 503, headers: { 'content-type': 'application/problem+json' } }));
|
||||
vi.stubGlobal('fetch', fetcher);
|
||||
await expect(requestJSON('/api/v1/example', { method: 'POST', retries: 2 })).rejects.toBeInstanceOf(ApiProblemError);
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([401, 403])('preserves authorization status %s without retry', async (status) => {
|
||||
const fetcher = vi.fn().mockResolvedValue(new Response(JSON.stringify({ title: 'Geen toegang', correlationId: 'corr-auth' }), { status, headers: { 'content-type': 'application/problem+json' } }));
|
||||
vi.stubGlobal('fetch', fetcher);
|
||||
await expect(requestJSON('/api/v1/protected', { retries: 2 })).rejects.toMatchObject({ status, correlationId: 'corr-auth' });
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('never retries an aborted request', async () => {
|
||||
const fetcher = vi.fn().mockRejectedValue(new DOMException('aborted', 'AbortError'));
|
||||
vi.stubGlobal('fetch', fetcher);
|
||||
await expect(requestJSON('/api/v1/example', { retries: 2 })).rejects.toMatchObject({ name: 'AbortError' });
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { movePlacement, normalizePlacement, overlaps, resizePlacement } from '../../src/dashboardSpatial';
|
||||
|
||||
describe('dashboard spatial layout', () => {
|
||||
it('clamps placements to the active column budget', () => {
|
||||
expect(normalizePlacement({ x: 17, y: -3, w: 6, h: 0 }, 'desktop')).toEqual({ x: 12, y: 0, w: 6, h: 1, visible: true });
|
||||
expect(normalizePlacement({ x: 4, y: 3, w: 4, h: 5 }, 'mobile')).toEqual({ x: 0, y: 3, w: 1, h: 5, visible: true });
|
||||
});
|
||||
|
||||
it('detects overlap and pushes a moved widget below collisions', () => {
|
||||
const occupied = [{ id: 'a', placement: { x: 0, y: 0, w: 6, h: 4, visible: true } }];
|
||||
expect(overlaps({ x: 0, y: 0, w: 2, h: 2 }, occupied[0].placement)).toBe(true);
|
||||
expect(movePlacement({ x: 6, y: 0, w: 6, h: 4 }, -6, 0, occupied, 'desktop')).toMatchObject({ x: 0, y: 4, w: 6, h: 4 });
|
||||
});
|
||||
|
||||
it('keeps resize within the viewport and resolves collisions', () => {
|
||||
const occupied = [{ id: 'a', placement: { x: 8, y: 0, w: 4, h: 4, visible: true } }];
|
||||
const resized = resizePlacement({ x: 0, y: 0, w: 6, h: 4 }, 6, 2, occupied, 'desktop');
|
||||
expect(resized.w).toBe(12);
|
||||
expect(resized.y).toBe(4);
|
||||
expect(resized.h).toBe(6);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildDashboardDocument, dashboardSlug } from '../../src/dashboardTemplates';
|
||||
|
||||
describe('dashboard templates', () => {
|
||||
it('creates a schema v2 blank document with required fields', () => {
|
||||
let index = 0;
|
||||
const document = buildDashboardDocument({ name: 'Mijn Test', scope: 'personal', template: 'blank', idFactory: () => `00000000-0000-4000-8000-${String(++index).padStart(12, '0')}` });
|
||||
expect(document.schemaVersion).toBe(2); expect(document.scope).toBe('personal'); expect(document.widgets).toEqual([]); expect(document.slug).toMatch(/^mijn-test-/);
|
||||
});
|
||||
it('only uses proven runtime sources in the operations template', () => {
|
||||
let index = 0;
|
||||
const document = buildDashboardDocument({ name: 'Operations', scope: 'shared', template: 'operations', idFactory: () => `10000000-0000-4000-8000-${String(++index).padStart(12, '0')}` });
|
||||
const widgets = document.widgets as Array<{ data?: { sourceType?: string } }>;
|
||||
expect(widgets.length).toBeGreaterThan(0); expect(new Set(widgets.map((widget) => widget.data?.sourceType))).toEqual(new Set(['inventory', 'semantic-metric', 'events']));
|
||||
});
|
||||
it('normalizes a unicode name to a valid bounded slug', () => { const slug = dashboardSlug('Opslag & Café / Test', 'ABC123'); expect(slug).toBe('opslag-cafe-test-abc123'); expect(slug.length).toBeLessThanOrEqual(80); });
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { historicalSamplesFromData } from '../../src/liveBuffer';
|
||||
|
||||
describe('historical metric boundary', () => {
|
||||
it('caps oversized upstream payloads before they enter chart state', () => {
|
||||
const values = Array.from({ length: 12_000 }, (_, index) => [1_700_000_000 + index, String(index)]);
|
||||
const result = historicalSamplesFromData({ result: Array.from({ length: 120 }, (_, index) => ({ metric: { host: `host-${index}` }, values })) });
|
||||
expect(result).toHaveLength(10_000);
|
||||
expect(new Set(result.map((sample) => sample.series)).size).toBe(1);
|
||||
expect(result[0].value).toBe(2000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { rangeForPreset, rangeForSelection } from '../../src/metricClient';
|
||||
|
||||
describe('metric range selection', () => {
|
||||
const now = new Date('2026-08-30T18:00:00.000Z');
|
||||
|
||||
it('supports the 30 day operator preset within the existing point budget', () => {
|
||||
const range = rangeForPreset('30d', now);
|
||||
expect(range.to).toBe(now.toISOString());
|
||||
expect(Date.parse(range.to) - Date.parse(range.from)).toBe(30 * 24 * 60 * 60 * 1000);
|
||||
expect(range.stepSeconds).toBeGreaterThanOrEqual(15);
|
||||
});
|
||||
|
||||
it('turns a valid custom selection into an exact UTC range', () => {
|
||||
const range = rangeForSelection('custom|2026-08-01T10:00:00.000Z|2026-08-02T10:00:00.000Z', now);
|
||||
expect(range.from).toBe('2026-08-01T10:00:00.000Z');
|
||||
expect(range.to).toBe('2026-08-02T10:00:00.000Z');
|
||||
});
|
||||
|
||||
it('fails closed to one hour when a custom range is invalid', () => {
|
||||
expect(rangeForSelection('custom|broken|2026-08-02T10:00:00.000Z', now)).toEqual(rangeForPreset('1h', now));
|
||||
});
|
||||
|
||||
it('uses bounded recent history while live transport is paused', () => {
|
||||
expect(rangeForSelection('paused', now)).toEqual(rangeForPreset('live', now));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
operatorCustomRangeValid,
|
||||
operatorRangeSelection,
|
||||
resetOperatorContextForTests,
|
||||
setOperatorCustomRange,
|
||||
setOperatorPaused,
|
||||
setOperatorRangePreset,
|
||||
} from '../../src/operatorContext';
|
||||
|
||||
afterEach(() => {
|
||||
resetOperatorContextForTests();
|
||||
window.localStorage.clear();
|
||||
window.history.replaceState({}, '', '/');
|
||||
});
|
||||
|
||||
describe('global operator context', () => {
|
||||
it('encodes a valid custom range in the shareable URL', () => {
|
||||
setOperatorCustomRange('2026-09-01T10:00', '2026-09-01T11:00');
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
expect(params.get('range')).toBe('custom');
|
||||
expect(params.get('from')).toBe(new Date('2026-09-01T10:00').toISOString());
|
||||
expect(params.get('to')).toBe(new Date('2026-09-01T11:00').toISOString());
|
||||
});
|
||||
|
||||
it('permits pause only for the live range', () => {
|
||||
setOperatorRangePreset('live');
|
||||
setOperatorPaused(true);
|
||||
expect(window.localStorage.getItem('pulse.operator.paused')).toBe('true');
|
||||
setOperatorRangePreset('1h');
|
||||
expect(window.localStorage.getItem('pulse.operator.paused')).toBe('false');
|
||||
});
|
||||
|
||||
it('fails closed to one hour when a custom range is invalid', () => {
|
||||
const value = { range: 'custom' as const, customFrom: '2026-09-01T11:00', customTo: '2026-09-01T10:00', paused: false, refreshVersion: 0 };
|
||||
expect(operatorCustomRangeValid(value)).toBe(false);
|
||||
expect(operatorRangeSelection(value)).toBe('1h');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
isFavorite,
|
||||
readWorkspaceState,
|
||||
recordRecent,
|
||||
removeSavedView,
|
||||
saveCurrentView,
|
||||
toggleFavorite,
|
||||
WORKSPACE_EVENT,
|
||||
} from '../../src/operatorWorkspace';
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('operator workspace preferences', () => {
|
||||
it('toggles favorites without duplicating the same path', () => {
|
||||
expect(toggleFavorite('Cache pool', '/pools/cache?range=24h')).toBe(true);
|
||||
expect(isFavorite('/pools/cache?range=24h')).toBe(true);
|
||||
expect(readWorkspaceState().favorites).toHaveLength(1);
|
||||
|
||||
expect(toggleFavorite('Cache pool', '/pools/cache?range=24h')).toBe(false);
|
||||
expect(isFavorite('/pools/cache?range=24h')).toBe(false);
|
||||
expect(readWorkspaceState().favorites).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('persists a saved view and removes it by exact route context', () => {
|
||||
saveCurrentView('Degraded services', '/services?state=degraded&range=24h');
|
||||
expect(readWorkspaceState().views).toEqual([
|
||||
expect.objectContaining({ label: 'Degraded services', path: '/services?state=degraded&range=24h', kind: 'view' }),
|
||||
]);
|
||||
|
||||
removeSavedView('/services?state=degraded&range=24h');
|
||||
expect(readWorkspaceState().views).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('keeps recent routes bounded, unique and excludes the 404 surface', () => {
|
||||
recordRecent('Missing', '/404');
|
||||
for (let index = 0; index < 15; index += 1) recordRecent('Item ' + index, '/inventory/' + index);
|
||||
recordRecent('Item 14 updated', '/inventory/14');
|
||||
|
||||
const recent = readWorkspaceState().recent;
|
||||
expect(recent).toHaveLength(12);
|
||||
expect(recent[0]).toMatchObject({ label: 'Item 14 updated', path: '/inventory/14', kind: 'recent' });
|
||||
expect(recent.filter((item) => item.path === '/inventory/14')).toHaveLength(1);
|
||||
expect(recent.some((item) => item.path === '/404')).toBe(false);
|
||||
});
|
||||
|
||||
it('fails closed on malformed storage and emits a workspace change event on writes', () => {
|
||||
window.localStorage.setItem('pulse.operator.workspace.v1', '{bad json');
|
||||
expect(readWorkspaceState()).toEqual({ favorites: [], views: [], recent: [] });
|
||||
|
||||
const listener = vi.fn();
|
||||
window.addEventListener(WORKSPACE_EVENT, listener);
|
||||
saveCurrentView('Overview', '/');
|
||||
window.removeEventListener(WORKSPACE_EVENT, listener);
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { readSessionCapabilities } from '../../src/sessionClient';
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
function json(value: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(value), { status, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
|
||||
describe('session capability client', () => {
|
||||
it('reads bounded identity, environment and effective permissions', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => json({
|
||||
subject: 'operator-42', role: 'editor', environment: 'development',
|
||||
permissions: { view: true, operate: true, edit: true, admin: false },
|
||||
})));
|
||||
|
||||
await expect(readSessionCapabilities()).resolves.toEqual({
|
||||
subject: 'operator-42', role: 'editor', environment: 'development',
|
||||
permissions: { view: true, operate: true, edit: true, admin: false }, source: 'session',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the legacy capability probe when the endpoint is absent', async () => {
|
||||
const fetch = vi.fn(async () => fetch.mock.calls.length === 1 ? json({}, 404) : json({}, 405));
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
|
||||
const result = await readSessionCapabilities();
|
||||
|
||||
expect(result).toMatchObject({ role: 'editor+', environment: '', permissions: { view: true, operate: true, edit: true, admin: false }, source: 'fallback' });
|
||||
expect(fetch).toHaveBeenNthCalledWith(2, '/api/v1/widgets/preview', expect.objectContaining({ method: 'GET', cache: 'no-store' }));
|
||||
});
|
||||
|
||||
it('preserves an explicit authentication failure', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => json({}, 401)));
|
||||
await expect(readSessionCapabilities()).rejects.toMatchObject({ status: 401 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fallbackWidgetCatalog, isWidgetType, readWidgetCatalog, widgetLabel, widgetTypes } from '../../src/widgetCatalog';
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe('widget catalog client', () => {
|
||||
it('keeps one bounded fourteen-type catalog including query inspector', () => {
|
||||
expect(widgetTypes).toHaveLength(14);
|
||||
expect(new Set(widgetTypes).size).toBe(14);
|
||||
expect(fallbackWidgetCatalog).toHaveLength(14);
|
||||
expect(widgetLabel('query-inspector')).toBe('Query-inspecteur');
|
||||
expect(isWidgetType('made-up')).toBe(false);
|
||||
});
|
||||
|
||||
it('normalizes the existing Go catalog shape and ignores unknown entries', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ items: [
|
||||
{ Type: 'stat', Capabilities: ['metrics'] }, { Type: 'made-up' }, { type: 'text', capabilities: ['safe-markdown'] },
|
||||
] }), { status: 200, headers: { 'Content-Type': 'application/json' } })));
|
||||
const catalog = await readWidgetCatalog();
|
||||
expect(catalog).toHaveLength(14);
|
||||
expect(catalog.find((item) => item.type === 'stat')?.capabilities).toEqual(['metrics']);
|
||||
expect(catalog.find((item) => item.type === 'text')?.capabilities).toEqual(['safe-markdown']);
|
||||
expect(catalog.some((item) => item.type === ('made-up' as never))).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user