194 lines
13 KiB
TypeScript
194 lines
13 KiB
TypeScript
import { test, expect, type APIRequestContext } from '../../../../../job-tracker-ui/node_modules/@playwright/test';
|
|
import os from 'node:os';
|
|
import { performance } from 'node:perf_hooks';
|
|
import { durationMs, paceMs, waitUntil, summarize, assertMembership, persistedCareerProfile } from './metrics.mjs';
|
|
import { snapshot, sql } from './mariadb-proof.mjs';
|
|
|
|
const api = 'http://localhost:5302/api';
|
|
const password = 'E2ePassword123!';
|
|
async function login(request: APIRequestContext, email: string) {
|
|
expect((await request.post(`${api}/auth/login`, { data: { email, password } })).status()).toBe(200);
|
|
const me = await (await request.get(`${api}/auth/me`)).json();
|
|
return { id: me.id as string, headers: { 'X-CSRF-TOKEN': (await request.storageState()).cookies.find(c => c.name === 'XSRF-TOKEN')!.value,
|
|
'X-Session-Context': me.sessionContext as string } };
|
|
}
|
|
|
|
test('P9-REQUAL-01 real MariaDB: 15 minutes, 250 applications, two paced readers and native workspaces', async ({ page, context, playwright }, info) => {
|
|
const admin = await playwright.request.newContext();
|
|
const readers = await Promise.all([playwright.request.newContext(), playwright.request.newContext()]);
|
|
const cdp = await context.newCDPSession(page);
|
|
const latency: { worker: number; endpoint: string; ms: number; atMs: number; bytes?: number; status?: number; error?: string }[] = [];
|
|
const navigation: { index: number; route: string; atMs: number; readyMs: number; error?: string }[] = [];
|
|
const preflightNavigation: typeof navigation = [];
|
|
const telemetry: object[] = [];
|
|
const ids: number[] = [];
|
|
const evidence: Record<string, unknown> = { fixtureJobs: 250, fixtureCareerRoles: 8, readConcurrency: 2, paceMs, requiredDurationMs: durationMs,
|
|
database: info.config.metadata.mariaDbDatabase, latency, navigation, preflightNavigation, telemetry, ids, phase: 'setup',
|
|
environment: { platform: os.platform(), cpus: os.cpus().length, totalMemoryBytes: os.totalmem(), browser: context.browser()?.version(),
|
|
databaseProvider: 'MariaDB', frontend: 'prebuilt production static export' },
|
|
limits: 'Bounded local synthetic workload; no SLA, capacity, production, physical-device, model-inference or server CPU attribution claim. Host/Node/CDP telemetry is descriptive.' };
|
|
try {
|
|
const server = await sql('SELECT VERSION()');
|
|
expect(server).toMatch(/^12\.2\.2-MariaDB(?:-|$)/);
|
|
evidence.server = server;
|
|
const adminOwner = await login(admin, 'e2e@example.test');
|
|
const email = `p9-requal-perf-${Date.now()}@example.test`;
|
|
// A free synthetic account excludes creation-time model inference from this database/UI workload.
|
|
expect((await admin.post(`${api}/users`, { headers: adminOwner.headers, data: { email, password, roles: [] } })).status()).toBe(200);
|
|
const owner = await login(page.request, email);
|
|
evidence.owner = owner.id;
|
|
const company = await page.request.post(`${api}/companies`, { headers: owner.headers, data: { name: 'P9 requalification synthetic workload' } });
|
|
expect(company.status()).toBe(201);
|
|
const companyId = (await company.json()).id;
|
|
for (let index = 0; index < 250; index++) {
|
|
const response = await page.request.post(`${api}/jobapplications`, { headers: owner.headers,
|
|
data: { companyId, jobTitle: `P9 requalification role ${index}`, status: 'Applied', dateApplied: new Date().toISOString() } });
|
|
expect(response.status(), `seed application ${index}`).toBe(201);
|
|
ids.push((await response.json()).id);
|
|
}
|
|
const profile = { version: '1', contact: { fullName: 'Åse Synthetic', email: 'synthetic@example.test' }, summary: ['Documented accessible workflows.'],
|
|
jobs: Array.from({ length: 8 }, (_, index) => ({ id: `p9-role-${index}`, title: `Platform engineer ${index}`, company: 'Nordlys synthetic',
|
|
start: '2017-01', end: '2025-12', bullets: Array.from({ length: 6 }, (_, bullet) =>
|
|
`Evidence ${index}.${bullet}: ${'Documented reliable workflows. Dokumenterte pålitelige arbeidsflyter. '.repeat(3).trim()}`) })) };
|
|
const initialProfile = await (await page.request.get(`${api}/career/profile`)).json();
|
|
expect((await page.request.put(`${api}/career/profile`, { headers: owner.headers,
|
|
data: { expectedVersion: initialProfile.revision, profile } })).status()).toBe(200);
|
|
const savedResponse = await (await page.request.get(`${api}/career/profile`)).json();
|
|
const savedProfile = savedResponse.profile;
|
|
expect(savedProfile.jobs).toHaveLength(8);
|
|
expect(savedProfile.jobs.map((job: { start: string; end: string; bullets: string[] }) => ({ start: job.start, end: job.end, bullets: job.bullets })))
|
|
.toEqual(profile.jobs.map(job => ({ start: job.start, end: job.end, bullets: job.bullets })));
|
|
const cv = await page.request.post(`${api}/cv/variants`, { headers: owner.headers,
|
|
data: { name: 'P9 requalification long CV', settings: { themeId: 'minimal', language: 'nb' } } });
|
|
expect(cv.status()).toBe(200);
|
|
const cvId = (await cv.json()).id;
|
|
const before = await snapshot(owner.id);
|
|
evidence.before = before;
|
|
assertMembership(before.ids, ids);
|
|
expect(before.ownerCount).toBe(250); expect(before.deletedCount).toBe(0);
|
|
expect(before.revision).toBe(savedResponse.revision);
|
|
expect(JSON.parse(before.profile)).toEqual(persistedCareerProfile(savedProfile));
|
|
await Promise.all(readers.map(reader => login(reader, email)));
|
|
await page.addInitScript(() => localStorage.setItem('uiLanguage', 'en'));
|
|
await context.route('**/*', route => {
|
|
const url = new URL(route.request().url());
|
|
return ['localhost', '127.0.0.1'].includes(url.hostname) || ['data:', 'blob:', 'about:'].includes(url.protocol) ? route.continue() : route.abort();
|
|
});
|
|
await cdp.send('Performance.enable');
|
|
const endpoints = ['/jobapplications/stats', '/jobapplications/board', '/career/profile', '/documents'];
|
|
const routes = ['/dashboard', '/jobs?view=table', '/jobs?view=board', `/career/builder/${cvId}`];
|
|
evidence.phase = 'preflight';
|
|
for (const endpoint of endpoints) {
|
|
const response = await page.request.get(`${api}${endpoint}`, { timeout: 10000 });
|
|
try {
|
|
expect(response.status(), `preflight ${endpoint}`).toBe(200);
|
|
const body = await response.json();
|
|
if (endpoint.endsWith('/board')) assertMembership(body.map((job: { id: number }) => job.id), ids);
|
|
else if (endpoint.endsWith('/profile')) expect(body.profile).toEqual(savedProfile);
|
|
else if (endpoint.endsWith('/stats')) expect(body.active).toBe(250);
|
|
else expect(Array.isArray(body.items)).toBe(true);
|
|
} finally { await response.dispose(); }
|
|
}
|
|
let started = performance.now();
|
|
let deadline = 0;
|
|
const readWorker = async (worker: number) => {
|
|
let index = 0;
|
|
while (performance.now() < deadline) {
|
|
const endpoint = endpoints[index++ % endpoints.length];
|
|
const begin = performance.now();
|
|
const row: typeof latency[number] = { worker, endpoint, atMs: begin - started, ms: 0 };
|
|
try {
|
|
const response = await readers[worker].get(`${api}${endpoint}`, { timeout: 10000 });
|
|
try {
|
|
row.status = response.status(); row.bytes = (await response.body()).length;
|
|
expect(row.status, endpoint).toBe(200);
|
|
if (endpoint.endsWith('/board')) assertMembership((await response.json()).map((job: { id: number }) => job.id), ids);
|
|
if (endpoint.endsWith('/profile')) expect((await response.json()).profile).toEqual(savedProfile);
|
|
} finally { await response.dispose(); }
|
|
} catch (error) { row.error = String(error); }
|
|
row.ms = performance.now() - begin;
|
|
latency.push(row);
|
|
// Completion-to-start pacing avoids catch-up bursts after a slow response.
|
|
await waitUntil(Math.min(deadline, performance.now() + paceMs));
|
|
}
|
|
};
|
|
const interact = async (preflight = false) => {
|
|
for (let index = 0; index < (preflight ? 4 : 20); index++) {
|
|
if (!preflight) await waitUntil(started + index * durationMs / 20);
|
|
const route = routes[index % routes.length];
|
|
const begin = performance.now();
|
|
const row: typeof navigation[number] = { index, route, atMs: begin - started, readyMs: 0 };
|
|
try {
|
|
await page.goto(route, { timeout: 20000 });
|
|
if (route === '/dashboard') await expect(page.getByRole('link', { name: 'Tracked jobs: 250', exact: true })).toBeVisible();
|
|
else if (route.endsWith('board')) {
|
|
await expect(page.locator('[data-kanban-card-id]')).toHaveCount(250);
|
|
assertMembership(await page.locator('[data-kanban-card-id]').evaluateAll(elements => elements.map(element => Number(element.getAttribute('data-kanban-card-id')))), ids);
|
|
} else if (route.endsWith('table')) await expect(page.locator('[data-job-row-id]').first()).toBeVisible();
|
|
else {
|
|
await expect(page.getByRole('region', { name: 'Live preview', exact: true })).toHaveAttribute('aria-busy', 'false');
|
|
const measured = await page.getByTitle('CV preview', { exact: true }).evaluate(element => {
|
|
const doc = (element as HTMLIFrameElement).contentDocument!;
|
|
const pages = Number(doc.documentElement.dataset.cvPreviewPages);
|
|
const width = Number(doc.documentElement.dataset.cvPreviewWidth);
|
|
const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT);
|
|
let node; let finalMarkerPage = 0;
|
|
while ((node = walker.nextNode())) {
|
|
const index = node.textContent?.indexOf('Evidence 7.5:') ?? -1;
|
|
if (index < 0) continue;
|
|
const range = doc.createRange(); range.setStart(node, index); range.setEnd(node, index + 'Evidence 7.5:'.length);
|
|
finalMarkerPage = Math.floor(range.getClientRects()[0].x / width) + 1;
|
|
}
|
|
return { pages, finalMarkerPage, ready: doc.documentElement.dataset.cvPreviewReady };
|
|
});
|
|
expect(measured.ready).toBe('true'); expect(measured.pages).toBeGreaterThan(1);
|
|
expect(measured.finalMarkerPage).toBe(measured.pages);
|
|
await expect(page.locator('[data-cv-page]')).toHaveCount(measured.pages);
|
|
await expect(page.getByTitle('CV preview page 1', { exact: true })).toBeVisible();
|
|
}
|
|
telemetry.push({ phase: preflight ? 'preflight' : 'workload', atMs: performance.now() - started, metrics: (await cdp.send('Performance.getMetrics')).metrics,
|
|
domElements: await page.locator('*').count(), hostFreeMemoryBytes: os.freemem(), hostLoadAverage: os.loadavg(),
|
|
nodeMemoryBytes: process.memoryUsage(), nodeCpuMicroseconds: process.cpuUsage() });
|
|
} catch (error) { row.error = String(error); }
|
|
row.readyMs = performance.now() - begin;
|
|
(preflight ? preflightNavigation : navigation).push(row);
|
|
if (preflight) expect(row.error, `preflight ${route}`).toBeUndefined();
|
|
}
|
|
};
|
|
await interact(true);
|
|
evidence.preflightPassed = true;
|
|
evidence.phase = 'workload';
|
|
started = performance.now();
|
|
deadline = started + durationMs;
|
|
evidence.startedUtc = new Date().toISOString();
|
|
await Promise.all([readWorker(0), readWorker(1), interact()]);
|
|
evidence.actualDurationMs = performance.now() - started;
|
|
evidence.finishedUtc = new Date().toISOString();
|
|
evidence.phase = 'preservation';
|
|
expect(evidence.actualDurationMs).toBeGreaterThanOrEqual(durationMs);
|
|
const after = await snapshot(owner.id);
|
|
evidence.after = after;
|
|
assertMembership(after.ids, ids);
|
|
expect(after).toEqual(before);
|
|
const finalBoard = await page.request.get(`${api}/jobapplications/board`);
|
|
expect(finalBoard.status()).toBe(200);
|
|
assertMembership((await finalBoard.json()).map((job: { id: number }) => job.id), ids);
|
|
expect((await (await page.request.get(`${api}/career/profile`)).json()).profile).toEqual(savedProfile);
|
|
expect(summarize(latency).failures).toBe(0);
|
|
expect(navigation).toHaveLength(20); expect(navigation.filter(row => row.error)).toEqual([]);
|
|
for (const worker of [0, 1]) {
|
|
const rows = latency.filter(row => row.worker === worker);
|
|
expect(rows.length).toBeGreaterThan(0);
|
|
for (let index = 1; index < rows.length; index++) expect(rows[index].atMs - rows[index - 1].atMs).toBeGreaterThanOrEqual(paceMs);
|
|
expect(rows.at(-1)!.atMs).toBeGreaterThan(durationMs - 15000);
|
|
}
|
|
evidence.phase = 'complete';
|
|
} finally {
|
|
evidence.summary = summarize(latency);
|
|
evidence.byEndpoint = Object.fromEntries([...new Set(latency.map(row => row.endpoint))].map(endpoint => [endpoint, summarize(latency.filter(row => row.endpoint === endpoint))]));
|
|
evidence.navigationFailures = navigation.filter(row => row.error).length;
|
|
await info.attach('p9-requal-01-mariadb-performance', { body: JSON.stringify(evidence, null, 2), contentType: 'application/json' });
|
|
await cdp.detach(); await admin.dispose(); await Promise.all(readers.map(reader => reader.dispose()));
|
|
}
|
|
});
|