feat(ui): migrate public CV entry and recovery

This commit is contained in:
cesnimda
2026-10-13 00:24:40 +02:00
parent fcacae7384
commit bfa0ab62f8
15 changed files with 10472 additions and 57 deletions
+402
View File
@@ -0,0 +1,402 @@
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);
});
+14
View File
@@ -784,6 +784,13 @@ export const translations = {
cvEditorRestoredVersion: "Restored version {version}.",
cvEditorRestoreFailed: "Restore failed.",
cvDashboardTitle: "CV Builder",
publicCvTitle: "Public CV",
publicCvDescription: "A read-only CV shared by its owner. Download the PDF to keep a copy.",
publicCvDownloadPdf: "Download PDF",
publicCvUnavailableTitle: "CV unavailable",
publicCvUnavailableBody: "This CV isn’t available. The link may be wrong, or the owner has made it private.",
publicCvLoadFailedTitle: "Could not load CV",
publicCvLoadFailedBody: "This CV can’t be loaded right now. Please try again in a moment.",
cvDashboardLibraryTitle: "Your CVs",
cvDashboardSubtitle: "Build polished, job-specific resumes from one trusted career profile. Every version keeps its own template, content choices and history.",
cvDashboardUntitled: "Untitled CV",
@@ -3418,6 +3425,13 @@ export const translations = {
cvEditorRestoredVersion: "Versjon {version} er gjenopprettet.",
cvEditorRestoreFailed: "Gjenoppretting mislyktes.",
cvDashboardTitle: "CV-bygger",
publicCvTitle: "Offentlig CV",
publicCvDescription: "En CV som eieren har delt. Du kan lese den og laste ned en kopi som PDF.",
publicCvDownloadPdf: "Last ned PDF",
publicCvUnavailableTitle: "CV-en er utilgjengelig",
publicCvUnavailableBody: "Denne CV-en er ikke tilgjengelig. Lenken kan være feil, eller eieren kan ha gjort CV-en privat.",
publicCvLoadFailedTitle: "Kunne ikke laste CV",
publicCvLoadFailedBody: "CV-en kan ikke lastes akkurat nå. Prøv igjen om litt.",
cvDashboardLibraryTitle: "Dine CV-er",
cvDashboardSubtitle: "Lag profesjonelle, jobbtilpassede CV-er fra én pålitelig karriereprofil. Hver versjon beholder sin egen mal, sine innholdsvalg og sin historikk.",
cvDashboardUntitled: "CV uten tittel",
+241 -10
View File
@@ -1,10 +1,11 @@
import React from 'react';
import '@testing-library/jest-dom';
import { render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter, Route, Routes, useNavigate } from 'react-router-dom';
import { api } from './api';
import PublicCvPage from './views/PublicCvPage';
import { I18nProvider } from './i18n/I18nProvider';
jest.mock('./api', () => ({
api: {
@@ -14,24 +15,254 @@ jest.mock('./api', () => ({
}));
const mockedApi = api as jest.Mocked<typeof api>;
const originalInnerWidth = window.innerWidth;
const originalTitle = document.title;
function RouteControls() {
const navigate = useNavigate();
return <><button onClick={() => navigate('/cv/slug-a')}>Open A</button><button onClick={() => navigate('/cv/slug-b')}>Open B</button></>;
}
function renderPage(path = '/cv/public-slug') {
return render(
<I18nProvider>
<MemoryRouter initialEntries={[path]}>
<RouteControls />
<Routes><Route path="/cv/:slug" element={<PublicCvPage />} /></Routes>
</MemoryRouter>
</I18nProvider>,
);
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
function switchLanguage(language: 'en' | 'nb') {
act(() => { window.dispatchEvent(new StorageEvent('storage', { key: 'uiLanguage', newValue: language })); });
}
beforeEach(() => {
mockedApi.get.mockReset();
window.localStorage.removeItem('uiLanguage');
document.title = originalTitle;
});
afterEach(() => {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalInnerWidth });
window.localStorage.removeItem('uiLanguage');
document.title = originalTitle;
});
test('public CV exposes the rendered CV and PDF download', async () => {
const originalInnerWidth = window.innerWidth;
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 375 });
mockedApi.get.mockResolvedValueOnce({ data: { html: '<p>Public CV</p>', name: 'Ada Lovelace' } } as any);
render(
<MemoryRouter initialEntries={['/cv/public-slug']}>
<Routes><Route path="/cv/:slug" element={<PublicCvPage />} /></Routes>
</MemoryRouter>,
);
renderPage();
const frame = await screen.findByTitle('Public CV');
expect(frame).toHaveAttribute('srcdoc', '<p>Public CV</p>');
expect(frame).toHaveAttribute('sandbox', 'allow-same-origin');
expect(Number.parseFloat(frame.style.width)).toBeGreaterThan(790);
await waitFor(() => expect(frame.style.transform).not.toBe('scale(1)'));
expect(Number.parseFloat(screen.getByTestId('public-cv-frame-container').style.width)).toBeLessThanOrEqual(343);
expect(screen.getByRole('link', { name: 'Download PDF' })).toHaveAttribute('href', '/api/public-cv/public-slug/pdf');
expect(mockedApi.get).toHaveBeenCalledWith('/public-cv/public-slug');
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalInnerWidth });
});
test('a public slug containing path and URL delimiters stays one encoded API and PDF path segment', async () => {
const slug = 'owner/document?mode=preview#section%';
const encodedSlug = encodeURIComponent(slug);
const read = deferred<any>();
mockedApi.get.mockImplementationOnce(() => read.promise);
renderPage(`/cv/${encodedSlug}`);
expect(mockedApi.get).toHaveBeenCalledWith(`/public-cv/${encodedSlug}`);
expect(screen.queryByRole('link', { name: 'Download PDF' })).not.toBeInTheDocument();
await act(async () => { read.resolve({ data: { html: '<p>Encoded slug document</p>', name: 'Candidate' } }); });
expect(await screen.findByTitle('Public CV')).toHaveAttribute('srcdoc', '<p>Encoded slug document</p>');
expect(screen.getByTitle('Public CV')).toHaveAttribute('sandbox', 'allow-same-origin');
expect(screen.getByRole('link', { name: 'Download PDF' })).toHaveAttribute('href', `/api/public-cv/${encodedSlug}/pdf`);
expect(mockedApi.get).toHaveBeenCalledTimes(1);
});
test('a held public CV read has a named page and loading announcement without a document or download', async () => {
const read = deferred<any>();
mockedApi.get.mockImplementationOnce(() => read.promise);
renderPage();
expect(screen.getByRole('heading', { level: 1, name: 'Public CV' })).toBeInTheDocument();
expect(screen.getByRole('status')).toHaveTextContent(/Loading/i);
expect(screen.queryByTitle('Public CV')).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Download PDF' })).not.toBeInTheDocument();
expect(mockedApi.get).toHaveBeenCalledTimes(1);
await act(async () => { read.resolve({ data: { html: '<p>Current public document</p>', name: 'Ada' } }); });
expect(await screen.findByTitle('Public CV')).toHaveAttribute('srcdoc', '<p>Current public document</p>');
});
test('a revoked or missing public slug shows an unavailable state without retry or download', async () => {
mockedApi.get.mockRejectedValueOnce({ response: { status: 404 } });
renderPage();
expect(await screen.findByText('CV unavailable')).toBeInTheDocument();
expect(screen.getByRole('alert')).toHaveTextContent(/link|owner|private/i);
expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument();
expect(screen.queryByTitle('Public CV')).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Download PDF' })).not.toBeInTheDocument();
expect(mockedApi.get).toHaveBeenCalledTimes(1);
});
test('a transient failure retries only when requested and keeps focus on the page heading during its held read', async () => {
const retry = deferred<any>();
mockedApi.get.mockRejectedValueOnce({ response: { status: 503 } }).mockImplementationOnce(() => retry.promise);
renderPage();
expect(await screen.findByText('Could not load CV')).toBeInTheDocument();
expect(mockedApi.get).toHaveBeenCalledTimes(1);
const retryButton = screen.getByRole('button', { name: 'Retry' });
retryButton.focus();
fireEvent.click(retryButton);
expect(mockedApi.get).toHaveBeenCalledTimes(2);
expect(screen.getByRole('heading', { level: 1, name: 'Public CV' })).toHaveFocus();
expect(screen.getByRole('status')).toHaveTextContent(/Loading/i);
expect(screen.queryByRole('link', { name: 'Download PDF' })).not.toBeInTheDocument();
await act(async () => { retry.resolve({ data: { html: '<p>Recovered CV</p>', name: 'Recovered candidate' } }); });
expect(await screen.findByTitle('Public CV')).toHaveAttribute('srcdoc', '<p>Recovered CV</p>');
expect(screen.queryByText('Could not load CV')).not.toBeInTheDocument();
expect(mockedApi.get).toHaveBeenCalledTimes(2);
});
test('changing public slugs immediately hides the previous document, name and PDF and resets its measured height', async () => {
const second = deferred<any>();
mockedApi.get.mockResolvedValueOnce({ data: { html: '<p>Candidate A</p>', name: 'Candidate A' } } as any).mockImplementationOnce(() => second.promise);
renderPage('/cv/slug-a');
const firstFrame = await screen.findByTitle('Public CV') as HTMLIFrameElement;
Object.defineProperty(firstFrame.contentDocument!.documentElement, 'scrollHeight', { configurable: true, value: 3000 });
fireEvent.load(firstFrame);
expect(firstFrame.style.height).toBe('3000px');
expect(document.title).toBe('Candidate A — CV');
fireEvent.click(screen.getByRole('button', { name: 'Open B' }));
expect(screen.queryByTitle('Public CV')).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Download PDF' })).not.toBeInTheDocument();
expect(document.title).not.toContain('Candidate A');
expect(screen.getByRole('status')).toHaveTextContent(/Loading/i);
await act(async () => { second.resolve({ data: { html: '<p>Candidate B</p>', name: 'Candidate B' } }); });
const secondFrame = await screen.findByTitle('Public CV');
expect(secondFrame).toHaveAttribute('srcdoc', '<p>Candidate B</p>');
expect(Number.parseFloat(secondFrame.style.height)).toBeCloseTo((297 / 25.4) * 96);
expect(screen.getByRole('link', { name: 'Download PDF' })).toHaveAttribute('href', '/api/public-cv/slug-b/pdf');
expect(document.title).toBe('Candidate B — CV');
});
test('a failed public slug cannot hide a subsequently loaded different public CV', async () => {
mockedApi.get.mockRejectedValueOnce({ response: { status: 404 } }).mockResolvedValueOnce({ data: { html: '<p>Candidate B</p>', name: 'Candidate B' } } as any);
renderPage('/cv/slug-a');
await screen.findByText(/link.*wrong|wrong.*link/i);
fireEvent.click(screen.getByRole('button', { name: 'Open B' }));
expect(await screen.findByTitle('Public CV')).toHaveAttribute('srcdoc', '<p>Candidate B</p>');
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Download PDF' })).toHaveAttribute('href', '/api/public-cv/slug-b/pdf');
});
test.each(['success', 'failure'])('a late %s from the previous slug cannot replace the current CV or its title', async (outcome) => {
const previous = deferred<any>();
mockedApi.get.mockImplementationOnce(() => previous.promise).mockResolvedValueOnce({ data: { html: '<p>Candidate B</p>', name: 'Candidate B' } } as any);
renderPage('/cv/slug-a');
fireEvent.click(screen.getByRole('button', { name: 'Open B' }));
expect(await screen.findByTitle('Public CV')).toHaveAttribute('srcdoc', '<p>Candidate B</p>');
await act(async () => {
if (outcome === 'success') previous.resolve({ data: { html: '<p>Late candidate A</p>', name: 'Late candidate A' } });
else previous.reject({ response: { status: 404 } });
});
expect(screen.getByTitle('Public CV')).toHaveAttribute('srcdoc', '<p>Candidate B</p>');
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(document.title).toBe('Candidate B — CV');
expect(mockedApi.get).toHaveBeenCalledTimes(2);
});
test('language changes translate loading, failure and loaded chrome without refetching anonymous CV content', async () => {
const first = deferred<any>();
mockedApi.get.mockImplementationOnce(() => first.promise).mockResolvedValueOnce({ data: { html: '<p>Untranslated candidate facts</p>', name: 'Candidate' } } as any);
renderPage();
switchLanguage('nb');
expect(screen.getByRole('heading', { level: 1, name: 'Offentlig CV' })).toBeInTheDocument();
expect(screen.getByRole('status')).toHaveTextContent(/Laster/i);
expect(mockedApi.get).toHaveBeenCalledTimes(1);
await act(async () => { first.reject({ response: { status: 500 } }); });
expect(await screen.findByText('Kunne ikke laste CV')).toBeInTheDocument();
switchLanguage('en');
expect(screen.getByText('Could not load CV')).toBeInTheDocument();
expect(mockedApi.get).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
expect(await screen.findByTitle('Public CV')).toHaveAttribute('srcdoc', '<p>Untranslated candidate facts</p>');
switchLanguage('nb');
expect(screen.getByTitle('Offentlig CV')).toHaveAttribute('srcdoc', '<p>Untranslated candidate facts</p>');
expect(screen.getByRole('link', { name: 'Last ned PDF' })).toHaveAttribute('href', '/api/public-cv/public-slug/pdf');
expect(mockedApi.get).toHaveBeenCalledTimes(2);
});
test('the public page removes only its own noindex metadata and ignores a response after unmount', async () => {
const read = deferred<any>();
mockedApi.get.mockImplementationOnce(() => read.promise);
const unrelated = document.createElement('meta');
unrelated.name = 'robots';
unrelated.content = 'index, follow';
document.head.appendChild(unrelated);
try {
const page = renderPage();
const owned = Array.from(document.head.querySelectorAll('meta[name="robots"]')).find((item) => item !== unrelated)!;
expect(owned).toHaveAttribute('content', 'noindex, nofollow');
expect(unrelated).toHaveAttribute('content', 'index, follow');
page.unmount();
expect(owned).not.toBeInTheDocument();
expect(unrelated).toBeInTheDocument();
document.title = 'Destination after unmount';
await act(async () => { read.resolve({ data: { html: '<p>Late response</p>', name: 'Late candidate' } }); });
expect(document.title).toBe('Destination after unmount');
expect(unrelated).toHaveAttribute('content', 'index, follow');
} finally { unrelated.remove(); }
});
test('leaving a loaded public CV restores the prior page title when its candidate title is still current', async () => {
document.title = 'Jobs dashboard — Jobbjakt';
mockedApi.get.mockResolvedValueOnce({ data: { html: '<p>Candidate document</p>', name: 'Candidate identity' } } as any);
const page = renderPage();
await screen.findByTitle('Public CV');
expect(document.title).toBe('Candidate identity — CV');
page.unmount();
expect(document.title).toBe('Jobs dashboard — Jobbjakt');
});
test('leaving a loaded public CV preserves a page title already replaced by another owner', async () => {
document.title = 'Jobs dashboard — Jobbjakt';
mockedApi.get.mockResolvedValueOnce({ data: { html: '<p>Candidate document</p>', name: 'Candidate identity' } } as any);
const page = renderPage();
await screen.findByTitle('Public CV');
expect(document.title).toBe('Candidate identity — CV');
document.title = 'Destination page — Jobbjakt';
page.unmount();
expect(document.title).toBe('Destination page — Jobbjakt');
});
test('returning from A through B to A ignores both earlier held reads until the current A response arrives', async () => {
const originalA = deferred<any>();
const previousB = deferred<any>();
const currentA = deferred<any>();
mockedApi.get.mockImplementationOnce(() => originalA.promise)
.mockImplementationOnce(() => previousB.promise)
.mockImplementationOnce(() => currentA.promise);
renderPage('/cv/slug-a');
fireEvent.click(screen.getByRole('button', { name: 'Open B' }));
fireEvent.click(screen.getByRole('button', { name: 'Open A' }));
expect(mockedApi.get.mock.calls.map(([url]) => url)).toEqual(['/public-cv/slug-a', '/public-cv/slug-b', '/public-cv/slug-a']);
await act(async () => { originalA.resolve({ data: { html: '<p>Stale original A</p>', name: 'Stale original A' } }); });
expect(screen.queryByTitle('Public CV')).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Download PDF' })).not.toBeInTheDocument();
expect(document.title).not.toContain('Stale original A');
await act(async () => { previousB.reject({ response: { status: 404 } }); });
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(screen.getByRole('status')).toHaveTextContent(/Loading/i);
await act(async () => { currentA.resolve({ data: { html: '<p>Current A document</p>', name: 'Current A candidate' } }); });
expect(await screen.findByTitle('Public CV')).toHaveAttribute('srcdoc', '<p>Current A document</p>');
expect(screen.getByRole('link', { name: 'Download PDF' })).toHaveAttribute('href', '/api/public-cv/slug-a/pdf');
expect(document.title).toBe('Current A candidate — CV');
expect(mockedApi.get).toHaveBeenCalledTimes(3);
});
+73 -41
View File
@@ -1,43 +1,78 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import { Box, Button } from "@mui/material";
import { api } from "../api";
import { useI18n } from "../i18n/I18nProvider";
import { PageHeader } from "../layout/PageLayout";
import ViewStateNotice from "../components/ViewStateNotice";
const A4_WIDTH_PX = (210 / 25.4) * 96;
const A4_HEIGHT_PX = (297 / 25.4) * 96;
type PublicCvRead = {
slug?: string;
html?: string;
name?: string;
error?: "missing" | "transient";
loading: boolean;
height: number;
};
// Anonymous read-only public CV at /cv/:slug. Renders the server-produced HTML in a sandboxed
// iframe. noindex is enforced server-side (X-Robots-Tag) and reinforced with a meta tag here.
export default function PublicCvPage() {
const { slug } = useParams();
const pdfUrl = `${api.defaults.baseURL}/public-cv/${slug}/pdf`;
const [html, setHtml] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const { t } = useI18n();
const pdfUrl = `${api.defaults.baseURL}/public-cv/${encodeURIComponent(slug ?? "")}/pdf`;
const [read, setRead] = useState<PublicCvRead>({ slug, loading: true, height: A4_HEIGHT_PX });
const [retry, setRetry] = useState(0);
const pending = useRef(false);
const headingRef = useRef<HTMLHeadingElement>(null);
const previousTitle = useRef("");
const ownedTitle = useRef<string | null>(null);
const [frameScale, setFrameScale] = useState(1);
const [frameHeight, setFrameHeight] = useState(A4_HEIGHT_PX);
// Hide the old document on the first render of a new route, before its effect runs.
const current = read.slug === slug ? read : null;
const loading = !current || current.loading;
const frameHeight = current?.height ?? A4_HEIGHT_PX;
const title = t("publicCvTitle");
const unavailable = current?.error === "missing";
const errorTitle = t(unavailable ? "publicCvUnavailableTitle" : "publicCvLoadFailedTitle");
useEffect(() => {
const originalTitle = document.title;
previousTitle.current = originalTitle;
const meta = document.createElement("meta");
meta.name = "robots";
meta.content = "noindex, nofollow";
document.head.appendChild(meta);
return () => {
meta.remove();
if (document.title === ownedTitle.current) document.title = originalTitle;
};
}, []);
useEffect(() => {
let alive = true;
api.get<{ html: string; name: string }>(`/public-cv/${slug}`)
.then((r) => { if (alive) { setHtml(r.data.html); if (r.data.name) document.title = `${r.data.name} — CV`; } })
pending.current = true;
setRead({ slug, loading: true, height: A4_HEIGHT_PX });
api.get<{ html: string; name: string }>(`/public-cv/${encodeURIComponent(slug ?? "")}`)
.then((r) => { if (alive) setRead({ slug, html: r.data.html, name: r.data.name, loading: false, height: A4_HEIGHT_PX }); })
.catch((err) => {
if (!alive) return;
// 404 = unknown slug or the owner made it private again. Anything else is a transient error.
setError(err?.response?.status === 404
? "This CV isn’t available. The link may be wrong, or the owner has made it private."
: "This CV can’t be loaded right now. Please try again in a moment.");
});
setRead({ slug, error: err?.response?.status === 404 ? "missing" : "transient", loading: false, height: A4_HEIGHT_PX });
})
.finally(() => { if (alive) pending.current = false; });
return () => { alive = false; };
}, [slug, retry]);
return () => {
alive = false;
document.head.removeChild(meta);
};
}, [slug]);
useEffect(() => {
const nextTitle = current?.name ? `${current.name} — CV` : previousTitle.current;
document.title = nextTitle;
ownedTitle.current = nextTitle;
}, [current?.name]);
useEffect(() => {
const updateScale = () => setFrameScale(Math.min(1, Math.max(0.1, (window.innerWidth - 32) / A4_WIDTH_PX)));
@@ -46,41 +81,38 @@ export default function PublicCvPage() {
return () => window.removeEventListener("resize", updateScale);
}, []);
if (error) {
return (
<div style={{ display: "flex", height: "100vh", alignItems: "center", justifyContent: "center", fontFamily: "system-ui", color: "#374151" }}>
{error}
</div>
);
}
if (html === null) {
return (
<div role="status" style={{ display: "flex", height: "100vh", alignItems: "center", justifyContent: "center", fontFamily: "system-ui", color: "#4b5563" }}>
Loading…
</div>
);
}
return (
<div style={{ minHeight: "100vh", background: "#e9edf2", padding: "24px 16px", display: "flex", flexDirection: "column", alignItems: "center", gap: 16, overflowX: "hidden" }}>
<a href={pdfUrl} download style={{ padding: "10px 16px", borderRadius: 8, background: "#1d4ed8", color: "#fff", font: "600 14px system-ui", textDecoration: "none" }}>
Download PDF
</a>
<div
<Box component="main" sx={{ minHeight: "100vh", bgcolor: "background.default", color: "text.primary", py: 3, px: "16px", display: "flex", flexDirection: "column", alignItems: "center", gap: 2, minWidth: 0 }}>
<Box sx={{ width: "100%", maxWidth: A4_WIDTH_PX, minWidth: 0 }}>
<PageHeader title={title} titleRef={headingRef} description={t("publicCvDescription")}
actions={current?.html !== undefined ? <Button component="a" variant="contained" href={pdfUrl} download>{t("publicCvDownloadPdf")}</Button> : undefined} />
<ViewStateNotice compact loading={loading} title={errorTitle}
error={current?.error ? { kind: "unavailable", message: errorTitle, retryable: !unavailable } : null}
description={t(unavailable ? "publicCvUnavailableBody" : "publicCvLoadFailedBody")}
onRetry={() => {
if (pending.current || loading) return;
pending.current = true;
headingRef.current?.focus();
setRead({ slug, loading: true, height: A4_HEIGHT_PX });
setRetry((value) => value + 1);
}} />
</Box>
{current?.html !== undefined && <div
data-testid="public-cv-frame-container"
style={{ width: A4_WIDTH_PX * frameScale, height: frameHeight * frameScale, maxWidth: "100%", overflow: "hidden", boxShadow: "0 8px 30px rgba(0,0,0,0.18)" }}
>
<iframe
title="Public CV"
srcDoc={html}
title={title}
srcDoc={current.html}
sandbox="allow-same-origin"
onLoad={(event) => {
const documentHeight = event.currentTarget.contentDocument?.documentElement.scrollHeight ?? A4_HEIGHT_PX;
setFrameHeight(Math.max(A4_HEIGHT_PX, documentHeight));
setRead((previous) => previous.slug === slug && previous.html === current.html && !previous.loading
? { ...previous, height: Math.max(A4_HEIGHT_PX, documentHeight) } : previous);
}}
style={{ width: A4_WIDTH_PX, height: frameHeight, transform: `scale(${frameScale})`, transformOrigin: "top left", border: "none", background: "#fff", display: "block" }}
/>
</div>
</div>
</div>}
</Box>
);
}