Files
jobtrackingapp/job-tracker-ui/e2e/phase7-career-editor.spec.ts

408 lines
29 KiB
TypeScript

import { expect, test as base, type APIRequestContext, type APIResponse, type Page, type TestInfo } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
import { translations } from '../src/i18n/translations';
import type { StructuredCvProfile } from '../src/profileCv';
const apiUrl = 'http://localhost:5302/api';
const password = 'E2ePassword123!';
type Owner = { id: string; headers: Record<string, string> };
type Sample = { language: 'en' | 'nb'; theme: 'light' | 'dark'; width: number; height: number };
type CareerReadback = { profile: StructuredCvProfile; completeness: unknown; cvText: string | null };
async function json<T>(response: APIResponse, status = 200): Promise<T> {
expect(response.status(), `${response.url()} must return ${status}`).toBe(status);
return response.json();
}
async function login(api: APIRequestContext, email: string): Promise<Owner> {
expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200);
const me = await json<{ id: string; sessionContext: string }>(await api.get(`${apiUrl}/auth/me`));
const csrf = (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN');
expect(csrf).toBeDefined();
return { id: me.id, headers: { 'X-CSRF-TOKEN': csrf!.value, 'X-Session-Context': me.sessionContext } };
}
const test = base.extend<{ owner: Owner }>({
owner: async ({ page, context, playwright }, provide) => {
const admin = await playwright.request.newContext();
try {
const adminOwner = await login(admin, 'e2e@example.test');
const email = `p7-career-editor-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`;
expect((await admin.post(`${apiUrl}/users`, { headers: adminOwner.headers, data: { email, password, roles: [] } })).status()).toBe(200);
const owner = await login(page.request, email);
await context.route('**/*', route => {
const url = new URL(route.request().url());
return ['localhost', '127.0.0.1'].includes(url.hostname) || ['data:', 'blob:', 'about:'].includes(url.protocol)
? route.continue() : route.abort();
});
await provide(owner);
} finally { await admin.dispose(); }
},
});
async function seed(page: Page, owner: Owner) {
const profile = {
version: '1',
metadata: { fields: { 'contact.fullName': { confidence: null, sourcePage: null, method: 'manual',
sourceSnippet: 'A long owner-reviewed source note about recorded work and qualifications, retained without adding a new claim.', reviewState: 'reviewed' } } },
contact: { fullName: 'Åse Nordlys — owner-reviewed professional identity with an intentionally long recorded name',
headline: 'Experienced software engineer with recorded delivery, collaboration and professional responsibility across Norwegian organisations',
email: 'owner-reviewed-professional-contact@example.test', phone: '+47 900 00 001', location: 'Trondheim, Trøndelag, Norge — remote and hybrid work',
website: 'https://example.test/portfolio/recorded-professional-work', linkedIn: 'https://www.linkedin.com/in/reviewed-professional-identity',
gitHub: 'https://github.com/reviewed-professional-identity', links: [{ label: 'Detailed portfolio of recorded work and owner-reviewed outcomes',
url: 'https://example.test/recorded-work/long-reviewed-professional-source-reference' }] },
summary: ['Owner-reviewed professional summary with specific recorded delivery and collaboration.', 'Another factual summary line, kept separate.'],
jobs: [{ id: 'editor-job-source', title: 'Senior software engineer with responsibility for recorded platform delivery and professional collaboration',
company: 'Nordlys programvare og dokumenterte tjenester AS', location: 'Trondheim and remote collaboration across Norway',
start: 'Jan 2020', end: 'Dec 2022', bullets: ['Recorded delivery with owner-reviewed implementation details.', 'Recorded collaboration and operational responsibility.'], skills: ['C#', 'React', 'Norsk'] }],
education: [{ id: 'editor-education-source', qualification: 'Computer science and software engineering with a recorded specialisation in reliable services',
qualificationLevel: 'Bachelor', institution: 'Recorded university department for computer science and software engineering',
location: 'Trondheim, Norge', start: 'Aug 2016', end: 'Jun 2019', details: ['Recorded qualification and reviewed project work.'] }],
projects: [{ id: 'editor-project-source', name: 'Recorded professional project', role: 'Developer', start: '2023-01', end: '2023-12',
bullets: ['Recorded project evidence.'], skills: ['React'] }],
certifications: [{ id: 'editor-certification-source', name: 'Recorded certification', issuer: 'Fixture Academy', date: 'Jun 2022', details: ['Reviewed certification detail.'] }],
skills: ['C#', 'React', 'SQL', 'Norsk'], interests: ['Recorded community participation'],
languages: [{ name: 'Norwegian Sign Language — recorded professional communication experience', level: 'Professional working proficiency with reviewed workplace communication',
notes: 'An owner-reviewed language note retained with the source and kept separate from CV-specific presentation.' }],
awards: ['Recorded award'], publications: ['Recorded publication'], organisations: ['Recorded organisation'], references: ['Available by owner agreement'],
otherSections: [{ title: 'Owner-reviewed custom career information with a deliberately long descriptive section title',
items: ['Recorded custom fact with relevant professional context.', 'Another recorded custom fact, preserved during unrelated contact editing.'] }],
};
const raw = 'Exact original career source — æøå\nRecorded work and qualifications.\n';
await json<CareerReadback>(await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers, data: { profile, cvText: raw } }));
return { canonical: await json<CareerReadback>(await page.request.get(`${apiUrl}/career/profile`)),
history: await json<unknown[]>(await page.request.get(`${apiUrl}/career/profile/versions`)) };
}
async function enter(page: Page, owner: Owner, sample: Sample) {
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers: owner.headers,
data: { language: sample.language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204);
await page.addInitScript(({ language, theme }) => {
localStorage.setItem('uiLanguage', language);
localStorage.setItem('jobtracker.themeMode', theme);
}, sample);
await page.setViewportSize({ width: sample.width, height: sample.height });
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto('/career?section=profile&source=phase7-editor#preserved');
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.getByLabel(translations[sample.language].profileCvContactPhone, { exact: true })).toHaveValue('+47 900 00 001');
}
async function navigateSection(page: Page, language: Sample['language'], section: 'profile' | 'import') {
const t = translations[language];
const label = section === 'profile' ? t.careerWorkspaceProfileTab : t.careerWorkspaceImportTab;
const selector = page.getByRole('combobox', { name: t.careerWorkspaceNavigation, exact: true });
if (await selector.isVisible()) {
await selector.click();
await page.getByRole('option', { name: label, exact: true }).click();
} else await page.getByRole('tab', { name: label, exact: true }).click();
const url = new URL(page.url());
expect(url.searchParams.get('section')).toBe(section);
expect(url.searchParams.get('source')).toBe('phase7-editor');
expect(url.hash).toBe('#preserved');
}
function escaped(value: string) { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
async function openRaw(page: Page, language: Sample['language']) {
const field = page.getByLabel(translations[language].profileCvTextLabel, { exact: true });
const disclosure = page.getByRole('button', { name: new RegExp(escaped(translations[language].profileCvRawPanelTitle)) });
if (await disclosure.getAttribute('aria-expanded') !== 'true') await disclosure.click();
await expect(disclosure).toHaveAttribute('aria-expanded', 'true');
await expect(field).toBeVisible();
await field.scrollIntoViewIfNeeded();
await expect(field).toBeInViewport({ ratio: 0.5 });
await expect(disclosure).toHaveAttribute('aria-expanded', 'true');
return field;
}
async function settle(page: Page) {
await page.evaluate(async () => { await document.fonts.ready; await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); });
}
async function capture(page: Page, info: TestInfo, sample: Sample, state: string) {
await settle(page);
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();
const style = getComputedStyle(element);
return { tag: element.tagName.toLowerCase(), id: element.id || null, class: element.getAttribute('class'),
text: (element.textContent ?? '').replace(/\s+/g, ' ').trim().slice(0, 120), left: rect.left, right: rect.right, width: rect.width,
minWidth: style.minWidth, display: style.display, clientWidth: element.clientWidth, scrollWidth: element.scrollWidth };
})
.filter(element => element.width > 0 && (element.right > innerWidth + 1 || element.left < -1))
.slice(0, 20),
visibleFields: [...document.querySelectorAll('#career-profile-editor input, #career-profile-editor textarea, #career-cv-import input, #career-cv-import textarea')]
.filter(element => element.getClientRects().length > 0 && getComputedStyle(element).visibility !== 'hidden' && element.getAttribute('aria-hidden') !== 'true')
.map(element => { const rect = element.getBoundingClientRect(); return { name: element.getAttribute('aria-label') || (element as HTMLInputElement).labels?.[0]?.textContent,
left: rect.left, right: rect.right, width: rect.width }; }),
}));
const axe = await new AxeBuilder({ page }).options({ rules: { 'target-size': { enabled: true } } }).analyze();
const name = `p7-career-editor-${sample.language}-${sample.theme}-${sample.width}-${state}`;
await info.attach(`${name}-audit`, { body: JSON.stringify({ sample, state, geometry, ariaSnapshot: await page.getByRole('main').ariaSnapshot(),
axe: { scope: 'All enabled rules plus target-size; complete findings retained', violations: axe.violations, incomplete: axe.incomplete,
passes: axe.passes.map(rule => ({ id: rule.id, nodes: rule.nodes.length })) },
qualifications: ['Automated Chromium evidence only; no human screen reader or physical touch/software keyboard',
'No actual browser zoom, production font/photo, PDF, model/provider or production acceptance'],
}), contentType: 'application/json' });
await info.attach(name, { body: await page.screenshot({ fullPage: true, animations: 'disabled' }), contentType: 'image/png' });
expect(geometry.rootOverflow).toBe(0);
expect(geometry.visibleFields.length).toBeGreaterThan(0);
if (state === 'held-save-raw-import') expect(geometry.visibleFields.some(field => field.name === translations[sample.language].profileCvTextLabel)).toBe(true);
expect(geometry.visibleFields.every(field => field.left >= 0 && field.right <= geometry.width && field.width > 0)).toBe(true);
expect(axe.violations).toEqual([]);
return axe;
}
test('[P7-03 PRES-C01 C02 E08] structured and raw drafts survive English to Bokmål without canonical refetch or write', async ({ page, owner }, info) => {
test.setTimeout(90_000);
const before = await seed(page, owner);
const sample: Sample = { language: 'en', theme: 'light', width: 1440, height: 900 };
let profileReads = 0;
const readCompletions: Promise<void>[] = [];
await page.route('**/api/career/profile', async route => {
if (route.request().method() !== 'GET') return route.continue();
profileReads++;
const completed = (async () => { const response = await route.fetch(); await route.fulfill({ response }); })();
readCompletions.push(completed);
await completed;
});
const canonicalWrites: unknown[] = [];
page.on('request', request => { if (request.url() === `${apiUrl}/career/profile` && request.method() === 'PUT') canonicalWrites.push(request.postDataJSON()); });
await enter(page, owner, sample);
const draftPhone = '+47 900 00 099';
const draftRaw = 'Unsaved owner-reviewed raw draft — æøå\nThis text is not persisted yet.\n';
await page.getByLabel(translations.en.profileCvContactPhone, { exact: true }).fill(draftPhone);
await navigateSection(page, 'en', 'import');
await (await openRaw(page, 'en')).fill(draftRaw);
await navigateSection(page, 'en', 'profile');
await expect(page.getByLabel(translations.en.profileCvContactPhone, { exact: true })).toHaveValue(draftPhone);
const readsBeforeLocale = profileReads;
const preferenceSaved = page.waitForResponse(response => response.url() === `${apiUrl}/auth/preferences/language` && response.request().method() === 'PUT');
await page.locator('header').getByRole('button', { name: 'Norsk', exact: true }).click();
expect((await preferenceSaved).status()).toBe(204);
await expect(page.locator('html')).toHaveAttribute('lang', 'nb-NO');
await expect(page.getByLabel(translations.nb.profileCvContactPhone, { exact: true })).toBeVisible();
await settle(page);
await Promise.all(readCompletions);
await settle(page);
const observedPhone = await page.getByLabel(translations.nb.profileCvContactPhone, { exact: true }).inputValue();
await navigateSection(page, 'nb', 'import');
const observedRaw = await (await openRaw(page, 'nb')).inputValue();
const after = await json<CareerReadback>(await page.request.get(`${apiUrl}/career/profile`));
const historyAfter = await json<unknown[]>(await page.request.get(`${apiUrl}/career/profile/versions`));
await info.attach('native-career-editor-locale-drafts', { body: JSON.stringify({ readsBeforeLocale, readsAfterLocale: profileReads,
expected: { phone: draftPhone, raw: draftRaw }, observed: { phone: observedPhone, raw: observedRaw }, canonicalWrites, before, after, historyAfter,
qualification: 'Preference PUT204 is real; canonical GETs are proxied to actual HTTP for completion tracking. No source response is fabricated.' }), contentType: 'application/json' });
await capture(page, info, { ...sample, language: 'nb' }, 'locale-retained-raw-draft');
expect(readsBeforeLocale).toBeGreaterThan(0);
expect(profileReads).toBe(readsBeforeLocale);
expect(observedPhone).toBe(draftPhone);
expect(observedRaw).toBe(draftRaw);
expect(canonicalWrites).toEqual([]);
expect(after).toEqual(before.canonical);
expect(historyAfter).toEqual(before.history);
});
test('[P7-03 PRES-C01 C02 E08] held save locks edits and duplicate writes; failed save retains its snapshot for explicit identical-body retry', async ({ page, owner }, info) => {
test.setTimeout(120_000);
const before = await seed(page, owner);
const sample: Sample = { language: 'en', theme: 'light', width: 390, height: 844 };
const t = translations.en;
const payloads: unknown[] = [];
let release!: () => void;
const held = new Promise<void>(resolve => { release = resolve; });
await page.route('**/api/career/profile', async route => {
if (route.request().method() !== 'PUT') return route.continue();
payloads.push(route.request().postDataJSON());
if (payloads.length === 1) {
await held;
return route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ message: 'Controlled career save unavailable' }) });
}
return route.continue();
});
await enter(page, owner, sample);
const draftPhone = '+47 900 00 088';
const draftSummary = 'Owner-reviewed pending summary.';
const draftRaw = 'Pending owner raw source — æøå\nRetain this exact draft after failure.\n';
await page.getByLabel(t.profileCvContactPhone, { exact: true }).fill(draftPhone);
const summary = page.getByLabel(t.profileCvStructuredSummary, { exact: true });
await summary.fill(draftSummary);
await navigateSection(page, 'en', 'import');
await (await openRaw(page, 'en')).fill(draftRaw);
await navigateSection(page, 'en', 'profile');
await summary.evaluate((element: HTMLTextAreaElement) => element.setSelectionRange(0, 5));
const save = page.getByRole('button', { name: new RegExp(`^(?:${escaped(t.profileSaveChanges)}|${escaped(t.saving)})$`) });
const firstResponse = page.waitForResponse(response => response.url() === `${apiUrl}/career/profile` && response.request().method() === 'PUT');
let pending!: { fields: Array<{ name: string | null; disabled: boolean }>; actions: Array<{ name: string; disabled: boolean }>;
rawDisabled: boolean; saveDisabled: boolean; savingLabelCount: number; writeCount: number; summaryAfterAttempt: string };
try {
await save.focus();
await page.keyboard.press('Enter');
await expect.poll(() => payloads.length).toBe(1);
await settle(page);
const editor = page.locator('#career-profile-editor');
const actionNames = [t.profileCvAddLink, t.profileCvStructuredAddLanguage, t.profileCvStructuredAddJob, t.profileCvStructuredAddEducation,
t.profileCvStructuredAddOtherSection, t.profileCvStructuredRemove, t.profileCvDeleteCustomLink.replace('{index}', '1'),
t.careerProfileRemoveJob.replace('{index}', '1'), t.careerProfileRemoveEducation.replace('{index}', '1'),
t.careerProfileRemoveLanguage.replace('{index}', '1'), t.careerProfileRemoveOtherSection.replace('{index}', '1'),
t.richTextBold, t.richTextItalic, t.richTextUnderline, t.richTextLink];
const fields = await editor.locator('input:not([type="file"]),textarea:not([aria-hidden="true"])').evaluateAll(elements => elements
.filter(element => element.getClientRects().length > 0 && getComputedStyle(element).visibility !== 'hidden')
.map(element => ({ name: element.getAttribute('aria-label') || (element as HTMLInputElement).labels?.[0]?.textContent || null,
disabled: (element as HTMLInputElement).disabled })));
const actions = (await editor.getByRole('button').evaluateAll(elements => elements.map(element => ({
name: element.getAttribute('aria-label') || (element.textContent ?? '').replace(/\s+/g, ' ').trim(), disabled: (element as HTMLButtonElement).disabled,
})))).filter(action => actionNames.includes(action.name));
const rawDisabled = await page.getByLabel(t.profileCvTextLabel, { exact: true }).isDisabled();
const saveDisabled = await save.isDisabled();
const savingLabelCount = await page.getByRole('button', { name: t.saving, exact: true }).count();
// A native disabled button ignores click(); an enabled formatting button would mutate
// the held snapshot's draft. These attempts remain local to this disposable owner.
await save.evaluate((element: HTMLButtonElement) => element.click());
await editor.getByRole('button', { name: t.richTextBold, exact: true }).first().evaluate((element: HTMLButtonElement) => element.click());
await settle(page);
pending = { fields, actions, rawDisabled, saveDisabled, savingLabelCount, writeCount: payloads.length, summaryAfterAttempt: await summary.inputValue() };
await info.attach('native-career-editor-pending-lock', { body: JSON.stringify({ pending, heldBody: payloads[0] }), contentType: 'application/json' });
await capture(page, info, sample, 'held-save');
await navigateSection(page, 'en', 'import');
await expect(await openRaw(page, 'en')).toBeDisabled();
try { await capture(page, info, sample, 'held-save-raw-import'); }
finally { await navigateSection(page, 'en', 'profile'); }
} finally { release(); }
expect((await firstResponse).status()).toBe(500);
await expect(page.getByRole('button', { name: t.profileSaveChanges, exact: true })).toBeEnabled();
const failedPhone = await page.getByLabel(t.profileCvContactPhone, { exact: true }).inputValue();
const failedSummary = await summary.inputValue();
const failedRaw = await page.getByLabel(t.profileCvTextLabel, { exact: true }).inputValue();
const failureAlerts = await page.getByRole('main').getByRole('alert').allTextContents();
const afterFailure = await json<CareerReadback>(await page.request.get(`${apiUrl}/career/profile`));
const historyAfterFailure = await json<unknown[]>(await page.request.get(`${apiUrl}/career/profile/versions`));
await capture(page, info, sample, 'failed-save-retained-draft');
const retryResponse = page.waitForResponse(response => response.url() === `${apiUrl}/career/profile` && response.request().method() === 'PUT');
await page.getByRole('button', { name: t.profileSaveChanges, exact: true }).focus();
await page.keyboard.press('Enter');
expect((await retryResponse).status()).toBe(200);
const afterRetry = await json<CareerReadback>(await page.request.get(`${apiUrl}/career/profile`));
const historyAfterRetry = await json<unknown[]>(await page.request.get(`${apiUrl}/career/profile/versions`));
await info.attach('native-career-editor-save-retry', { body: JSON.stringify({ pending: pending!, payloads, failedDraft: { phone: failedPhone, summary: failedSummary, raw: failedRaw },
failureAlerts, before, afterFailure, historyAfterFailure, afterRetry, historyAfterRetry,
qualification: 'First500 is controlled transport failure; retry200 and canonical/history readbacks use actual SQLite HTTP. No canonical stale-write protection is claimed.' }), contentType: 'application/json' });
expect(pending!.fields.length).toBeGreaterThan(20);
expect(pending!.fields.every(field => field.disabled)).toBe(true);
expect(pending!.actions.length).toBeGreaterThanOrEqual(26);
expect(pending!.actions.every(action => action.disabled)).toBe(true);
expect(pending!.rawDisabled).toBe(true);
expect(pending!.saveDisabled).toBe(true);
expect(pending!.savingLabelCount).toBe(1);
expect(pending!.writeCount).toBe(1);
expect(pending!.summaryAfterAttempt).toBe(draftSummary);
expect(failedPhone).toBe(draftPhone);
expect(failedSummary).toBe(draftSummary);
expect(failedRaw).toBe(draftRaw);
expect(failureAlerts.some(alert => alert.includes(t.profileUpdateFailed) && alert.includes('Controlled career save unavailable'))).toBe(true);
expect(afterFailure).toEqual(before.canonical);
expect(historyAfterFailure).toEqual(before.history);
expect(payloads).toHaveLength(2);
expect(payloads[1]).toEqual(payloads[0]);
expect(afterRetry.profile.contact.phone).toBe(draftPhone);
expect(afterRetry.profile.summary).toEqual([draftSummary]);
expect(afterRetry.cvText).toBe(draftRaw);
expect(historyAfterRetry).toHaveLength(before.history.length + 1);
await page.reload();
await expect(page.getByLabel(t.profileCvContactPhone, { exact: true })).toHaveValue(draftPhone);
await expect(page.getByLabel(t.profileCvStructuredSummary, { exact: true })).toHaveValue(draftSummary);
});
test('[P7-03 PRES-C01 C02 E08] actual validation400 retains draft and source; corrected explicit save persists once', async ({ page, owner }, info) => {
test.setTimeout(90_000);
const before = await seed(page, owner);
const sample: Sample = { language: 'nb', theme: 'dark', width: 390, height: 844 };
const t = translations.nb;
const payloads: unknown[] = [];
page.on('request', request => { if (request.url() === `${apiUrl}/career/profile` && request.method() === 'PUT') payloads.push(request.postDataJSON()); });
await enter(page, owner, sample);
const website = page.getByLabel(t.profileCvContactWebsite, { exact: true });
const prefix = 'https://example.test/';
const overLimit = prefix + 'a'.repeat(501 - prefix.length);
await website.fill(overLimit);
const rejectedResponse = page.waitForResponse(response => response.url() === `${apiUrl}/career/profile` && response.request().method() === 'PUT');
await page.getByRole('button', { name: t.profileSaveChanges, exact: true }).focus();
await page.keyboard.press('Enter');
const rejected = await rejectedResponse;
const rejectedBody = await rejected.text();
expect(rejected.status()).toBe(400);
await expect(page.getByRole('button', { name: t.profileSaveChanges, exact: true })).toBeEnabled();
const retainedWebsite = await website.inputValue();
const failureAlerts = await page.getByRole('main').getByRole('alert').allTextContents();
const afterFailure = await json<CareerReadback>(await page.request.get(`${apiUrl}/career/profile`));
const historyAfterFailure = await json<unknown[]>(await page.request.get(`${apiUrl}/career/profile/versions`));
await capture(page, info, sample, 'actual-validation400-retained-draft');
const corrected = 'https://example.test/recorded-work/corrected-reviewed-reference';
await website.fill(corrected);
const acceptedResponse = page.waitForResponse(response => response.url() === `${apiUrl}/career/profile` && response.request().method() === 'PUT');
await page.getByRole('button', { name: t.profileSaveChanges, exact: true }).focus();
await page.keyboard.press('Enter');
expect((await acceptedResponse).status()).toBe(200);
const afterSave = await json<CareerReadback>(await page.request.get(`${apiUrl}/career/profile`));
const historyAfterSave = await json<unknown[]>(await page.request.get(`${apiUrl}/career/profile/versions`));
await info.attach('native-career-editor-real-validation', { body: JSON.stringify({ rejectedStatus: rejected.status(), rejectedBody,
requestedLength: overLimit.length, retainedWebsite, failureAlerts, payloads, before, afterFailure, historyAfterFailure, afterSave, historyAfterSave,
qualification: 'The501-character website is rejected by the actual existing500-character short-field limit before persistence. No transport fault substitutes for this validation response.' }), contentType: 'application/json' });
expect(rejectedBody).toContain('A contact field exceeds the allowed length.');
expect(retainedWebsite).toBe(overLimit);
expect(failureAlerts.some(alert => alert.includes(t.profileUpdateFailed) && alert.includes('A contact field exceeds the allowed length.'))).toBe(true);
expect(afterFailure).toEqual(before.canonical);
expect(historyAfterFailure).toEqual(before.history);
expect(payloads).toHaveLength(2);
expect(afterSave.profile.contact.website).toBe(corrected);
expect(afterSave.cvText).toBe(before.canonical.cvText);
expect(historyAfterSave).toHaveLength(before.history.length + 1);
await page.reload();
await expect(page.getByLabel(t.profileCvContactWebsite, { exact: true })).toHaveValue(corrected);
});
const matrix: Sample[] = [
{ language: 'nb', theme: 'dark', width: 320, height: 844 },
{ language: 'en', theme: 'light', width: 360, height: 800 },
{ language: 'nb', theme: 'light', width: 390, height: 844 },
{ language: 'en', theme: 'dark', width: 768, height: 1024 },
{ language: 'nb', theme: 'dark', width: 1024, height: 768 },
{ language: 'en', theme: 'light', width: 1280, height: 320 },
{ language: 'nb', theme: 'light', width: 1440, height: 900 },
{ language: 'en', theme: 'dark', width: 1920, height: 1080 },
];
for (const sample of matrix) {
test(`[P7-03 PRES-C01 C02 E08] structured editor headings, contextual actions and geometry ${sample.language}/${sample.theme}/${sample.width}x${sample.height}`, async ({ page, owner }, info) => {
test.setTimeout(90_000);
const before = await seed(page, owner);
await enter(page, owner, sample);
const t = translations[sample.language];
const writes: string[] = [];
page.on('request', request => { if (request.url().startsWith(apiUrl) && !['GET', 'HEAD', 'OPTIONS'].includes(request.method())) writes.push(request.method() + ' ' + request.url()); });
// Capture the existing rendered baseline even when a new semantic requirement
// fails in RED. This retains actual geometry, axe results and pixels for review.
const axe = await capture(page, info, sample, 'structured-editor');
await expect(page.getByRole('heading', { level: 1 })).toHaveCount(1);
await expect(page.getByRole('heading', { level: 2, name: t.profileCvStructuredEditor, exact: true })).toBeVisible();
for (const label of [t.careerProfileContactSection, t.profileCvStructuredLanguages, t.profileCvStructuredJobs, t.profileCvStructuredEducation, t.profileCvStructuredOtherSections]) {
await expect(page.getByRole('heading', { level: 3, name: label, exact: true })).toBeVisible();
}
for (const label of [t.careerProfileRemoveLanguage, t.careerProfileRemoveJob, t.careerProfileRemoveEducation, t.careerProfileRemoveOtherSection]) {
const remove = page.getByRole('button', { name: label.replace('{index}', '1'), exact: true });
await expect(remove).toHaveCount(1);
await remove.focus();
await expect(remove).toBeFocused();
}
expect(axe.violations).toEqual([]);
expect(await json<CareerReadback>(await page.request.get(`${apiUrl}/career/profile`))).toEqual(before.canonical);
expect(await json<unknown[]>(await page.request.get(`${apiUrl}/career/profile/versions`))).toEqual(before.history);
expect(writes).toEqual([]);
});
}