Files
jobtrackingapp/job-tracker-ui/e2e/cv-editor-frame.spec.ts

180 lines
14 KiB
TypeScript

import { expect, test, type APIRequestContext, type Page, type TestInfo } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
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(page: Page, info: TestInfo, state: string) {
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
await page.screenshot({ path: info.outputPath(`${state}.png`) });
}
async function scan(page: Page, info: TestInfo, state: string) {
const result = await new AxeBuilder({ page }).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 C06 C07 C10 W05 E08] ${mode}/${language} CV frame navigation, actions and pending editing`, async ({ page, context, playwright }, info) => {
test.setTimeout(180_000);
const t = translations[language];
const label = (key: keyof typeof t, section: string) => t[key].replace('{section}', section);
const admin = await playwright.request.newContext();
const adminHeaders = await login(admin, 'e2e@example.test');
const email = `p509-${Date.now()}-${mode}-${language}@example.test`;
expect((await admin.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email, password, roles: [] } })).status()).toBe(200);
await admin.dispose();
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.'] }],
education: [{ id: 'education-1', qualification: 'Computer science', institution: 'Fixture University', startDate: '2016-08', endDate: '2020-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 company = await page.request.post(`${apiUrl}/companies`, { headers, data: { name: 'Nordlys fixture AS' } });
expect(company.status()).toBe(201);
const job = await page.request.post(`${apiUrl}/jobapplications`, { headers, data: { companyId: (await company.json()).id, jobTitle: 'Application-linked CV fixture', status: 'Saved' } });
expect(job.status()).toBe(201); const jobId = (await job.json()).id;
const created = await page.request.post(`${apiUrl}/cv/variants`, { headers, data: { name: 'Åse — dokumenterte kvalifikasjoner og erfaring', jobApplicationId: jobId, settings: {
themeId: 'minimal', sections: [{ key: 'summary' }, { key: 'experience' }, { key: 'education' }, { key: 'projects' }, { key: 'skills', hidden: true }],
customSections: [{ key: 'volunteer', title: 'Frivillig arbeid og dokumenterte bidrag', contentType: 'paragraphs', items: ['Eksisterende innhold — æøå.'] }],
} } });
expect(created.status()).toBe(200); const cv = await created.json();
const cvUrl = `${apiUrl}/cv/variants/${cv.id}`;
const read = async () => (await page.request.get(cvUrl)).json();
const saved = async () => { await expect(page.getByText(t.cvEditorSaved, { exact: true })).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 expect(page.getByRole('tab', { name: t.cvEditorContent, exact: true })).toHaveAttribute('aria-selected', 'true');
await expect(page.getByRole('tabpanel', { name: t.cvEditorContent, exact: true })).toBeVisible();
await expect(page.getByText(t.cvEditorScope, { exact: true })).toBeVisible();
await expect(page.getByRole('link', { name: t.cvEditorLinkedApplication })).toHaveAttribute('href', `/jobs/${jobId}`);
await expect(page.getByTitle(t.cvEditorPreviewPage.replace('{page}', '1'), { exact: true })).toBeVisible();
await saved(); await capture(page, info, 'desktop-content'); await scan(page, info, 'desktop-content');
const sectionSelect = page.getByRole('combobox', { name: new RegExp(`^${t.cvEditorGoToSection}`) });
await sectionSelect.click();
await page.getByRole('option', { name: `${t.cvSectionSkills} · ${t.cvEditorOmitted}`, exact: true }).click();
await expect(page.getByRole('button', { name: label('cvEditorCollapseSection', t.cvSectionSkills), exact: true })).toBeFocused();
await expect(page.getByRole('button', { name: label('cvEditorShowSection', t.cvSectionSkills), exact: true })).toBeVisible();
expect((await read()).version).toBe(cv.version);
await capture(page, info, 'omitted-section-focus');
// Native HTML drag path and the existing keyboard alternative both change the
// persisted document order; no simulated request or alternative order model.
await page.getByRole('group', { name: t.cvSectionProjects, exact: true }).dragTo(page.getByRole('group', { name: t.cvSectionExperience, exact: true }));
await saved();
await expect.poll(async () => (await read()).settings.sections.slice(0, 4).map((row: { key: string }) => row.key)).toEqual(['summary', 'projects', 'experience', 'education']);
const move = page.getByRole('button', { name: label('cvEditorMoveSectionDown', t.cvSectionExperience), exact: true });
await move.focus(); await page.keyboard.press('Enter');
await expect(move).toBeFocused(); await saved();
expect((await read()).settings.sections.slice(0, 4).map((row: { key: string }) => row.key)).toEqual(['summary', 'projects', 'education', 'experience']);
await capture(page, info, 'reordered-section-focus');
await sectionSelect.focus(); await page.keyboard.press('Space');
await page.keyboard.press('End'); await page.keyboard.press('Enter');
await expect(page.getByRole('button', { name: label('cvEditorCollapseSection', 'Frivillig arbeid og dokumenterte bidrag'), exact: true })).toBeFocused();
let release!: () => void;
const hold = new Promise<void>(resolve => { release = resolve; });
let pending = false;
await page.route(cvUrl, async route => {
if (route.request().method() === 'PUT') { pending = true; await hold; }
await route.continue();
});
const headline = page.getByLabel(t.cvEditorHeadlineOverride, { exact: true });
const editing = page.getByRole('region', { name: t.cvEditorEditingRegion, exact: true });
try {
await headline.fill('Local CV draft — æøå');
await expect.poll(() => pending).toBe(true);
await expect(page.getByText(t.cvEditorSaving, { exact: true })).toBeVisible();
await capture(page, info, 'pending-save');
await page.getByRole('tab', { name: t.cvEditorCustomize, exact: true }).click();
await expect(page.getByLabel(t.cvEditorPageSize, { exact: true })).toBeVisible();
await page.getByRole('tab', { name: t.cvEditorTemplate, exact: true }).click();
await expect(page.getByRole('button', { name: t.cvEditorAllTemplates, exact: true })).toBeVisible();
await page.getByRole('tab', { name: t.cvEditorContent, exact: true }).click();
await expect(headline).toHaveValue('Local CV draft — æøå');
await expect(sectionSelect).toContainText('Frivillig arbeid og dokumenterte bidrag');
for (const width of [899, 900, 1024]) {
await headline.focus(); await page.setViewportSize({ width, height: 900 });
await expect(headline).toBeFocused(); await expect(editing).toBeVisible();
await expect(headline).toHaveValue('Local CV draft — æøå');
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
await capture(page, info, `pending-${width}`);
}
} finally { release(); }
await saved(); await page.unroute(cvUrl);
expect((await read()).settings.headline).toBe('Local CV draft — æøå');
expect(await (await page.request.get(`${apiUrl}/career/profile`)).json()).toEqual(source);
await page.getByRole('button', { name: t.cvEditorUndo, exact: true }).click(); await saved();
await expect(headline).toHaveValue('');
await page.getByRole('button', { name: t.cvEditorRedo, exact: true }).click(); await saved();
await expect(headline).toHaveValue('Local CV draft — æøå');
// Publication and PDF stay in the existing client handlers. Menu Escape returns
// focus; public state is explicit, and unpublishing revokes anonymous access.
const more = page.getByRole('button', { name: t.cvEditorMoreActions, exact: true });
await more.click(); await page.keyboard.press('Escape'); await expect(more).toBeFocused();
await more.click(); await page.getByRole('menuitem', { name: t.cvEditorShare, exact: true }).click();
await expect(page.getByText(t.cvEditorShared, { exact: true })).toBeVisible();
const publicCv = await read();
const anonymous = await playwright.request.newContext();
expect((await anonymous.get(`${apiUrl}/public-cv/${publicCv.publicSlug}`)).status()).toBe(200);
await more.click(); await expect(page.getByRole('menuitem', { name: t.cvEditorCopyLink, exact: true })).toBeVisible();
await page.getByRole('menuitem', { name: t.cvEditorStopSharing, exact: true }).click();
await expect(page.getByText(t.cvEditorShared, { exact: true })).toHaveCount(0);
expect((await anonymous.get(`${apiUrl}/public-cv/${publicCv.publicSlug}`)).status()).toBe(404); await anonymous.dispose();
const pdfResponse = page.waitForResponse(response => response.url() === `${cvUrl}/export-pdf` && response.request().method() === 'POST');
const download = page.waitForEvent('download');
await page.getByRole('button', { name: t.cvEditorDownloadPdf, exact: true }).click();
expect((await pdfResponse).status()).toBe(200); expect((await download).suggestedFilename()).toMatch(/\.pdf$/i);
await page.setViewportSize({ width: 1440, height: 900 });
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 capture(page, info, 'history'); await scan(page, info, 'history');
await page.getByRole('tab', { name: t.cvEditorAiTools, exact: true }).click();
await expect(page.getByRole('tabpanel', { name: t.cvEditorAiTools, exact: true })).toBeVisible();
await page.getByRole('tab', { name: t.cvEditorContent, exact: true }).click();
await expect(sectionSelect).toContainText('Frivillig arbeid og dokumenterte bidrag');
await expect(page.getByRole('button', { name: label('cvEditorCollapseSection', 'Frivillig arbeid og dokumenterte bidrag'), exact: true })).toBeVisible();
for (const width of [768, 390, 320]) {
await headline.focus(); await page.setViewportSize({ width, height: 844 });
await expect(editing).toBeVisible();
await expect(page.getByRole('button', { name: t.cvEditorEdit, exact: true })).toHaveAttribute('aria-pressed', 'true');
await page.getByLabel(t.cvEditorName, { exact: true }).scrollIntoViewIfNeeded();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
await capture(page, info, `edit-${width}`);
await page.getByRole('button', { name: t.cvEditorPreview, exact: true }).click();
await expect(page.getByRole('button', { name: t.cvEditorPreview, exact: true })).toHaveAttribute('aria-pressed', 'true');
const preview = page.getByRole('region', { name: t.cvEditorLivePreview, exact: true });
await expect(preview).toBeVisible(); await preview.focus(); await page.keyboard.press('End');
await capture(page, info, `preview-${width}`);
await page.getByRole('button', { name: t.cvEditorEdit, exact: true }).click();
await expect(headline).toHaveValue('Local CV draft — æøå');
}
await scan(page, info, 'phone-edit');
await page.setViewportSize({ width: 1440, height: 900 });
await more.click();
const copiedResponse = page.waitForResponse(response => response.url() === `${cvUrl}/duplicate`);
await page.getByRole('menuitem', { name: t.cvEditorDuplicate, exact: true }).click();
const copied = await (await copiedResponse).json();
await expect(page).toHaveURL(new RegExp(`/career/builder/${copied.id}$`));
expect((await (await page.request.get(`${apiUrl}/cv/variants/${copied.id}`)).json()).settings).toEqual((await read()).settings);
});
}