Files
jobtrackingapp/docs/redesign/phase-9/p9-requal-01/performance/sustained.spec.ts
T
cesnimda 5ac11549b2
MariaDB persistence qualification / mariadb (push) Failing after 7s
test: harden database qualification startup and cleanup evidence
2026-10-24 14:04:57 +02:00

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()));
}
});