Files
jobtrackingapp/job-tracker-ui/e2e/cv-section-scope.spec.ts
T
2026-10-07 13:53:54 +02:00

264 lines
21 KiB
TypeScript

import { expect, test, type APIRequestContext, type Locator, type Page, type TestInfo } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
import type { CvOutline, CvVariant } from '../src/cvBuilder';
import { translations } from '../src/i18n/translations';
const apiUrl = 'http://localhost:5302/api';
const password = 'E2ePassword123!';
async function login(api: APIRequestContext, email: string) {
expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200);
const me = await (await api.get(`${apiUrl}/auth/me`)).json();
return { 'X-CSRF-TOKEN': (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value, 'X-Session-Context': me.sessionContext as string };
}
async function capture(scope: Locator, info: TestInfo, state: string) {
await scope.scrollIntoViewIfNeeded();
await scope.screenshot({ path: info.outputPath(`${state}.png`), animations: 'disabled' });
}
async function scan(page: Page, info: TestInfo, state: string, region: string) {
const result = await new AxeBuilder({ page }).include(`[role="region"][aria-label=${JSON.stringify(region)}]`)
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
await info.attach(`${state}-axe`, { body: JSON.stringify({ violations: result.violations, incomplete: result.incomplete }), contentType: 'application/json' });
expect(result.violations).toEqual([]);
}
for (const [mode, language] of [['light', 'en'], ['dark', 'nb'], ['light', 'nb'], ['dark', 'en']] as const) {
test(`[PRES-C05 C07 C10 W05 E08] ${mode}/${language} CV field scope, source resets, entry order and custom content`, async ({ page, context, playwright }, info) => {
test.setTimeout(180_000);
const t = translations[language];
const named = (key: keyof typeof t, entry: string) => t[key].replace('{entry}', entry);
const sectionLabel = (key: keyof typeof t, section: string) => t[key].replace('{section}', section);
const resetLabel = (field: string) => t.cvEditorResetField.replace('{field}', field);
const admin = await playwright.request.newContext();
try {
const adminHeaders = await login(admin, 'e2e@example.test');
const email = `p510-${Date.now()}-${mode}-${language}@example.test`;
expect((await admin.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email, password, roles: [] } })).status()).toBe(200);
const headers = 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 context.addInitScript(({ mode, language }) => { localStorage.setItem('jobtracker.themeMode', mode); localStorage.setItem('uiLanguage', language); }, { mode, language });
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers, data: { language: language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204);
const profile = {
version: '1', contact: { fullName: 'Åse Eksempel', headline: 'Canonical career information', location: 'Oslo, Norge' },
summary: ['Verified source experience.'],
jobs: [
{ id: 'job-1', title: 'Platform engineer', company: 'Nordlys AS', startDate: '2021-01', isCurrent: true, bullets: ['Maintained verified, accessible workflows.'] },
{ id: 'job-2', title: 'Systems engineer', company: 'Fjord AS', startDate: '2019-01', endDate: '2020-12', bullets: ['Reviewed documented integrations.'] },
{ id: 'job-3', title: 'Software developer', company: 'Aurora AS', startDate: '2017-01', endDate: '2018-12', bullets: ['Built source-grounded software.'] },
],
education: [{ id: 'education-1', qualification: 'Computer science', institution: 'Fixture University', startDate: '2013-08', endDate: '2016-06' }],
projects: [{ id: 'project-1', name: 'Evidence workspace', bullets: ['Documented source-grounded reviews.'] }], skills: ['C#', 'React', 'Norsk'],
};
expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { profile } })).status()).toBe(200);
const source = await (await page.request.get(`${apiUrl}/career/profile`)).json();
const outline: CvOutline = await (await page.request.get(`${apiUrl}/cv/outline`)).json();
const entries = outline.sections.find(section => section.key === 'experience')!.entries;
const first = entries.find(entry => entry.title === 'Platform engineer')!;
const second = entries.find(entry => entry.title === 'Systems engineer')!;
const third = entries.find(entry => entry.title === 'Software developer')!;
expect(entries).toHaveLength(3);
const settings = { themeId: 'minimal', sections: [{ key: 'summary' }, { key: 'experience', itemOrder: [first.key!, second.key!, third.key!] }, { key: 'education' }, { key: 'projects' }, { key: 'skills', hidden: true }] };
const create = async (name: string): Promise<CvVariant> => {
const response = await page.request.post(`${apiUrl}/cv/variants`, { headers, data: { name, settings } });
expect(response.status()).toBe(200); return response.json();
};
const cv = await create('Åse — this CV only');
const sibling = await create('Åse — untouched sibling');
const read = async (id = cv.id): Promise<CvVariant> => (await page.request.get(`${apiUrl}/cv/variants/${id}`)).json();
const saved = async () => { await expect(page.getByText(t.cvEditorSaved, { exact: true })).toBeVisible(); };
const unchanged = async (expectedSource = source) => {
expect(await (await page.request.get(`${apiUrl}/career/profile`)).json()).toEqual(expectedSource);
expect(await read(sibling.id)).toEqual(sibling);
};
const experience = page.getByRole('group', { name: t.cvSectionExperience, exact: true });
const entry = (title: string) => experience.getByRole('group', { name: title, exact: true });
const openExperience = async () => {
await page.getByRole('button', { name: sectionLabel('cvEditorExpandSection', t.cvSectionExperience), exact: true }).click();
await expect(entry(first.title!)).toBeVisible();
};
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto(`/career/builder/${cv.id}`);
await expect(page.getByLabel(t.cvEditorName, { exact: true })).toHaveValue(cv.name);
await openExperience(); await saved();
await expect(entry(first.title!).getByText(t.cvEditorUsingSource, { exact: true }).first()).toBeVisible();
await expect(experience.getByText(t.cvEditorEntryScopeHelp, { exact: true })).toBeVisible();
await capture(entry(first.title!), info, 'desktop-source-entry');
await scan(page, info, 'desktop-source-entry', t.cvEditorEditingRegion);
// The native UI writes only the current variant's lens over Career Profile.
await entry(first.title!).getByLabel(t.cvEditorEntryTitle, { exact: true }).fill('CV-specific platform engineer');
const tailored = entry('CV-specific platform engineer');
await tailored.getByLabel(t.cvEditorEntrySubtitle, { exact: true }).fill('CV-specific employer — æøå');
await tailored.getByLabel(t.cvEditorBulletsHelp, { exact: true }).fill('CV-specific evidence.\nSecond tailored bullet.');
await expect.poll(async () => (await read()).settings.overrides[first.key!]).toMatchObject({ title: 'CV-specific platform engineer', subtitle: 'CV-specific employer — æøå', bullets: ['CV-specific evidence.', 'Second tailored bullet.'] });
await saved(); await unchanged();
await expect(tailored.getByText(t.cvEditorCustomized, { exact: true }).first()).toBeVisible();
const move = tailored.getByRole('button', { name: named('cvEditorMoveNamedEntryDown', 'CV-specific platform engineer'), exact: true });
await move.focus(); await page.keyboard.press('Enter');
await expect(move).toBeFocused();
const movedOrder = [second.key!, first.key!, third.key!];
await expect.poll(async () => (await read()).settings.sections.find(section => section.key === 'experience')?.itemOrder).toEqual(movedOrder);
await saved();
await tailored.getByRole('button', { name: named('cvEditorHideEntry', 'CV-specific platform engineer'), exact: true }).click();
await expect.poll(async () => (await read()).settings.overrides[first.key!].hidden).toBe(true);
await expect(tailored.getByLabel(t.cvEditorEntryTitle, { exact: true })).toBeEditable();
await expect(tailored.getByRole('button', { name: named('cvEditorShowEntry', 'CV-specific platform engineer'), exact: true })).toBeVisible();
await tailored.getByLabel(t.cvEditorEntrySubtitle, { exact: true }).fill('Edited while omitted — æøå');
await expect.poll(async () => (await read()).settings.overrides[first.key!].subtitle).toBe('Edited while omitted — æøå');
await saved();
for (const [field, label, value] of [
['bullets', t.cvEditorBulletsHelp, first.bullets.join('\n')],
['title', t.cvEditorEntryTitle, first.title!],
['subtitle', t.cvEditorEntrySubtitle, first.subtitle ?? ''],
] as const) {
const currentTitle = field === 'subtitle' ? first.title! : 'CV-specific platform engineer';
const before = await read();
const reset = entry(currentTitle).getByRole('button', { name: resetLabel(label), exact: true });
const resetRequest = page.waitForRequest(request => request.url() === `${apiUrl}/cv/variants/${cv.id}` && request.method() === 'PUT');
await reset.focus(); await page.keyboard.press('Enter');
await expect(entry(field === 'bullets' ? currentTitle : first.title!).getByLabel(label, { exact: true })).toBeFocused();
expect((await resetRequest).postDataJSON().settings.overrides[first.key!]).not.toHaveProperty(field);
await expect.poll(async () => (await read()).settings.overrides[first.key!]?.[field] ?? null).toBeNull();
await saved();
const after = await read();
const remaining = { ...before.settings.overrides[first.key!] }; delete remaining[field];
const persisted = { ...after.settings.overrides[first.key!] }; delete persisted[field];
expect(persisted).toEqual(remaining);
expect(after.settings.sections).toEqual(before.settings.sections);
await expect(entry(field === 'bullets' ? currentTitle : first.title!).getByLabel(label, { exact: true })).toHaveValue(value);
}
await expect(entry(first.title!).getByText(t.cvEditorUsingSource, { exact: true }).first()).toBeVisible();
await expect(entry(first.title!).getByRole('button', { name: named('cvEditorShowEntry', first.title!), exact: true })).toBeVisible();
await unchanged();
await capture(entry(first.title!), info, 'desktop-reset-omitted-entry');
// An absent field inherits the latest source; a retained override remains local.
await entry(first.title!).getByLabel(t.cvEditorEntryTitle, { exact: true }).fill('Retained CV title');
await expect.poll(async () => (await read()).settings.overrides[first.key!].title).toBe('Retained CV title');
await saved();
const changedProfile = { ...profile, jobs: profile.jobs.map(job => job.id === 'job-1' ? { ...job, title: 'Updated source title', company: 'Updated source employer', bullets: ['Updated source evidence — æøå.'] } : job) };
expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { profile: changedProfile } })).status()).toBe(200);
const changedSource = await (await page.request.get(`${apiUrl}/career/profile`)).json();
const beforeReload = await read();
await page.reload();
await page.getByRole('button', { name: sectionLabel('cvEditorExpandSection', t.cvSectionExperience), exact: true }).click();
const retained = entry('Retained CV title');
await expect(retained.getByLabel(t.cvEditorEntryTitle, { exact: true })).toHaveValue('Retained CV title');
await expect(retained.getByLabel(t.cvEditorEntrySubtitle, { exact: true })).toHaveValue('Updated source employer');
await expect(retained.getByLabel(t.cvEditorBulletsHelp, { exact: true })).toHaveValue('Updated source evidence — æøå.');
await expect(retained.getByRole('button', { name: named('cvEditorShowEntry', 'Retained CV title'), exact: true })).toBeVisible();
expect(await read()).toEqual(beforeReload); await unchanged(changedSource);
await retained.getByRole('button', { name: named('cvEditorShowEntry', 'Retained CV title'), exact: true }).click();
await expect.poll(async () => (await read()).settings.overrides[first.key!].hidden).toBe(false); await saved();
await expect(retained.getByRole('button', { name: named('cvEditorHideEntry', 'Retained CV title'), exact: true })).toBeVisible();
// Actual HTML drag handlers persist the same itemOrder used by keyboard moves.
await entry(second.title!).dragTo(retained, { sourcePosition: { x: 8, y: 24 }, targetPosition: { x: 8, y: 24 } });
await expect.poll(async () => (await read()).settings.sections.find(section => section.key === 'experience')?.itemOrder).toEqual([first.key!, second.key!, third.key!]);
await saved(); await unchanged(changedSource);
await capture(retained, info, 'desktop-customized-entry');
for (const width of [1440, 768, 320]) {
await page.setViewportSize({ width, height: 900 });
await retained.getByLabel(t.cvEditorEntrySubtitle, { exact: true }).focus();
await expect(retained.getByLabel(t.cvEditorEntrySubtitle, { exact: true })).toBeFocused();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
await capture(retained, info, `scoped-entry-${width}`);
await scan(page, info, `scoped-entry-${width}`, t.cvEditorEditingRegion);
}
await page.setViewportSize({ width: 1440, height: 900 });
// Existing document-name validation remains the validation owner; optional
// source fields do not acquire invented required rules in the section UI.
const cvName = page.getByLabel(t.cvEditorName, { exact: true });
await cvName.fill('');
await expect(page.getByRole('button', { name: t.cvEditorSaveNow, exact: true })).toBeDisabled();
await page.screenshot({ path: info.outputPath('name-validation.png'), animations: 'disabled' });
await cvName.fill(cv.name); await saved();
const addContent = page.getByRole('button', { name: t.cvEditorAddContent, exact: true });
await addContent.click();
const addDialog = page.getByRole('dialog');
await addDialog.getByText(t.cvEditorAddCustomHelp, { exact: true }).locator('..').getByRole('button', { name: t.cvEditorAdd, exact: true }).click();
await expect(addDialog).toHaveCount(0);
await expect.poll(async () => (await read()).settings.customSections.length).toBe(1); await saved();
const customSetting = (await read()).settings.customSections[0];
const sectionName = page.getByLabel(sectionLabel('cvEditorSectionName', `custom:${customSetting.key}`), { exact: true });
await sectionName.fill('CV-only volunteering — æøå');
const custom = page.getByRole('group', { name: 'CV-only volunteering — æøå', exact: true });
await custom.getByRole('button', { name: sectionLabel('cvEditorExpandSection', 'CV-only volunteering — æøå'), exact: true }).click();
await expect(custom.getByText(t.cvEditorCvOnly, { exact: true })).toBeVisible();
await custom.getByRole('combobox', { name: new RegExp(`^${t.cvEditorContentFormat}`) }).click();
await page.getByRole('option', { name: t.cvEditorMultipleEntries, exact: true }).click();
const addEntry = custom.getByRole('button', { name: t.cvEditorAddEntry, exact: true });
const customEntry = (number: number) => custom.getByLabel(t.cvEditorEntry.replace('{number}', String(number)), { exact: true });
await addEntry.focus(); await page.keyboard.press('Enter');
await expect(customEntry(1)).toBeFocused();
await customEntry(1).fill('CV-only first contribution — æøå.');
await addEntry.click(); await expect(customEntry(2)).toBeFocused();
await customEntry(2).fill('CV-only second contribution.');
await expect.poll(async () => (await read()).settings.customSections[0].items).toEqual(['CV-only first contribution — æøå.', 'CV-only second contribution.']); await saved();
await capture(custom, info, 'custom-cv-only-populated');
for (const width of [768, 320]) {
await page.setViewportSize({ width, height: 900 });
await customEntry(2).focus(); await expect(customEntry(2)).toBeFocused();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
await capture(custom, info, `custom-cv-only-${width}`);
}
const customMove = custom.getByRole('button', { name: t.cvEditorMoveEntryUp.replace('{number}', '2'), exact: true });
await customMove.focus(); await page.keyboard.press('Enter');
await expect(customEntry(1)).toBeFocused();
await expect.poll(async () => (await read()).settings.customSections[0].items).toEqual(['CV-only second contribution.', 'CV-only first contribution — æøå.']); await saved();
const deleteEntry = custom.getByRole('button', { name: t.cvEditorDeleteEntryNumber.replace('{number}', '1'), exact: true });
const beforeCancel = await read();
await deleteEntry.click();
await expect(page.getByRole('dialog').getByRole('button', { name: t.cancel, exact: true })).toBeFocused();
await page.getByRole('dialog').getByRole('button', { name: t.cancel, exact: true }).click();
await expect(page.getByRole('dialog')).toHaveCount(0); await expect(deleteEntry).toBeFocused();
expect(await read()).toEqual(beforeCancel);
await deleteEntry.click();
await capture(page.getByRole('dialog'), info, 'custom-entry-delete-confirmation');
await page.getByRole('dialog').getByRole('button', { name: t.cvEditorDeleteEntry, exact: true }).click();
await expect(page.getByRole('dialog')).toHaveCount(0); await expect(customEntry(1)).toBeFocused();
await expect.poll(async () => (await read()).settings.customSections[0].items).toEqual(['CV-only first contribution — æøå.']); await saved();
await custom.getByRole('button', { name: t.cvEditorDeleteEntryNumber.replace('{number}', '1'), exact: true }).click();
await page.getByRole('dialog').getByRole('button', { name: t.cvEditorDeleteEntry, exact: true }).click();
await expect(page.getByRole('dialog')).toHaveCount(0); await expect(addEntry).toBeFocused();
await expect.poll(async () => (await read()).settings.customSections[0].items).toEqual([]); await saved();
await capture(custom, info, 'custom-cv-only-empty'); await unchanged(changedSource);
const removeSection = custom.getByRole('button', { name: sectionLabel('cvEditorRemoveSection', 'CV-only volunteering — æøå'), exact: true });
const beforeRemove = await read();
await removeSection.click(); await page.keyboard.press('Escape');
await expect(page.getByRole('dialog')).toHaveCount(0); await expect(removeSection).toBeFocused();
expect(await read()).toEqual(beforeRemove);
await removeSection.click();
await page.getByRole('dialog').getByRole('button', { name: t.cvEditorDeleteSection, exact: true }).click();
await expect(page.getByRole('dialog')).toHaveCount(0); await expect(addContent).toBeFocused();
await expect.poll(async () => (await read()).settings.customSections).toEqual([]); await saved();
expect((await read()).settings.sections.some(section => section.key === `custom:${customSetting.key}`)).toBe(false);
await unchanged(changedSource);
// Existing history stores CV settings; it does not snapshot Career Profile.
await page.setViewportSize({ width: 1440, height: 900 });
const final = await read();
await page.getByRole('tab', { name: t.cvEditorHistory, exact: true }).click();
const history = page.getByRole('tabpanel', { name: t.cvEditorHistory, exact: true });
await expect(history.getByText(t.cvEditorVersion.replace('{version}', '1'), { exact: true })).toBeVisible();
await history.getByRole('button', { name: t.cvEditorRestoreTitle.replace('{version}', '1'), exact: true }).click();
await page.getByRole('dialog').getByRole('button', { name: t.cvEditorRestoreVersion, exact: true }).click();
await expect.poll(async () => (await read()).version).toBe(final.version + 1);
expect((await read()).settings).toEqual(cv.settings); await unchanged(changedSource);
await page.getByRole('tab', { name: t.cvEditorContent, exact: true }).click();
const inherited = entry('Updated source title');
await expect(inherited.getByLabel(t.cvEditorEntryTitle, { exact: true })).toHaveValue('Updated source title');
await expect(inherited.getByLabel(t.cvEditorEntrySubtitle, { exact: true })).toHaveValue('Updated source employer');
await expect(inherited.getByLabel(t.cvEditorBulletsHelp, { exact: true })).toHaveValue('Updated source evidence — æøå.');
await expect(inherited.getByText(t.cvEditorUsingSource, { exact: true }).first()).toBeVisible();
await unchanged(changedSource);
} finally { await admin.dispose(); }
});
}