fix(rd21): retain draft revisions and qualify conflict recovery

This commit is contained in:
cesnimda
2026-10-23 12:34:34 +02:00
parent 12f342c09c
commit 05a738693e
53 changed files with 813 additions and 192 deletions
@@ -0,0 +1,225 @@
import { test as base, expect, type APIRequestContext, type APIResponse, type TestInfo } from '../../../../../job-tracker-ui/node_modules/@playwright/test';
import { execFileSync } from 'node:child_process';
import path from 'node:path';
import { translations } from '../../../../../job-tracker-ui/src/i18n/translations';
import type { StructuredCvProfile } from '../../../../../job-tracker-ui/src/profileCv';
const api = 'http://localhost:5302/api';
const password = 'E2ePassword123!';
const t = translations.en;
type Career = { profile: StructuredCvProfile; revision: number; cvText: string | null };
type Job = { id: number; version: number; jobTitle: string; notes: string | null; [key: string]: unknown };
type Owner = { id: string; headers: Record<string, string> };
type DbRow = Record<string, unknown>;
type DbProof = { identity: DbRow; career: DbRow | null; careerHistory: DbRow[]; children: Record<string, DbRow[]>;
job: DbRow | null; opportunity: DbRow | null; events: DbRow[] };
async function json<T>(response: Pick<APIResponse, 'status' | 'url' | 'json'>, status = 200): Promise<T> {
expect(response.status(), `${response.url()} status`).toBe(status);
return response.json();
}
async function login(request: APIRequestContext, email: string): Promise<Owner> {
expect((await request.post(`${api}/auth/login`, { data: { email, password } })).status()).toBe(200);
const me = await json<{ id: string; sessionContext: string }>(await request.get(`${api}/auth/me`));
const csrf = (await request.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN');
expect(csrf).toBeDefined();
return { id: me.id, headers: { 'X-CSRF-TOKEN': csrf!.value, 'X-Session-Context': me.sessionContext } };
}
const test = base.extend<{ owner: Owner; second: { request: APIRequestContext; owner: Owner }; email: string }>({
email: async ({ playwright }, use, info) => {
const admin = await playwright.request.newContext();
try {
const adminOwner = await login(admin, 'e2e@example.test');
const email = `rd21-proof-${info.testId}-${Date.now()}@example.test`;
expect((await admin.post(`${api}/users`, { headers: adminOwner.headers, data: { email, password, roles: [] } })).status()).toBe(200);
await use(email);
} finally { await admin.dispose(); }
},
owner: async ({ page, context, email }, use) => {
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 page.addInitScript(() => localStorage.setItem('uiLanguage', 'en'));
await use(await login(page.request, email));
},
second: async ({ playwright, email }, use) => {
const request = await playwright.request.newContext();
try { await use({ request, owner: await login(request, email) }); }
finally { await request.dispose(); }
},
});
function db(info: TestInfo, owner: Owner, jobId?: number): DbProof {
const dataRoot = info.config.metadata.isolatedDataRoot;
expect(typeof dataRoot, 'Only the isolated test database may be inspected').toBe('string');
const args = ['proxy', 'python', path.join(__dirname, 'readonly-db-proof.py'), path.join(dataRoot, 'jobtracker.db'), owner.id];
if (jobId !== undefined) args.push(String(jobId));
return JSON.parse(execFileSync('rtk', args, { encoding: 'utf8' }));
}
async function attach(info: TestInfo, name: string, body: unknown) {
await info.attach(name, { body: JSON.stringify(body, null, 2), contentType: 'application/json' });
}
async function seedCareer(request: APIRequestContext, owner: Owner) {
const absent = await json<Career>(await request.get(`${api}/career/profile`));
expect(absent.revision).toBe(0);
return json<Career>(await request.put(`${api}/career/profile`, { headers: owner.headers, data: { expectedVersion: 0,
profile: { version: '1', contact: { fullName: 'Synthetic concurrent owner', phone: '+47 900 00 001' }, summary: ['Initial summary'],
jobs: [{ id: 'retained-experience', title: 'Recorded role', company: 'Synthetic employer', bullets: ['Retain this reviewed fact.'] }],
skills: ['C#'] }, cvText: 'Original synthetic source retained through edits.' } }));
}
function assertCareer(proof: DbProof, expected: Career) {
expect(proof.career?.Version).toBe(expected.revision);
const stored = JSON.parse(proof.career!.ProfileJson as string);
expect(stored.summary).toEqual(expected.profile.summary);
expect(stored.contact).toMatchObject({ fullName: expected.profile.contact.fullName, phone: expected.profile.contact.phone });
expect(stored.jobs).toHaveLength(expected.profile.jobs.length);
expect(stored.jobs[0]).toMatchObject({ id: 'retained-experience', title: 'Recorded role', company: 'Synthetic employer', bullets: ['Retain this reviewed fact.'] });
expect(JSON.parse(proof.identity.ProfileCvStructureJson as string)).toEqual(stored);
expect(proof.identity.ProfileCvText).toBe(expected.cvText);
expect(proof.careerHistory).toHaveLength(expected.revision);
expect(proof.careerHistory.at(-1)?.Version).toBe(expected.revision);
expect(JSON.parse(proof.careerHistory.at(-1)!.ProfileJson as string)).toEqual(stored);
expect(proof.children.CareerExperiences).toEqual([expect.objectContaining({ ItemKey: 'retained-experience', Title: 'Recorded role', OwnerUserId: proof.identity.Id })]);
}
async function createJob(request: APIRequestContext, owner: Owner) {
const company = await json<{ id: number }>(await request.post(`${api}/companies`, { headers: owner.headers, data: { name: 'Synthetic stale job fixture' } }), 201);
return json<Job>(await request.post(`${api}/jobapplications`, { headers: owner.headers,
data: { companyId: company.id, jobTitle: 'Original job title', status: 'Saved', notes: 'Retain original notes', location: 'Trondheim' } }), 201);
}
test('RD21 original two-session ordering rejects stale snapshots and preserves each acknowledged SQLite commit across navigation and a fresh session', async ({ page, owner, second, playwright, email }, info) => {
await seedCareer(page.request, owner);
const careerA = await json<Career>(await page.request.get(`${api}/career/profile`));
const careerB = await json<Career>(await second.request.get(`${api}/career/profile`));
careerA.profile.summary = ['First editor committed summary'];
careerB.profile.contact.headline = 'Second editor headline';
const acknowledged = await json<Career>(await page.request.put(`${api}/career/profile`, { headers: owner.headers,
data: { profile: careerA.profile, expectedVersion: careerA.revision } }));
const immediateCareer = db(info, owner);
assertCareer(immediateCareer, acknowledged);
const staleCareer = await second.request.put(`${api}/career/profile`, { headers: second.owner.headers,
data: { profile: careerB.profile, expectedVersion: careerB.revision } });
expect(staleCareer.status()).toBe(409);
expect(await staleCareer.json()).toMatchObject({ code: 'revision_conflict' });
expect(db(info, owner)).toEqual(immediateCareer);
expect((await json<Career>(await page.request.get(`${api}/career/profile`))).profile).toEqual(acknowledged.profile);
const created = await createJob(page.request, owner);
const jobA = await json<Job>(await page.request.get(`${api}/jobapplications/${created.id}`));
const jobB = await json<Job>(await second.request.get(`${api}/jobapplications/${created.id}`));
jobA.jobTitle = 'First editor committed title'; jobB.notes = 'Second editor notes';
const committed = await page.request.put(`${api}/jobapplications/${created.id}`, { headers: owner.headers, data: { ...jobA, expectedVersion: jobA.version } });
expect(committed.status()).toBe(204);
const committedRevision = Number(committed.headers()['x-job-version']);
expect(committedRevision).toBe(jobA.version + 1);
const immediateJob = db(info, owner, created.id);
assertCareer(immediateJob, acknowledged);
expect(immediateJob.job).toMatchObject({ Id: created.id, OwnerUserId: owner.id, Version: committedRevision,
JobTitle: jobA.jobTitle, Notes: 'Retain original notes', Location: 'Trondheim', IsDeleted: 0 });
expect(immediateJob.opportunity).toMatchObject({ Id: immediateJob.job!.JobId, OwnerUserId: owner.id,
JobTitle: jobA.jobTitle, Location: 'Trondheim', CompanyId: immediateJob.job!.CompanyId });
expect(immediateJob.events.length).toBeGreaterThan(0);
const staleJob = await second.request.put(`${api}/jobapplications/${created.id}`, { headers: second.owner.headers,
data: { ...jobB, expectedVersion: jobB.version } });
expect(staleJob.status()).toBe(409);
expect(await staleJob.json()).toMatchObject({ code: 'revision_conflict' });
expect(db(info, owner, created.id)).toEqual(immediateJob);
await page.goto(`/jobs/${created.id}?section=overview`);
await expect(page.getByRole('main')).toContainText(jobA.jobTitle);
expect(db(info, owner, created.id)).toEqual(immediateJob);
await page.reload();
await expect(page.getByRole('main')).toContainText(jobA.jobTitle);
expect(db(info, owner, created.id)).toEqual(immediateJob);
await page.goto('/career?section=profile');
await expect(page.getByLabel(t.profileCvStructuredSummary, { exact: true })).toHaveValue(acknowledged.profile.summary[0]);
expect(db(info, owner, created.id)).toEqual(immediateJob);
await page.reload();
await expect(page.getByLabel(t.profileCvStructuredSummary, { exact: true })).toHaveValue(acknowledged.profile.summary[0]);
expect(db(info, owner, created.id)).toEqual(immediateJob);
const fresh = await playwright.request.newContext();
try {
await login(fresh, email);
expect((await json<Career>(await fresh.get(`${api}/career/profile`))).profile).toEqual(acknowledged.profile);
expect(await json<Job>(await fresh.get(`${api}/jobapplications/${created.id}`))).toMatchObject({ jobTitle: jobA.jobTitle, version: committedRevision, notes: 'Retain original notes' });
expect(db(info, owner, created.id)).toEqual(immediateJob);
} finally { await fresh.dispose(); }
await attach(info, 'rd21-original-ordering-committed-database', { careerAcknowledgement: acknowledged, careerStaleStatus: staleCareer.status(),
immediateCareer, jobAcknowledgedStatus: committed.status(), committedRevision, jobStaleStatus: staleJob.status(), finalDatabase: immediateJob,
checkpoints: ['immediate Career acknowledgement', 'Career stale rejection', 'immediate job acknowledgement', 'job stale rejection', 'navigation', 'job refresh', 'Career navigation', 'Career refresh', 'new authenticated session'] });
});
test('RD21 missing revisions fail closed without changing canonical rows, Identity projection, job opportunity or history', async ({ page, owner }, info) => {
const career = await seedCareer(page.request, owner);
const job = await createJob(page.request, owner);
const before = db(info, owner, job.id);
const profile = structuredClone(career.profile); profile.summary = ['Unversioned replacement must not persist'];
const missingCareer = await page.request.put(`${api}/career/profile`, { headers: owner.headers, data: { profile } });
expect(missingCareer.status()).toBe(400);
expect(db(info, owner, job.id)).toEqual(before);
const negativeCareer = await page.request.put(`${api}/career/profile`, { headers: owner.headers, data: { profile, expectedVersion: -1 } });
expect(negativeCareer.status()).toBe(400);
expect(db(info, owner, job.id)).toEqual(before);
const missingJob = await page.request.put(`${api}/jobapplications/${job.id}`, { headers: owner.headers,
data: { ...job, jobTitle: 'Unversioned job replacement must not persist' } });
expect(missingJob.status()).toBe(409);
expect(db(info, owner, job.id)).toEqual(before);
await attach(info, 'rd21-missing-preconditions-database', { statuses: { career: missingCareer.status(), negativeCareer: negativeCareer.status(), job: missingJob.status() }, before, after: db(info, owner, job.id) });
});
test('RD21 native editor retains rejected and network-failed drafts until deliberate review and successful retry', async ({ page, owner, second }, info) => {
await seedCareer(page.request, owner);
await page.goto('/career?section=profile');
const summary = page.getByLabel(t.profileCvStructuredSummary, { exact: true });
await expect(summary).toHaveValue('Initial summary');
const localDraft = 'Retained draft reviewed after conflict';
await summary.fill(localDraft);
const remote = await json<Career>(await second.request.get(`${api}/career/profile`));
remote.profile.contact.phone = '+47 900 00 077';
const acknowledged = await json<Career>(await second.request.put(`${api}/career/profile`, { headers: second.owner.headers,
data: { profile: remote.profile, expectedVersion: remote.revision } }));
const beforeConflict = db(info, owner); assertCareer(beforeConflict, acknowledged);
const rejected = page.waitForResponse(response => response.url() === `${api}/career/profile` && response.request().method() === 'PUT');
await page.getByRole('button', { name: t.profileSaveChanges, exact: true }).click();
expect((await rejected).status()).toBe(409);
await expect(page.getByRole('main').getByRole('alert').filter({ hasText: t.revisionConflict })).toBeVisible();
await expect(summary).toHaveValue(localDraft);
expect(db(info, owner)).toEqual(beforeConflict);
// The explicit reload discards this editor snapshot; the retained draft is then reviewed against the new contact.
await page.reload();
await expect(page.getByLabel(t.profileCvContactPhone, { exact: true })).toHaveValue('+47 900 00 077');
await expect(summary).toHaveValue('Initial summary');
await summary.fill(localDraft);
const failedBodies: unknown[] = [];
await page.route('**/api/career/profile', async route => {
if (route.request().method() !== 'PUT') return route.continue();
failedBodies.push(route.request().postDataJSON());
return route.abort('failed');
});
const networkFailed = page.waitForEvent('requestfailed', request => request.url() === `${api}/career/profile` && request.method() === 'PUT');
await page.getByRole('button', { name: t.profileSaveChanges, exact: true }).click();
await networkFailed;
await expect(page.getByRole('main').getByRole('alert').filter({ hasText: t.profileUpdateFailed })).toBeVisible();
await expect(summary).toHaveValue(localDraft);
await expect(page.getByRole('button', { name: t.profileSaveChanges, exact: true })).toBeEnabled();
expect(failedBodies).toHaveLength(1);
expect(failedBodies[0]).toMatchObject({ expectedVersion: acknowledged.revision, profile: { summary: [localDraft], contact: { phone: '+47 900 00 077' } } });
expect(db(info, owner)).toEqual(beforeConflict);
await page.unroute('**/api/career/profile');
const retried = page.waitForResponse(response => response.url() === `${api}/career/profile` && response.request().method() === 'PUT');
await page.getByRole('button', { name: t.profileSaveChanges, exact: true }).click();
const retryResponse = await retried;
const recovered = await json<Career>(retryResponse);
const immediateRecovery = db(info, owner); assertCareer(immediateRecovery, recovered);
expect(recovered.revision).toBe(acknowledged.revision + 1);
expect(recovered.profile.summary).toEqual([localDraft]);
expect(recovered.profile.contact.phone).toBe('+47 900 00 077');
await page.reload();
await expect(summary).toHaveValue(localDraft);
expect(db(info, owner)).toEqual(immediateRecovery);
await attach(info, 'rd21-recoverable-native-editor', { rejectedStatus: 409, beforeConflict, failedBodies, recovered, immediateRecovery,
qualification: 'Real stale HTTP conflict and real successful SQLite commits; network failure is a controlled browser request abort.' });
});
@@ -0,0 +1,9 @@
import { defineConfig } from '../../../../../job-tracker-ui/node_modules/@playwright/test';
import base from '../../../../../job-tracker-ui/playwright.config';
import path from 'node:path';
if (!base.metadata?.isolatedDataRoot) throw new Error('RD21 persistence proof requires fresh isolated servers; unset PLAYWRIGHT_REUSE_SERVERS.');
const cwd = path.resolve(__dirname, '../../../../../job-tracker-ui');
const servers = Array.isArray(base.webServer) ? base.webServer : [base.webServer!];
export default defineConfig({ ...base, testDir: __dirname, testMatch: '*.spec.ts', retries: 0,
webServer: servers.map(server => ({ ...server, cwd })) });
@@ -0,0 +1,106 @@
"""Read only the explicitly named synthetic owner's committed SQLite rows."""
import json
from contextlib import closing
from pathlib import Path
import sqlite3
import sys
import tempfile
CHILDREN = ("CareerExperiences", "CareerEducations", "CareerSkills", "CareerProjects",
"CareerCertifications", "CareerLanguages")
def connect_readonly(filename):
path = Path(filename).resolve(strict=True)
connection = sqlite3.connect(path.as_uri() + "?mode=ro", uri=True)
connection.row_factory = sqlite3.Row
return connection
def snapshot(connection, owner, job_id=None):
def rows(sql, parameters):
return [dict(row) for row in connection.execute(sql, parameters)]
identities = rows('SELECT "Id", "ProfileCvText", "ProfileCvStructureJson", '
'"CurrentCvProfileVersion" FROM "AspNetUsers" WHERE "Id" = ?', (owner,))
if len(identities) != 1:
raise ValueError("Expected exactly one explicitly owned synthetic Identity row")
careers = rows('SELECT * FROM "CareerProfiles" WHERE "OwnerUserId" = ?', (owner,))
if len(careers) > 1:
raise ValueError("More than one canonical Career Profile for owner")
result = {
"identity": identities[0],
"career": careers[0] if careers else None,
"careerHistory": rows('SELECT * FROM "CareerProfileVersions" WHERE "OwnerUserId" = ? ORDER BY "Version", "Id"', (owner,)),
"children": {table: rows(f'SELECT * FROM "{table}" WHERE "OwnerUserId" = ? ORDER BY "SortOrder", "Id"', (owner,)) for table in CHILDREN},
"job": None, "opportunity": None, "events": [],
}
if job_id is not None:
jobs = rows('SELECT * FROM "JobApplications" WHERE "OwnerUserId" = ? AND "Id" = ?', (owner, job_id))
if len(jobs) != 1:
raise ValueError("Expected exactly one explicitly owned synthetic job")
result["job"] = jobs[0]
opportunities = rows('SELECT * FROM "Jobs" WHERE "OwnerUserId" = ? AND "Id" = ?', (owner, jobs[0]["JobId"]))
if len(opportunities) != 1:
raise ValueError("Expected the job's explicitly owned opportunity")
result["opportunity"] = opportunities[0]
result["events"] = rows('SELECT e.* FROM "JobEvents" e JOIN "JobApplications" j ON e."JobApplicationId" = j."Id" '
'WHERE j."OwnerUserId" = ? AND j."Id" = ? ORDER BY e."Id"', (owner, job_id))
return result
def self_test():
with tempfile.TemporaryDirectory(prefix="rd21-readonly-proof-") as directory:
filename = Path(directory) / "jobtracker.db"
with closing(sqlite3.connect(filename)) as writer, writer:
writer.executescript('''
CREATE TABLE AspNetUsers (Id TEXT, ProfileCvText TEXT, ProfileCvStructureJson TEXT, CurrentCvProfileVersion INTEGER);
INSERT INTO AspNetUsers VALUES ('owner', 'raw', '{}', 1), ('other', 'private', '{}', 1);
CREATE TABLE CareerProfiles (Id INTEGER, OwnerUserId TEXT, Version INTEGER, ProfileJson TEXT);
INSERT INTO CareerProfiles VALUES (1, 'owner', 1, '{}'), (2, 'other', 1, '{}');
CREATE TABLE CareerProfileVersions (Id INTEGER, OwnerUserId TEXT, Version INTEGER);
INSERT INTO CareerProfileVersions VALUES (1, 'owner', 1), (2, 'other', 1);
CREATE TABLE JobApplications (Id INTEGER, OwnerUserId TEXT, JobId INTEGER, Version INTEGER);
INSERT INTO JobApplications VALUES (1, 'owner', 10, 2), (2, 'other', 20, 3);
CREATE TABLE Jobs (Id INTEGER, OwnerUserId TEXT);
INSERT INTO Jobs VALUES (10, 'owner'), (20, 'other');
CREATE TABLE JobEvents (Id INTEGER, JobApplicationId INTEGER);
INSERT INTO JobEvents VALUES (1, 1), (2, 2);
''')
for table in CHILDREN:
writer.execute(f'CREATE TABLE "{table}" (Id INTEGER, OwnerUserId TEXT, SortOrder INTEGER)')
writer.executemany(f'INSERT INTO "{table}" VALUES (?, ?, ?)', [(1, "owner", 0), (2, "other", 0)])
with closing(connect_readonly(filename)) as reader:
before = snapshot(reader, "owner", 1)
assert before["career"]["Version"] == 1
assert before["job"]["Version"] == 2
assert before["opportunity"]["Id"] == 10
assert before["events"] == [{"Id": 1, "JobApplicationId": 1}]
assert all(len(rows) == 1 for rows in before["children"].values())
assert "private" not in json.dumps(before)
try:
reader.execute('UPDATE CareerProfiles SET Version = 99')
except sqlite3.OperationalError as error:
assert "readonly" in str(error).lower()
else:
raise AssertionError("Read-only URI allowed mutation")
assert snapshot(reader, "owner", 1) == before
try:
snapshot(reader, "owner", 2)
except ValueError:
pass
else:
raise AssertionError("Cross-owner job was returned")
print("readonly-db-proof self-test passed")
if __name__ == "__main__":
if sys.argv[1:] == ["--self-test"]:
self_test()
else:
if len(sys.argv) not in (3, 4):
raise SystemExit("Usage: readonly-db-proof.py DATABASE OWNER_ID [JOB_ID] | --self-test")
with closing(connect_readonly(sys.argv[1])) as connection:
connection.execute("BEGIN")
print(json.dumps(snapshot(connection, sys.argv[2], int(sys.argv[3]) if len(sys.argv) == 4 else None)))
@@ -0,0 +1,99 @@
import { test, expect, type APIRequestContext } from '../../../../../job-tracker-ui/node_modules/@playwright/test';
import os from 'node:os';
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();
const csrf = (await request.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value;
return { 'X-CSRF-TOKEN': csrf, 'X-Session-Context': me.sessionContext as string };
}
test('P9 bounded sustained reads with 250 jobs and long CV while using native workspaces', async ({ page, context, playwright }, info) => {
test.setTimeout(240000);
const admin = await playwright.request.newContext();
const latency: { worker: number; endpoint: string; ms: number; bytes: number; status: number }[] = [];
const navigation: object[] = [];
const cdp = await context.newCDPSession(page);
await cdp.send('Performance.enable');
try {
const adminHeaders = await login(admin, 'e2e@example.test');
const email = `p9-sustained-${Date.now()}@example.test`;
expect((await admin.post(`${api}/users`, { headers: adminHeaders, data: { email, password, roles: ['Premium'] } })).status()).toBe(200);
const headers = await login(page.request, email);
const company = await page.request.post(`${api}/companies`, { headers, data: { name: 'P9 bounded workload' } });
expect(company.status()).toBe(201); const companyId = (await company.json()).id;
for (let index = 0; index < 250; index++) expect((await page.request.post(`${api}/jobapplications`, { headers,
data: { companyId, jobTitle: `P9 bounded role ${index}`, status: 'Applied', dateApplied: new Date().toISOString() } })).status()).toBe(201);
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()}`) })) };
expect((await page.request.put(`${api}/career/profile`, { headers, data: { profile, expectedVersion: (await (await page.request.get(api + "/career/profile")).json()).revision } })).status()).toBe(200);
const savedProfile = (await (await page.request.get(`${api}/career/profile`)).json()).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, data: { name: 'P9 long workload CV', settings: { themeId: 'minimal', language: 'nb' } } });
expect(cv.status()).toBe(200); const cvId = (await cv.json()).id;
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();
});
const endpoints = ['/jobapplications/stats', '/jobapplications/board', '/career/profile', '/documents'];
const started = Date.now();
const readWorker = async (worker: number) => {
for (let index = 0; index < 120; index++) {
const endpoint = endpoints[index % endpoints.length]; const begin = Date.now();
const response = await page.request.get(`${api}${endpoint}`);
latency.push({ worker, endpoint, ms: Date.now() - begin, bytes: (await response.body()).length, status: response.status() });
expect(response.status(), endpoint).toBe(200);
if (endpoint.endsWith('/board')) expect(await response.json()).toHaveLength(250);
await new Promise(resolve => setTimeout(resolve, 500));
}
};
const interact = async () => {
for (let cycle = 0; cycle < 5; cycle++) for (const route of ['/dashboard', '/jobs?view=table', '/jobs?view=board', `/career/builder/${cvId}`]) {
const begin = Date.now(); await page.goto(route);
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);
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();
}
navigation.push({ cycle, route, readyMs: Date.now() - begin,
metrics: (await cdp.send('Performance.getMetrics')).metrics,
domElements: await page.locator('*').count() });
}
};
await Promise.all([readWorker(0), readWorker(1), interact()]);
const durationMs = Date.now() - started;
expect(durationMs).toBeGreaterThanOrEqual(60000);
expect(latency).toHaveLength(240);
expect((await (await page.request.get(`${api}/jobapplications/board`)).json())).toHaveLength(250);
expect((await (await page.request.get(`${api}/career/profile`)).json()).profile).toEqual(savedProfile);
const sorted = latency.map(row => row.ms).sort((a, b) => a - b);
await info.attach('phase9-sustained-performance', { body: JSON.stringify({ durationMs, fixtureJobs: 250, fixtureCareerRoles: 8,
readConcurrency: 2, reads: latency.length, p50Ms: sorted[Math.floor(sorted.length * .5)], p95Ms: sorted[Math.floor(sorted.length * .95)], maxMs: sorted.at(-1),
environment: { platform: os.platform(), cpus: os.cpus().length, totalMemoryBytes: os.totalmem(), browser: context.browser()?.version(), database: 'isolated SQLite', frontend: 'production static export' },
latency, navigation, limits: 'Finite paced two-reader workload plus 20 native navigations; no SLA/capacity, physical mobile, deployed Linux, model inference or server CPU attribution.' }, null, 2), contentType: 'application/json' });
} finally { await cdp.detach(); await admin.dispose(); }
});