Files
jobtrackingapp/job-tracker-ui/e2e/phase7-cv-library.spec.ts

481 lines
36 KiB
TypeScript

import { expect, test as base, type APIRequestContext, type APIResponse, type Page, type Response, type TestInfo } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
import { translations } from '../src/i18n/translations';
import type { CvTheme, CvVariant, CvVariantSummary } from '../src/cvBuilder';
import { emptyStructuredCv } from '../src/profileCv';
const apiUrl = 'http://localhost:5302/api';
const password = 'E2ePassword123!';
type Owner = { id: string; headers: Record<string, string> };
type Sample = { language: 'en' | 'nb'; theme: 'light' | 'dark'; width: number; height: number };
const desktop: Sample = { language: 'en', theme: 'light', width: 1440, height: 900 };
function settings(themeId: string) {
return { themeId, showPhoto: false, showPageNumbers: false, showIcons: true, sections: [], overrides: {}, customSections: [] };
}
async function json<T>(response: APIResponse | Response, status = 200): Promise<T> {
expect(response.status(), `${response.url()} must return ${status}`).toBe(status);
return response.json();
}
async function login(api: APIRequestContext, email: string): Promise<Owner> {
expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200);
const me = await json<{ id: string; sessionContext: string }>(await api.get(`${apiUrl}/auth/me`));
const csrf = (await api.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 } };
}
async function createOwner(admin: APIRequestContext, owner: Owner) {
const email = `p7-cv-library-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`;
expect((await admin.post(`${apiUrl}/users`, { headers: owner.headers, data: { email, password, roles: [] } })).status()).toBe(200);
return email;
}
const test = base.extend<{ owner: Owner }>({
owner: async ({ page, context, playwright }, provide, info) => {
expect(process.env.PLAYWRIGHT_REUSE_SERVERS).not.toBe('true');
expect(info.config.metadata.isolatedDataRoot).toEqual(expect.any(String));
const admin = await playwright.request.newContext();
try {
const administrator = await login(admin, 'e2e@example.test');
const owner = await login(page.request, await createOwner(admin, administrator));
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 provide(owner);
} finally { await admin.dispose(); }
},
});
async function seedSource(page: Page, owner: Owner) {
const profile = { ...emptyStructuredCv(), contact: { ...emptyStructuredCv().contact,
fullName: 'Åse Nordlys — recorded owner-reviewed professional identity', headline: 'Recorded professional context', email: 'recorded-contact@example.test' },
jobs: [{ id: 'library-source-job', title: 'Recorded engineer', company: 'Nordlys AS', bullets: ['Recorded source evidence.'], skills: ['C#'] }],
summary: ['Reviewed canonical summary, kept separate from CV-specific presentation.'] };
await json(await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers,
data: { profile, cvText: 'Exact owner-reviewed source — æøå\nRecorded work.\n' } }));
return readSource(page.request);
}
async function readSource(api: APIRequestContext) {
return { canonical: await json<unknown>(await api.get(`${apiUrl}/career/profile`)),
history: await json<unknown[]>(await api.get(`${apiUrl}/career/profile/versions`)) };
}
async function createVariant(api: APIRequestContext, owner: Owner, name: string, jobApplicationId?: number) {
return json<CvVariant>(await api.post(`${apiUrl}/cv/variants`, { headers: owner.headers,
data: { name, jobApplicationId, settings: { ...settings('minimal'), language: 'nb-NO',
overrides: { 'library-source-job': { title: 'CV-specific reviewed title', bullets: ['CV-specific reviewed evidence.'] } } } } }));
}
async function enter(page: Page, owner: Owner, sample: Sample = desktop) {
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers: owner.headers,
data: { language: sample.language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204);
await page.addInitScript(({ language, theme }) => {
if (localStorage.getItem('uiLanguage') === null) localStorage.setItem('uiLanguage', language);
if (localStorage.getItem('jobtracker.themeMode') === null) localStorage.setItem('jobtracker.themeMode', theme);
}, sample);
await page.setViewportSize({ width: sample.width, height: sample.height });
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto('/career/builder?source=phase7-library#preserved');
const stored = await page.evaluate(() => ({ language: localStorage.getItem('uiLanguage'), theme: localStorage.getItem('jobtracker.themeMode') }));
if (stored.language !== sample.language || stored.theme !== sample.theme) {
await page.evaluate(({ language, theme }) => {
localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', theme);
}, sample);
await page.reload();
}
await expect(page.locator('html')).toHaveAttribute('lang', sample.language === 'nb' ? 'nb-NO' : 'en');
await expect(page.locator('html')).toHaveAttribute('data-color-scheme', sample.theme);
await expect(page.getByRole('heading', { level: 1 })).toHaveCount(1);
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
await expect(page.getByRole('heading', { level: 2, name: translations[sample.language].cvDashboardLibraryTitle, exact: true })).toBeVisible();
}
function card(page: Page, name: string, language: Sample['language'] = 'en') {
return page.getByRole('link', { name: translations[language].cvDashboardOpenNamed.replace('{name}', name), exact: true });
}
function actionButton(page: Page, name: string, language: Sample['language'] = 'en') {
return page.getByRole('button', { name: translations[language].cvDashboardActionsFor.replace('{name}', name), exact: true });
}
async function openAction(page: Page, name: string, action: string, language: Sample['language'] = 'en') {
await actionButton(page, name, language).focus();
await page.keyboard.press('Enter');
const menu = page.getByRole('menu');
await expect(menu).toBeVisible();
await menu.getByRole('menuitem', { name: action, exact: true }).focus();
await page.keyboard.press('Enter');
}
async function openCreate(page: Page, language: Sample['language'] = 'en') {
const t = translations[language];
await page.getByRole('button', { name: t.cvDashboardNewCv, exact: true }).click();
const dialog = page.getByRole('dialog', { name: t.cvDashboardCreateTitle, exact: true });
await expect(dialog).toBeVisible();
return dialog;
}
async function capture(page: Page, info: TestInfo, sample: Sample, state: string) {
await page.evaluate(async () => { await document.fonts.ready; await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); });
const geometry = await page.evaluate(() => ({ width: innerWidth, height: innerHeight,
rootOverflow: Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth),
overflowElements: [...document.querySelectorAll('body *')]
.filter(element => element.getClientRects().length > 0 && getComputedStyle(element).visibility !== 'hidden' && !element.closest('[hidden],[aria-hidden="true"]'))
.map(element => { const rect = element.getBoundingClientRect(); return { tag: element.tagName, text: (element.textContent ?? '').slice(0, 100), left: rect.left, right: rect.right, width: rect.width }; })
.filter(element => element.width > 0 && (element.left < -1 || element.right > innerWidth + 1)).slice(0, 20),
}));
const axe = await new AxeBuilder({ page }).options({ rules: { 'target-size': { enabled: true } } }).analyze();
const name = `p7-cv-library-${sample.language}-${sample.theme}-${sample.width}-${state}`;
await info.attach(`${name}-audit`, { body: JSON.stringify({ sample, state, geometry, ariaSnapshot: await page.locator('body').ariaSnapshot(),
axe: { scope: 'All enabled rules plus target-size; complete findings retained', violations: axe.violations, incomplete: axe.incomplete,
passes: axe.passes.map(rule => ({ id: rule.id, nodes: rule.nodes.length })) },
qualifications: ['Automated Chromium only; incomplete axe findings require triage and do not establish human screen-reader acceptance.',
'No physical touch/software keyboard, actual browser zoom, production font/photo, print/PDF fidelity, model/provider or production acceptance.'],
}), contentType: 'application/json' });
await info.attach(name, { body: await page.screenshot({ fullPage: true, animations: 'disabled' }), contentType: 'image/png' });
expect(geometry.rootOverflow).toBe(0);
expect(axe.violations).toEqual([]);
}
test('[P7-04 PRES-C05 E08] failed list has an explicit keyboard retry with held progress, stable focus and actual recovery', async ({ page, owner }, info) => {
const sourceBefore = await seedSource(page, owner);
const existing = await createVariant(page.request, owner, 'Recorded recovery CV');
let reads = 0;
let release!: () => void;
const held = new Promise<void>(resolve => { release = resolve; });
await page.route('**/api/cv/variants', async route => {
if (route.request().method() !== 'GET') return route.continue();
reads++;
if (reads === 1) return route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ message: 'Controlled library read unavailable' }) });
if (reads === 2) await held;
return route.continue();
});
await enter(page, owner);
await expect(page.getByText('Controlled library read unavailable', { exact: true })).toBeVisible();
const retry = page.getByRole('button', { name: translations.en.retry, exact: true });
const content = page.getByRole('region', { name: translations.en.cvDashboardTitle, exact: true });
try {
await retry.focus(); await page.keyboard.press('Enter');
await expect.poll(() => reads).toBe(2);
await expect(content).toBeFocused();
await expect(page.getByRole('progressbar', { name: translations.en.loading, exact: true })).toBeVisible();
await expect(content.getByRole('status')).toHaveText(translations.en.loading);
await expect(content.getByRole('status')).toHaveAttribute('aria-live', 'polite');
await page.keyboard.press('Enter');
expect(reads).toBe(2);
} finally { release(); }
await expect(card(page, existing.name)).toBeVisible();
await expect(page.getByText('Controlled library read unavailable', { exact: true })).toHaveCount(0);
await expect(content).toBeFocused();
expect(reads).toBe(2);
expect(await readSource(page.request)).toEqual(sourceBefore);
await capture(page, info, desktop, 'list-recovered');
await info.attach('library-list-recovery', { body: JSON.stringify({ reads, qualification: 'First read is a controlled500; explicit retry is held before continuing the actual HTTP GET. No successful CV response is fabricated.' }), contentType: 'application/json' });
});
test('[P7-04 PRES-C05 C08 E08] locale-retained create draft locks during held failure and retries one identical snapshot', async ({ page, owner }, info) => {
test.setTimeout(90_000);
const sourceBefore = await seedSource(page, owner);
const themes = await json<CvTheme[]>(await page.request.get(`${apiUrl}/cv/themes`));
const selected = themes.find(theme => theme.id === 'minimal' && theme.available)!;
expect(selected).toBeDefined();
let listReads = 0;
let themeReads = 0;
const payloads: unknown[] = [];
let release!: () => void;
const held = new Promise<void>(resolve => { release = resolve; });
await page.route('**/api/cv/variants', async route => {
if (route.request().method() === 'GET') { listReads++; return route.continue(); }
if (route.request().method() !== 'POST') return route.continue();
payloads.push(route.request().postDataJSON());
if (payloads.length === 1) {
await held;
return route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ message: 'Controlled create unavailable' }) });
}
return route.continue();
});
await page.route('**/api/cv/themes', route => { themeReads++; return route.continue(); });
await enter(page, owner, { ...desktop, width: 360, height: 844 });
await expect(page.getByText(translations.en.cvDashboardEmptyTitle, { exact: true })).toBeVisible();
const name = 'Backendutvikler — Åse sin gjennomgåtte CV med en lang, beskrivende tittel';
let dialog = await openCreate(page);
await dialog.getByLabel(translations.en.cvEditorName, { exact: false }).fill(name);
await dialog.getByRole('button', { name: selected.name, exact: true }).focus(); await page.keyboard.press('Enter');
await expect(dialog.getByRole('button', { name: selected.name, exact: true })).toHaveAttribute('aria-pressed', 'true');
await dialog.getByRole('button', { name: translations.en.cancel, exact: true }).click();
const readsBeforeLocale = { listReads, themeReads };
const saved = page.waitForResponse(response => response.url() === `${apiUrl}/auth/preferences/language` && response.request().method() === 'PUT');
await page.locator('header').getByRole('button', { name: 'Norsk', exact: true }).click();
expect((await saved).status()).toBe(204);
await expect(page.locator('html')).toHaveAttribute('lang', 'nb-NO');
dialog = await openCreate(page, 'nb');
await expect(dialog.getByLabel(translations.nb.cvEditorName, { exact: false })).toHaveValue(name);
await expect(dialog.getByRole('button', { name: selected.name, exact: true })).toHaveAttribute('aria-pressed', 'true');
expect({ listReads, themeReads }).toEqual(readsBeforeLocale);
try {
await dialog.getByRole('button', { name: translations.nb.cvDashboardCreateCv, exact: true }).focus(); await page.keyboard.press('Enter');
await expect.poll(() => payloads.length).toBe(1);
await expect(dialog.getByLabel(translations.nb.cvEditorName, { exact: false })).toHaveAttribute('required', '');
await expect(dialog.getByLabel(translations.nb.cvEditorName, { exact: false })).toBeDisabled();
await expect(dialog.getByRole('button', { name: translations.nb.cancel, exact: true })).toBeDisabled();
await expect(dialog.getByRole('button', { name: translations.nb.cvEditorAtsTemplates, exact: true })).toBeDisabled();
await expect(dialog.getByRole('button', { name: selected.name, exact: true })).toHaveAttribute('aria-disabled', 'true');
await page.keyboard.press('Enter'); await page.keyboard.press('Escape');
await expect(dialog).toBeVisible(); expect(payloads).toHaveLength(1);
const content = dialog.getByRole('region', { name: translations.nb.cvDashboardCreateTitle, exact: true });
await page.keyboard.press('Tab');
await expect(content).toBeFocused();
const scrollBefore = await content.evaluate(element => ({ top: element.scrollTop, height: element.clientHeight, scrollHeight: element.scrollHeight }));
if (scrollBefore.scrollHeight > scrollBefore.height + 1) {
await page.keyboard.press(scrollBefore.top > 0 ? 'PageUp' : 'PageDown');
if (scrollBefore.top > 0) await expect.poll(() => content.evaluate(element => element.scrollTop)).toBeLessThan(scrollBefore.top);
else await expect.poll(() => content.evaluate(element => element.scrollTop)).toBeGreaterThan(scrollBefore.top);
}
await info.attach('library-held-create-keyboard-scroll', { body: JSON.stringify({ scrollBefore,
scrollAfter: await content.evaluate(element => element.scrollTop),
qualification: 'Actual Tab reaches the named pending-dialog content; PageUp/PageDown is exercised when this viewport has vertical content overflow. Browser keyboard evidence does not establish physical touch/software-keyboard or human screen-reader acceptance.' }), contentType: 'application/json' });
await capture(page, info, { language: 'nb', theme: 'light', width: 360, height: 844 }, 'held-create');
} finally { release(); }
await expect(dialog.getByText('Controlled create unavailable', { exact: true })).toBeVisible();
await expect(dialog.getByLabel(translations.nb.cvEditorName, { exact: false })).toHaveValue(name);
await expect(dialog.getByRole('button', { name: selected.name, exact: true })).toHaveAttribute('aria-pressed', 'true');
const createdResponse = page.waitForResponse(response => response.url() === `${apiUrl}/cv/variants` && response.request().method() === 'POST');
await dialog.getByRole('button', { name: translations.nb.cvDashboardCreateCv, exact: true }).click();
const created = await json<CvVariant>(await createdResponse);
await expect(page).toHaveURL(new RegExp(`/career/builder/${created.id}(?:[?#]|$)`));
expect(payloads).toHaveLength(2); expect(payloads[1]).toEqual(payloads[0]);
expect(created).toMatchObject({ name, isPublic: false, version: 1, settings: { themeId: 'minimal' } });
expect(await json<CvVariant>(await page.request.get(`${apiUrl}/cv/variants/${created.id}`))).toEqual(created);
expect(await readSource(page.request)).toEqual(sourceBefore);
await info.attach('library-create-recovery', { body: JSON.stringify({ readsBeforeLocale, listReads, themeReads, payloads, created,
qualification: 'Locale is changed through the real header after cancelling the draft dialog. First create is a controlled500; only explicit retry reaches actual HTTP creation. No model/provider or source write.' }), contentType: 'application/json' });
});
test('[P7-04 PRES-C05 C07 C10 W07] keyboard rename duplicate and delete persist without changing source, settings or frozen packages', async ({ page, owner }, info) => {
test.setTimeout(90_000);
const sourceBefore = await seedSource(page, owner);
const company = await json<{ id: number }>(await page.request.post(`${apiUrl}/companies`, { headers: owner.headers, data: { name: 'Recorded library fixture AS' } }), 201);
const job = await json<{ id: number }>(await page.request.post(`${apiUrl}/jobapplications`, { headers: owner.headers,
data: { companyId: company.id, jobTitle: 'Recorded library association', status: 'Saved' } }), 201);
const original = await createVariant(page.request, owner, 'Recorded original CV', job.id);
expect((await page.request.put(`${apiUrl}/jobapplications/${job.id}/cv`, { headers: owner.headers, data: { variantId: original.id } })).status()).toBe(200);
const frozen = await json<{ id: number }>(await page.request.post(`${apiUrl}/jobapplications/${job.id}/submitted-packages`, { headers: owner.headers }));
const frozenUrl = `${apiUrl}/jobapplications/${job.id}/submitted-packages/${frozen.id}`;
const frozenBefore = await json<unknown>(await page.request.get(frozenUrl));
await enter(page, owner);
const renamedName = 'Recorded renamed CV — explicit owner review';
await openAction(page, original.name, translations.en.cvDashboardRename);
const prompt = page.getByRole('dialog');
await expect(prompt.getByRole('textbox')).toBeFocused();
await prompt.getByRole('textbox').fill(renamedName);
const renamedResponse = page.waitForResponse(response => response.url() === `${apiUrl}/cv/variants/${original.id}` && response.request().method() === 'PUT');
await prompt.getByRole('button', { name: translations.en.cvDashboardRename, exact: true }).click();
const renameResponse = await renamedResponse;
const renamed = await json<CvVariant>(renameResponse);
expect(renameResponse.request().postDataJSON()).toMatchObject({ name: renamedName, source: 'manual', expectedVersion: original.version, settings: original.settings });
expect(renamed.settings).toEqual(original.settings); expect(renamed.version).toBe(original.version + 1);
await expect(card(page, renamedName)).toBeVisible();
await expect(actionButton(page, renamedName)).toBeFocused();
let duplicateWrites = 0;
let release!: () => void;
const held = new Promise<void>(resolve => { release = resolve; });
await page.route(`**/api/cv/variants/${original.id}/duplicate`, async route => { duplicateWrites++; await held; return route.continue(); });
const duplicatedResponse = page.waitForResponse(response => response.url() === `${apiUrl}/cv/variants/${original.id}/duplicate` && response.request().method() === 'POST');
try {
await openAction(page, renamedName, translations.en.cvEditorDuplicate);
await expect.poll(() => duplicateWrites).toBe(1);
await expect(actionButton(page, renamedName)).toBeDisabled();
await page.keyboard.press('Enter');
expect(duplicateWrites).toBe(1);
} finally { release(); }
const copy = await json<CvVariant>(await duplicatedResponse);
expect(copy).toMatchObject({ name: `${renamedName} (copy)`, version: 1, isPublic: false, jobApplicationId: job.id });
expect(copy.id).not.toBe(original.id); expect(copy.publicSlug).not.toBe(original.publicSlug); expect(copy.settings).toEqual(renamed.settings);
await expect(card(page, copy.name)).toBeVisible();
await openAction(page, copy.name, translations.en.cvDashboardDelete);
const confirmation = page.getByRole('dialog');
await expect(confirmation.getByRole('button', { name: translations.en.cancel, exact: true })).toBeFocused();
await page.keyboard.press('Enter');
await expect(confirmation).toHaveCount(0);
await expect(actionButton(page, copy.name)).toBeFocused();
expect((await page.request.get(`${apiUrl}/cv/variants/${copy.id}`)).status()).toBe(200);
await openAction(page, copy.name, translations.en.cvDashboardDelete);
const deletedResponse = page.waitForResponse(response => response.url() === `${apiUrl}/cv/variants/${copy.id}` && response.request().method() === 'DELETE');
await page.getByRole('dialog').getByRole('button', { name: translations.en.cvDashboardDeleteCv, exact: true }).focus(); await page.keyboard.press('Enter');
expect((await deletedResponse).status()).toBe(204);
await expect(card(page, copy.name)).toHaveCount(0);
await expect(page.getByRole('region', { name: translations.en.cvDashboardTitle, exact: true })).toBeFocused();
expect((await page.request.get(`${apiUrl}/cv/variants/${copy.id}`)).status()).toBe(404);
expect(await json<unknown[]>(await page.request.get(`${apiUrl}/cv/variants/${copy.id}/versions`))).toEqual([]);
await page.reload();
await expect(card(page, renamedName)).toBeVisible(); await expect(card(page, copy.name)).toHaveCount(0);
expect(await json<CvVariant>(await page.request.get(`${apiUrl}/cv/variants/${original.id}`))).toEqual(renamed);
expect(await readSource(page.request)).toEqual(sourceBefore);
expect(await json<unknown>(await page.request.get(frozenUrl))).toEqual(frozenBefore);
await capture(page, info, desktop, 'crud-reloaded');
await info.attach('library-crud-readback', { body: JSON.stringify({ original, renamed, copy, duplicateWrites, frozenBefore,
sourceUnchanged: true, frozenUnchanged: true, qualification: 'Actual HTTP rename/duplicate/delete and frozen DTO readback; held duplicate continues the real request. Frozen DTO equality is not archived PDF-byte or restore qualification.' }), contentType: 'application/json' });
});
test('[P7-04 PRES-C05 C10] concurrent rename receives ordinary409 and requires owner review before a new save', async ({ page, owner }, info) => {
const sourceBefore = await seedSource(page, owner);
const original = await createVariant(page.request, owner, 'Recorded conflict CV');
await enter(page, owner);
const writes: unknown[] = [];
let release!: () => void;
const held = new Promise<void>(resolve => { release = resolve; });
await page.route(`**/api/cv/variants/${original.id}`, async route => {
if (route.request().method() !== 'PUT') return route.continue();
writes.push(route.request().postDataJSON()); if (writes.length === 1) await held;
return route.continue();
});
await openAction(page, original.name, translations.en.cvDashboardRename);
await page.getByRole('dialog').getByRole('textbox').fill('Unsaved rename that must not win');
const conflictedResponse = page.waitForResponse(response => response.url() === `${apiUrl}/cv/variants/${original.id}` && response.request().method() === 'PUT');
let intervening!: CvVariant;
try {
await page.getByRole('dialog').getByRole('button', { name: translations.en.cvDashboardRename, exact: true }).click();
await expect.poll(() => writes.length).toBe(1);
intervening = await json<CvVariant>(await page.request.put(`${apiUrl}/cv/variants/${original.id}`, { headers: owner.headers,
data: { name: 'Recorded intervening owner change', settings: original.settings, source: 'manual', expectedVersion: original.version } }));
} finally { release(); }
const conflict = await conflictedResponse;
expect(conflict.status()).toBe(409); expect(await conflict.json()).toMatchObject({ code: 'cv_revision_conflict' });
await expect(page.getByRole('alert').filter({ hasText: 'changed elsewhere' })).toBeVisible();
expect(writes).toHaveLength(1);
expect(await json<CvVariant>(await page.request.get(`${apiUrl}/cv/variants/${original.id}`))).toEqual(intervening);
await page.reload(); await expect(card(page, intervening.name)).toBeVisible();
await openAction(page, intervening.name, translations.en.cvDashboardRename);
await expect(page.getByRole('dialog').getByRole('textbox')).toHaveValue(intervening.name);
await page.getByRole('dialog').getByRole('textbox').fill('Recorded rename after explicit reload and review');
const reviewedResponse = page.waitForResponse(response => response.url() === `${apiUrl}/cv/variants/${original.id}` && response.request().method() === 'PUT');
await page.getByRole('dialog').getByRole('button', { name: translations.en.cvDashboardRename, exact: true }).click();
const reviewed = await json<CvVariant>(await reviewedResponse);
expect(writes).toHaveLength(2); expect(writes[1]).toMatchObject({ expectedVersion: intervening.version, settings: intervening.settings, source: 'manual' });
expect(reviewed.version).toBe(intervening.version + 1); expect(reviewed.settings).toEqual(intervening.settings);
expect(await readSource(page.request)).toEqual(sourceBefore);
await info.attach('library-ordinary-rename-conflict', { body: JSON.stringify({ original, intervening, writes, reviewed,
qualification: 'Both saves reach the actual server; browser PUT is held while an owner-authorized API save advances its revision. No expected-failure/skip or automatic retry.' }), contentType: 'application/json' });
});
test('[P7-04 PRES-C05 C08 C12 A02] free gallery, private ownership and revoked public links retain server admission boundaries', async ({ page, owner, playwright }, info) => {
const sourceBefore = await seedSource(page, owner);
const original = await createVariant(page.request, owner, 'Recorded owner-private CV');
const themes = await json<CvTheme[]>(await page.request.get(`${apiUrl}/cv/themes`));
const locked = themes.find(theme => theme.requiresPro && !theme.available)!;
expect(locked).toBeDefined();
expect((await page.request.post(`${apiUrl}/cv/variants`, { headers: owner.headers,
data: { name: 'Forbidden premium CV', settings: settings(locked.id) } })).status()).toBe(403);
await enter(page, owner);
const dialog = await openCreate(page);
await dialog.getByRole('button', { name: translations.en.cvEditorAllTemplates, exact: true }).click();
const premium = dialog.getByRole('button', { name: locked.name, exact: true });
await expect(premium).toHaveAttribute('aria-disabled', 'true');
await expect(premium).toHaveAttribute('tabindex', '-1');
const selectedBefore = await dialog.locator('[aria-pressed="true"][role="button"]').getAttribute('aria-label');
expect(selectedBefore).not.toBe(locked.name);
await dialog.getByRole('button', { name: translations.en.cancel, exact: true }).click();
const foreign = await playwright.request.newContext();
const anonymous = await playwright.request.newContext();
const admin = await playwright.request.newContext();
try {
const administrator = await login(admin, 'e2e@example.test');
const foreignOwner = await login(foreign, await createOwner(admin, administrator));
expect(await json<CvVariantSummary[]>(await foreign.get(`${apiUrl}/cv/variants`))).toEqual([]);
const forbidden = [
await foreign.get(`${apiUrl}/cv/variants/${original.id}`),
await foreign.put(`${apiUrl}/cv/variants/${original.id}`, { headers: foreignOwner.headers, data: { name: 'Forbidden rename', settings: original.settings, source: 'manual', expectedVersion: original.version } }),
await foreign.post(`${apiUrl}/cv/variants/${original.id}/duplicate`, { headers: foreignOwner.headers, data: {} }),
await foreign.put(`${apiUrl}/cv/variants/${original.id}/public`, { headers: foreignOwner.headers, data: { isPublic: true } }),
await foreign.post(`${apiUrl}/cv/variants/${original.id}/export-pdf`, { headers: foreignOwner.headers, data: {} }),
await foreign.delete(`${apiUrl}/cv/variants/${original.id}`, { headers: foreignOwner.headers }),
];
expect(forbidden.map(response => response.status())).toEqual([404, 404, 404, 404, 404, 404]);
expect(await json<unknown[]>(await foreign.get(`${apiUrl}/cv/variants/${original.id}/versions`))).toEqual([]);
expect((await anonymous.get(`${apiUrl}/cv/variants/${original.id}`)).status()).toBe(401);
expect((await anonymous.get(`${apiUrl}/public-cv/${original.publicSlug}`)).status()).toBe(404);
const published = await json<CvVariant>(await page.request.put(`${apiUrl}/cv/variants/${original.id}/public`, { headers: owner.headers, data: { isPublic: true } }));
const publicResponse = await anonymous.get(`${apiUrl}/public-cv/${published.publicSlug}`);
const publicDocument = await json<{ html: string; name: string }>(publicResponse);
expect(publicResponse.headers()['x-robots-tag']).toBe('noindex, nofollow'); expect(publicDocument.html).toContain('CV-specific reviewed title');
await page.reload(); await expect(card(page, original.name).getByText(translations.en.cvEditorPublic, { exact: true })).toBeVisible();
await json<CvVariant>(await page.request.put(`${apiUrl}/cv/variants/${original.id}/public`, { headers: owner.headers, data: { isPublic: false } }));
expect((await anonymous.get(`${apiUrl}/public-cv/${published.publicSlug}`)).status()).toBe(404);
expect((await anonymous.get(`${apiUrl}/public-cv/${published.publicSlug}/pdf`)).status()).toBe(404);
const republished = await json<CvVariant>(await page.request.put(`${apiUrl}/cv/variants/${original.id}/public`, { headers: owner.headers, data: { isPublic: true } }));
expect(republished.publicSlug).not.toBe(published.publicSlug);
expect((await anonymous.get(`${apiUrl}/public-cv/${published.publicSlug}`)).status()).toBe(404);
expect((await anonymous.get(`${apiUrl}/public-cv/${published.publicSlug}/pdf`)).status()).toBe(404);
expect((await anonymous.get(`${apiUrl}/public-cv/${republished.publicSlug}`)).status()).toBe(200);
expect(await readSource(page.request)).toEqual(sourceBefore);
await info.attach('library-server-admission', { body: JSON.stringify({ originalId: original.id, lockedTheme: locked.id,
foreignStatuses: forbidden.map(response => response.status()), publishedSlug: published.publicSlug, republishedSlug: republished.publicSlug,
qualification: 'Actual HTTP owner/entitlement, anonymous HTML and revoked-PDF admission only. No valid private/public PDF rendering or output-fidelity claim; public UI migration is a separate checkpoint.' }), contentType: 'application/json' });
} finally { await foreign.dispose(); await anonymous.dispose(); await admin.dispose(); }
});
for (const status of [503, 504]) {
test(`[P7-04 PRES-C05 C12 E08] private PDF${status} feedback does not retry or change the CV`, async ({ page, owner }, info) => {
const sourceBefore = await seedSource(page, owner);
const existing = await createVariant(page.request, owner, `Recorded PDF${status} CV`);
let requests = 0;
await page.route(`**/api/cv/variants/${existing.id}/export-pdf`, async route => {
requests++; return route.fulfill({ status, contentType: 'application/json', body: JSON.stringify({ message: 'Controlled PDF unavailable' }) });
});
const sample: Sample = { language: 'nb', theme: 'dark', width: 390, height: 844 };
await enter(page, owner, sample);
await openAction(page, existing.name, translations.nb.cvEditorDownloadPdf, 'nb');
await expect(page.getByRole('alert').filter({ hasText: status === 503 ? translations.nb.cvPdfBusy : translations.nb.cvPdfTimedOut })).toBeVisible();
expect(requests).toBe(1);
expect(await json<CvVariant>(await page.request.get(`${apiUrl}/cv/variants/${existing.id}`))).toEqual(existing);
expect(await readSource(page.request)).toEqual(sourceBefore);
await info.attach(`library-pdf-${status}-presentation`, { body: JSON.stringify({ requests, status,
qualification: 'Controlled browser HTTP failure tests presentation/no automatic retry. Exporter is not invoked; this is not PDF output or actual admission qualification.' }), contentType: 'application/json' });
});
}
test('[P7-04 PRES-C05 C08 E08] finite library and gallery width locale theme matrix keeps actions and real data reachable', async ({ page, owner }, info) => {
test.setTimeout(180_000);
const sourceBefore = await seedSource(page, owner);
const longName = 'Backendutvikler — Åse Nordlys sin eiergjennomgåtte CV for dokumentert profesjonelt arbeid i norske organisasjoner';
const longUnbrokenName = 'EiergjennomgåttDokumentertProfesjonellErfaringOgKvalifikasjoner'.repeat(3);
const originals = [await createVariant(page.request, owner, longName), await createVariant(page.request, owner, longUnbrokenName)];
const samples: Sample[] = [
{ language: 'nb', theme: 'light', width: 320, height: 844 },
{ language: 'en', theme: 'dark', width: 360, height: 844 },
{ language: 'nb', theme: 'dark', width: 390, height: 844 },
{ language: 'en', theme: 'light', width: 768, height: 1024 },
{ language: 'nb', theme: 'light', width: 1024, height: 768 },
{ language: 'en', theme: 'dark', width: 1280, height: 320 },
{ language: 'nb', theme: 'dark', width: 1440, height: 900 },
{ language: 'en', theme: 'light', width: 1920, height: 1080 },
];
for (const sample of samples) {
await enter(page, owner, sample);
await expect(card(page, longName, sample.language)).toBeVisible();
await expect(card(page, longUnbrokenName, sample.language)).toBeVisible();
await actionButton(page, longName, sample.language).focus(); await page.keyboard.press('Enter');
await expect(page.getByRole('menu').getByRole('menuitem', { name: translations[sample.language].cvDashboardRename, exact: true })).toBeVisible();
await page.keyboard.press('Escape'); await expect(actionButton(page, longName, sample.language)).toBeFocused();
await page.getByRole('heading', { level: 2, name: translations[sample.language].cvDashboardLibraryTitle, exact: true }).scrollIntoViewIfNeeded();
await capture(page, info, sample, 'library');
const dialog = await openCreate(page, sample.language);
await expect(dialog.getByLabel(translations[sample.language].cvEditorName, { exact: false })).toBeFocused();
await dialog.getByLabel(translations[sample.language].cvEditorName, { exact: false }).fill(longName);
await dialog.getByRole('button', { name: translations[sample.language].cvEditorAllTemplates, exact: true }).click();
await capture(page, info, sample, 'gallery');
await dialog.getByRole('button', { name: translations[sample.language].cancel, exact: true }).focus(); await page.keyboard.press('Enter');
await expect(page.getByRole('button', { name: translations[sample.language].cvDashboardNewCv, exact: true })).toBeFocused();
}
expect(await readSource(page.request)).toEqual(sourceBefore);
for (const original of originals) expect(await json<CvVariant>(await page.request.get(`${apiUrl}/cv/variants/${original.id}`))).toEqual(original);
await info.attach('library-finite-responsive-matrix', { body: JSON.stringify({ samples, originals,
qualification: 'Eight balanced samples, not the complete width x theme x locale cross-product. Library and create gallery are captured separately; no document-canvas scrolling, physical touch, actual zoom or human screen-reader acceptance.' }), contentType: 'application/json' });
});