403 lines
28 KiB
TypeScript
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);
|
|
});
|