Files
jobtrackingapp/job-tracker-ui/e2e/phase7-public-cv.spec.ts
T
2026-10-13 00:24:40 +02:00

403 lines
28 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 { readFile } from 'node:fs/promises';
import { translations } from '../src/i18n/translations';
import type { CvVariant } from '../src/cvBuilder';
import { emptyStructuredCv } from '../src/profileCv';
const apiUrl = 'http://localhost:5302/api';
const password = 'E2ePassword123!';
const a4Width = 210 / 25.4 * 96;
const a4Height = 297 / 25.4 * 96;
type Owner = { api: APIRequestContext; id: string; headers: Record<string, string> };
type Sample = { language: 'en' | 'nb'; theme: 'light' | 'dark'; width: number; height: number };
type Documents = { published: CvVariant; sibling: CvVariant };
const desktop: Sample = { language: 'en', theme: 'light', width: 1440, height: 900 };
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) {
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 } };
}
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();
const api = await playwright.request.newContext();
try {
const administrator = await login(admin, 'e2e@example.test');
const email = `p7-public-cv-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`;
expect((await admin.post(`${apiUrl}/users`, { headers: administrator.headers, data: { email, password, roles: [] } })).status()).toBe(200);
const owner = { api, ...await login(api, email) };
expect((await page.request.get(`${apiUrl}/auth/me`)).status()).toBe(401);
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 api.dispose(); await admin.dispose(); }
},
});
async function seed(owner: Owner, long = false): Promise<Documents> {
const profile = { ...emptyStructuredCv(), contact: { ...emptyStructuredCv().contact,
fullName: 'Åse Nordlys — recorded owner-reviewed identity', headline: 'Recorded canonical professional context',
email: 'reviewed-professional-contact@example.test', location: 'Trondheim, Norge' },
summary: ['Reviewed canonical summary, kept separate from CV-specific presentation.'],
skills: ['C#', 'React', 'Norsk'],
jobs: Array.from({ length: long ? 14 : 1 }, (_, index) => ({ id: `public-source-job-${index}`, title: `Recorded professional role ${index + 1}`,
company: `Recorded Norwegian organisation ${index + 1}`, start: 'Jan 2020', end: 'Dec 2023',
bullets: long ? ['Recorded implementation responsibility with owner-reviewed professional context across reliable services and accessible interfaces.',
'Recorded collaboration with colleagues on documented delivery and operational responsibilities across Norwegian organisations.',
'Recorded review and maintenance of existing services, preserving factual source material without inventing metrics or qualifications.'] : ['Recorded owner-reviewed source evidence.'], skills: ['C#'] })),
};
await json(await owner.api.put(`${apiUrl}/career/profile`, { headers: owner.headers,
data: { profile, cvText: 'Exact owner-reviewed original source — æøå\nRecorded work and qualifications.\n' } }));
const variant = await json<CvVariant>(await owner.api.post(`${apiUrl}/cv/variants`, { headers: owner.headers,
data: { name: 'Recorded shared CV', settings: { themeId: 'minimal', language: 'en', showPhoto: false, showPageNumbers: false, showIcons: true,
sections: [], customSections: [], headline: 'Recorded CV-specific shared presentation',
overrides: { 'public-source-job-0': { title: 'CV-specific reviewed public title', bullets: ['CV-specific reviewed public evidence.'] } } } } }));
const sibling = await json<CvVariant>(await owner.api.post(`${apiUrl}/cv/variants`, { headers: owner.headers,
data: { name: 'Recorded private sibling', settings: { themeId: 'minimal', showPhoto: false, showPageNumbers: false, showIcons: true,
sections: [], overrides: {}, customSections: [], headline: 'Recorded alternate CV presentation' } } }));
const published = await publish(owner, variant.id, true);
return { published, sibling };
}
async function publish(owner: Owner, id: number, isPublic: boolean) {
return json<CvVariant>(await owner.api.put(`${apiUrl}/cv/variants/${id}/public`, { headers: owner.headers, data: { isPublic } }));
}
async function preserved(owner: Owner, documents: Documents) {
const current = await json<CvVariant>(await owner.api.get(`${apiUrl}/cv/variants/${documents.published.id}`));
return { canonical: await json<unknown>(await owner.api.get(`${apiUrl}/career/profile`)),
sourceHistory: await json<unknown[]>(await owner.api.get(`${apiUrl}/career/profile/versions`)),
selected: { name: current.name, settings: current.settings, version: current.version, jobApplicationId: current.jobApplicationId },
selectedHistory: await json<unknown[]>(await owner.api.get(`${apiUrl}/cv/variants/${documents.published.id}/versions`)),
sibling: await json<CvVariant>(await owner.api.get(`${apiUrl}/cv/variants/${documents.sibling.id}`)),
siblingHistory: await json<unknown[]>(await owner.api.get(`${apiUrl}/cv/variants/${documents.sibling.id}/versions`)) };
}
async function enter(page: Page, slug: string, sample: Sample = desktop) {
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(`/cv/${slug}?source=phase7-public#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, name: translations[sample.language].publicCvTitle, exact: true })).toBeVisible();
}
async function ready(page: Page, language: Sample['language']) {
const frame = page.getByTitle(translations[language].publicCvTitle, { exact: true });
await expect(frame).toBeVisible();
await expect.poll(() => frame.evaluate(element => {
const doc = (element as HTMLIFrameElement).contentDocument;
return doc?.readyState === 'complete' && !!doc.body.textContent?.includes('CV-specific reviewed public title');
})).toBe(true);
await expect(frame).toHaveAttribute('sandbox', 'allow-same-origin');
await expect(page.getByRole('link', { name: translations[language].publicCvDownloadPdf, exact: true })).toBeVisible();
await expect(page.locator('meta[name="robots"]')).toHaveAttribute('content', 'noindex, nofollow');
return frame;
}
async function capture(page: Page, info: TestInfo, sample: Sample, state: string, documentExpected = true) {
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 frame = page.getByTitle(translations[sample.language].publicCvTitle, { exact: true });
const documentMetrics = documentExpected ? await frame.evaluate(async element => {
const iframe = element as HTMLIFrameElement;
const doc = iframe.contentDocument!;
await doc.fonts.ready;
const style = getComputedStyle(iframe);
const rect = iframe.getBoundingClientRect();
const wrapper = iframe.parentElement!;
const wrapperRect = wrapper.getBoundingClientRect();
return { cssWidth: parseFloat(style.width), cssHeight: parseFloat(style.height), renderedWidth: rect.width, renderedHeight: rect.height,
left: rect.left, right: rect.right, scale: new DOMMatrix(style.transform).a, frameBackground: style.backgroundColor,
wrapperWidth: wrapperRect.width, wrapperHeight: wrapperRect.height, wrapperLeft: wrapperRect.left, wrapperRight: wrapperRect.right,
documentLang: doc.documentElement.lang, documentWidth: doc.documentElement.clientWidth,
documentScrollWidth: doc.documentElement.scrollWidth, contentHeight: doc.body.scrollHeight,
documentScrollHeight: doc.documentElement.scrollHeight, reducedMotion: matchMedia('(prefers-reduced-motion: reduce)').matches };
}) : null;
const axe = await new AxeBuilder({ page }).options({ rules: { 'target-size': { enabled: true } } }).analyze();
const name = `p7-public-cv-${sample.language}-${sample.theme}-${sample.width}-${state}`;
await info.attach(`${name}-audit`, { body: JSON.stringify({ sample, state, geometry, documentMetrics, ariaSnapshot: await page.locator('body').ariaSnapshot(),
axe: { scope: 'All enabled rules plus target-size; complete iframe/incomplete contexts retained', violations: axe.violations, incomplete: axe.incomplete,
passes: axe.passes.map(rule => ({ id: rule.id, nodes: rule.nodes.length })) },
qualifications: ['Automated local Chromium; no human screen reader, physical touch/software keyboard, actual browser zoom or production font/photo qualification.',
'Document locale is separate from app locale. CSS-pixel A4/white-canvas measurements are not manual print, Linux-font or PDF pagination-fidelity acceptance.',
'Same-origin sandbox and noindex are retained; any untestable iframe axe contexts remain incomplete rather than being hidden.'],
}), contentType: 'application/json' });
await info.attach(name, { body: await page.screenshot({ fullPage: true, animations: 'disabled' }), contentType: 'image/png' });
expect(geometry.rootOverflow).toBe(0);
if (documentMetrics) {
expect(Math.abs(documentMetrics.cssWidth - a4Width)).toBeLessThan(0.05);
expect(Math.abs(documentMetrics.renderedWidth - Math.min(a4Width, geometry.width - 32))).toBeLessThan(1);
expect(documentMetrics.cssHeight).toBeGreaterThanOrEqual(a4Height - 0.05);
expect(documentMetrics.documentScrollWidth - documentMetrics.documentWidth).toBeLessThanOrEqual(1);
expect(documentMetrics.left).toBeGreaterThanOrEqual(0);
expect(documentMetrics.right).toBeLessThanOrEqual(geometry.width + 1);
expect(documentMetrics.left).toBeGreaterThanOrEqual(documentMetrics.wrapperLeft - 1);
expect(documentMetrics.right).toBeLessThanOrEqual(documentMetrics.wrapperRight + 1);
expect(Math.abs(documentMetrics.renderedWidth - documentMetrics.cssWidth * documentMetrics.scale)).toBeLessThan(1);
expect(Math.abs(documentMetrics.wrapperWidth - documentMetrics.renderedWidth)).toBeLessThan(1);
expect(Math.abs(documentMetrics.wrapperHeight - documentMetrics.renderedHeight)).toBeLessThan(1);
expect(documentMetrics.cssHeight).toBeGreaterThanOrEqual(documentMetrics.contentHeight - 1);
expect(documentMetrics.frameBackground).toBe('rgb(255, 255, 255)');
expect(documentMetrics.reducedMotion).toBe(true);
} else await expect(frame).toHaveCount(0);
expect(axe.violations).toEqual([]);
return documentMetrics;
}
test('[P7-04 PRES-C05 C07 C12 A02] anonymous shared CV keeps sandbox noindex private denial and actual keyboard PDF download', async ({ page, owner }, info) => {
test.setTimeout(90_000);
const documents = await seed(owner);
const before = await preserved(owner, documents);
const response = await page.request.get(`${apiUrl}/public-cv/${documents.published.publicSlug}`);
const shared = await json<{ html: string; name: string }>(response);
expect(response.headers()['x-robots-tag']).toBe('noindex, nofollow');
expect(shared.html).toContain('CV-specific reviewed public title');
expect((await page.request.get(`${apiUrl}/cv/variants/${documents.published.id}`)).status()).toBe(401);
expect((await page.request.get(`${apiUrl}/public-cv/${documents.sibling.publicSlug}`)).status()).toBe(404);
expect((await page.request.get(`${apiUrl}/public-cv/${documents.published.id}`)).status()).toBe(404);
await enter(page, documents.published.publicSlug);
const frame = await ready(page, 'en');
expect(await frame.getAttribute('srcdoc')).toBe(shared.html);
expect(await page.title()).toContain(shared.name);
const downloadResponse = page.waitForResponse(result => result.url() === `${apiUrl}/public-cv/${documents.published.publicSlug}/pdf`);
const downloadEvent = page.waitForEvent('download');
const link = page.getByRole('link', { name: translations.en.publicCvDownloadPdf, exact: true });
await expect(link).toHaveAttribute('href', `${apiUrl}/public-cv/${documents.published.publicSlug}/pdf`);
await link.focus(); await page.keyboard.press('Enter');
const downloaded = await downloadEvent;
const pdfResponse = await downloadResponse;
expect(pdfResponse.status()).toBe(200); expect(pdfResponse.headers()['content-type']).toContain('application/pdf');
expect(await downloaded.failure()).toBeNull();
const downloadedPath = await downloaded.path(); expect(downloadedPath).not.toBeNull();
const pdf = await readFile(downloadedPath!);
expect(pdf.length).toBeGreaterThan(1_000); expect(pdf.subarray(0, 5).toString()).toBe('%PDF-');
await info.attach('public-cv-actual-keyboard-download.pdf', { body: pdf, contentType: 'application/pdf' });
expect(await preserved(owner, documents)).toEqual(before);
await capture(page, info, desktop, 'anonymous-download');
await info.attach('public-cv-anonymous-contract', { body: JSON.stringify({ privateStatus: 401, unknownNumericSlugStatus: 404,
privateSiblingSlugStatus: 404, pdfStatus: pdfResponse.status(), pdfBytes: pdf.length, fileName: downloaded.suggestedFilename(),
qualification: 'Actual anonymous HTML and local Chromium PDF bytes, triggered by keyboard Enter on the real download link. This does not establish print pagination, deployed Linux fonts/photos or production acceptance.' }), contentType: 'application/json' });
await page.evaluate(() => {
history.pushState(null, '', '/login');
dispatchEvent(new PopStateEvent('popstate'));
});
await expect(page.getByTitle(translations.en.publicCvTitle, { exact: true })).toHaveCount(0);
await expect(page).toHaveTitle('Jobbjakt');
});
test('[P7-04 PRES-C05 C12] owner revocation removes anonymous view and PDF; republishing rotates the revoked link', async ({ page, owner }, info) => {
const documents = await seed(owner);
const before = await preserved(owner, documents);
const sample: Sample = { language: 'nb', theme: 'dark', width: 390, height: 844 };
await enter(page, documents.published.publicSlug, sample); await ready(page, 'nb');
await publish(owner, documents.published.id, false);
expect((await page.request.get(`${apiUrl}/public-cv/${documents.published.publicSlug}`)).status()).toBe(404);
expect((await page.request.get(`${apiUrl}/public-cv/${documents.published.publicSlug}/pdf`)).status()).toBe(404);
await page.reload();
await expect(page.getByText(translations.nb.publicCvUnavailableTitle, { exact: true })).toBeVisible();
await expect(page.getByText(translations.nb.publicCvUnavailableBody, { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: translations.nb.retry, exact: true })).toHaveCount(0);
await expect(page.getByRole('link', { name: translations.nb.publicCvDownloadPdf, exact: true })).toHaveCount(0);
await capture(page, info, sample, 'revoked', false);
const republished = await publish(owner, documents.published.id, true);
expect(republished.publicSlug).not.toBe(documents.published.publicSlug);
expect((await page.request.get(`${apiUrl}/public-cv/${documents.published.publicSlug}`)).status()).toBe(404);
expect((await page.request.get(`${apiUrl}/public-cv/${documents.published.publicSlug}/pdf`)).status()).toBe(404);
await enter(page, republished.publicSlug, sample); await ready(page, 'nb');
expect(await preserved(owner, documents)).toEqual(before);
await info.attach('public-cv-revocation-readback', { body: JSON.stringify({ oldSlug: documents.published.publicSlug, newSlug: republished.publicSlug,
qualification: 'Actual owner publication PUTs and anonymous old-link HTML/PDF404; settings, versions, source and private sibling are unchanged. Existing open content is refreshed explicitly to observe revocation; no claim of live cross-client revocation events.' }), contentType: 'application/json' });
});
test('[P7-04 PRES-C12 E08] transient error retries by keyboard with held real read polite progress and stable H1 focus', async ({ page, owner }, info) => {
test.setTimeout(90_000);
const documents = await seed(owner);
const before = await preserved(owner, documents);
const sample: Sample = { language: 'nb', theme: 'dark', width: 390, height: 844 };
let reads = 0;
let release!: () => void;
const held = new Promise<void>(resolve => { release = resolve; });
await page.route(`**/api/public-cv/${documents.published.publicSlug}`, async route => {
reads++;
if (reads === 1) return route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ message: 'Controlled public CV read unavailable' }) });
if (reads === 2) await held;
return route.continue();
});
await enter(page, documents.published.publicSlug, sample);
await expect(page.getByText(translations.nb.publicCvLoadFailedTitle, { exact: true })).toBeVisible();
await expect(page.getByText(translations.nb.publicCvLoadFailedBody, { exact: true })).toBeVisible();
await expect(page.getByRole('link', { name: translations.nb.publicCvDownloadPdf, exact: true })).toHaveCount(0);
await capture(page, info, sample, 'transient-failure', false);
const title = page.getByRole('heading', { level: 1, name: translations.nb.publicCvTitle, exact: true });
try {
await page.getByRole('button', { name: translations.nb.retry, exact: true }).focus(); await page.keyboard.press('Enter');
await expect.poll(() => reads).toBe(2);
await expect(title).toBeFocused();
await expect(page.getByRole('progressbar', { name: translations.nb.loading, exact: true })).toBeVisible();
await expect(page.getByRole('status')).toHaveText(translations.nb.loading);
await expect(page.getByRole('status')).toHaveAttribute('aria-live', 'polite');
await page.keyboard.press('Enter'); expect(reads).toBe(2);
} finally { release(); }
await ready(page, 'nb'); await expect(title).toBeFocused();
await expect(page.getByText(translations.nb.publicCvLoadFailedTitle, { exact: true })).toHaveCount(0);
expect(reads).toBe(2); expect(await preserved(owner, documents)).toEqual(before);
await capture(page, info, sample, 'explicit-recovery');
await info.attach('public-cv-controlled-retry', { body: JSON.stringify({ reads,
qualification: 'First GET is a controlled500; only keyboard Retry reaches the held then continued actual HTTP read. Successful document HTML is not fabricated; no automatic retry or source mutation.' }), contentType: 'application/json' });
});
test('[P7-04 PRES-C12 E08] initial held anonymous read announces loading without a document or download', async ({ page, owner }, info) => {
const documents = await seed(owner);
const before = await preserved(owner, documents);
const sample: Sample = { language: 'en', theme: 'dark', width: 360, height: 844 };
let reads = 0;
let release!: () => void;
const held = new Promise<void>(resolve => { release = resolve; });
await page.route(`**/api/public-cv/${documents.published.publicSlug}`, async route => { reads++; await held; return route.continue(); });
try {
await enter(page, documents.published.publicSlug, sample);
await expect.poll(() => reads).toBe(1);
await expect(page.getByRole('progressbar', { name: translations.en.loading, exact: true })).toBeVisible();
await expect(page.getByRole('status')).toHaveText(translations.en.loading);
await expect(page.getByRole('status')).toHaveAttribute('aria-live', 'polite');
await expect(page.getByRole('link', { name: translations.en.publicCvDownloadPdf, exact: true })).toHaveCount(0);
expect(await page.title()).toBe('Jobbjakt');
await capture(page, info, sample, 'initial-held-read', false);
} finally { release(); }
await ready(page, 'en'); expect(reads).toBe(1);
expect(await preserved(owner, documents)).toEqual(before);
});
test('[P7-04 PRES-C12 E08] unknown and numeric slugs use localized unavailable states without Retry or PDF controls', async ({ page, owner }, info) => {
const documents = await seed(owner);
const before = await preserved(owner, documents);
const samples: Sample[] = [
{ language: 'en', theme: 'light', width: 320, height: 844 },
{ language: 'nb', theme: 'dark', width: 390, height: 844 },
];
for (let index = 0; index < samples.length; index++) {
const sample = samples[index];
const slug = index === 0 ? `missing-${owner.id}` : String(documents.published.id);
expect((await page.request.get(`${apiUrl}/public-cv/${slug}`)).status()).toBe(404);
await enter(page, slug, sample);
const t = translations[sample.language];
await expect(page.getByText(t.publicCvUnavailableTitle, { exact: true })).toBeVisible();
await expect(page.getByText(t.publicCvUnavailableBody, { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: t.retry, exact: true })).toHaveCount(0);
await expect(page.getByRole('link', { name: t.publicCvDownloadPdf, exact: true })).toHaveCount(0);
await capture(page, info, sample, 'unavailable', false);
}
expect(await preserved(owner, documents)).toEqual(before);
});
test('[P7-04 PRES-C12] same-document slug change removes prior HTML title and download before the next actual response', async ({ page, owner }, info) => {
test.setTimeout(90_000);
const documents = await seed(owner);
const alternate = await publish(owner, documents.sibling.id, true);
const before = await preserved(owner, documents);
await enter(page, documents.published.publicSlug); await ready(page, 'en');
let reads = 0;
let release!: () => void;
const held = new Promise<void>(resolve => { release = resolve; });
await page.route(`**/api/public-cv/${alternate.publicSlug}`, async route => { reads++; await held; return route.continue(); });
try {
await page.evaluate(slug => {
history.pushState(null, '', `/cv/${slug}?source=phase7-public-route#preserved`);
dispatchEvent(new PopStateEvent('popstate'));
}, alternate.publicSlug);
await expect.poll(() => reads).toBe(1);
await expect(page.getByTitle(translations.en.publicCvTitle, { exact: true })).toHaveCount(0);
await expect(page.getByRole('link', { name: translations.en.publicCvDownloadPdf, exact: true })).toHaveCount(0);
expect(await page.title()).toBe('Jobbjakt');
await capture(page, info, desktop, 'changed-slug-held', false);
} finally { release(); }
const alternateFrame = page.getByTitle(translations.en.publicCvTitle, { exact: true });
await expect(alternateFrame).toBeVisible();
await expect.poll(() => alternateFrame.getAttribute('srcdoc')).toContain('Recorded alternate CV presentation');
expect(await alternateFrame.getAttribute('srcdoc')).not.toContain('Recorded CV-specific shared presentation');
await expect(page.getByRole('link', { name: translations.en.publicCvDownloadPdf, exact: true })).toHaveAttribute('href', `${apiUrl}/public-cv/${alternate.publicSlug}/pdf`);
await page.goBack(); await ready(page, 'en');
await page.goForward();
await expect.poll(() => page.getByTitle(translations.en.publicCvTitle, { exact: true }).getAttribute('srcdoc')).toContain('Recorded alternate CV presentation');
expect(await preserved(owner, documents)).toEqual(before);
await info.attach('public-cv-same-document-transition', { body: JSON.stringify({ originalSlug: documents.published.publicSlug, alternateSlug: alternate.publicSlug, reads,
qualification: 'A test-harness browser History pushState/popstate establishes a same-document route change; no product navigation is added. Back/forward then use actual browser history. Both documents and GETs are real; late-response isolation also requires focused unit evidence.' }), contentType: 'application/json' });
});
test('[P7-04 PRES-C12 E08] finite public width locale theme matrix preserves A4 white document and scaled wrapper boundaries', async ({ page, owner }, info) => {
test.setTimeout(180_000);
const documents = await seed(owner);
const before = await preserved(owner, documents);
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, documents.published.publicSlug, sample); await ready(page, sample.language);
const link = page.getByRole('link', { name: translations[sample.language].publicCvDownloadPdf, exact: true });
await page.getByRole('heading', { level: 1 }).focus(); await page.keyboard.press('Tab');
await expect(link).toBeFocused();
await page.getByRole('heading', { level: 1 }).scrollIntoViewIfNeeded();
await capture(page, info, sample, 'loaded');
}
expect(await preserved(owner, documents)).toEqual(before);
await info.attach('public-cv-finite-matrix', { body: JSON.stringify({ samples,
qualification: 'Eight balanced viewport/locale/theme samples, not the complete Cartesian product. Keyboard Tab reaches the PDF link; A4 CSS pixels and transformed wrapper boundaries are measured separately. Renderer settings and document locale remain unchanged.' }), contentType: 'application/json' });
});
test('[P7-04 PRES-C12 C07] long public CV height remains reachable without shrinking A4 geometry or mutating source', async ({ page, owner }, info) => {
test.setTimeout(90_000);
const documents = await seed(owner, true);
const before = await preserved(owner, documents);
const samples: Sample[] = [
{ language: 'nb', theme: 'dark', width: 1440, height: 900 },
{ language: 'en', theme: 'light', width: 320, height: 844 },
];
for (const sample of samples) {
await enter(page, documents.published.publicSlug, sample); const frame = await ready(page, sample.language);
await expect.poll(() => frame.evaluate(element => parseFloat(getComputedStyle(element).height))).toBeGreaterThan(a4Height);
const metrics = await capture(page, info, sample, 'long-document');
expect(metrics!.cssHeight).toBeGreaterThan(a4Height);
expect(metrics!.documentScrollHeight).toBeLessThanOrEqual(metrics!.cssHeight + 1);
}
expect(await preserved(owner, documents)).toEqual(before);
});