Files
jobtrackingapp/job-tracker-ui/e2e/pre-p10-cv-editor.spec.ts
T
2026-10-27 07:37:03 +01:00

149 lines
10 KiB
TypeScript

import { expect, test, type APIRequestContext, type Page } from '@playwright/test';
import { translations } from '../src/i18n/translations';
import { emptyStructuredCv } from '../src/profileCv';
import AxeBuilder from '@axe-core/playwright';
const api = 'http://localhost:5302/api';
const password = 'E2ePassword123!';
async function login(request: APIRequestContext, email: string) {
expect((await request.post(`${api}/auth/login`, { data: { email, password } })).status()).toBe(200);
const me = await (await request.get(`${api}/auth/me`)).json();
return { 'X-CSRF-TOKEN': (await request.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value,
'X-Session-Context': me.sessionContext as string };
}
async function owner(page: Page, admin: APIRequestContext, language: 'en' | 'nb' = 'en') {
const adminHeaders = await login(admin, 'e2e@example.test');
const email = `pre-p10-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`;
expect((await admin.post(`${api}/users`, { headers: adminHeaders, data: { email, password, roles: [] } })).status()).toBe(200);
const headers = await login(page.request, email);
const profile = { ...emptyStructuredCv(), contact: { ...emptyStructuredCv().contact,
fullName: 'Canonical Identity Marker', headline: 'Canonical Headline Marker', email: 'canonical-contact@example.test' },
summary: ['Canonical Summary Marker'], jobs: [{ id: 'pre-p10-job', title: 'Canonical Role Marker', company: 'Synthetic Employer', bullets: ['Canonical Detail Marker'] }] };
const before = await (await page.request.get(`${api}/career/profile`)).json();
expect((await page.request.put(`${api}/career/profile`, { headers, data: { expectedVersion: before.revision, profile } })).status()).toBe(200);
expect((await page.request.put(`${api}/auth/preferences/language`, { headers, data: { language: language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204);
await page.addInitScript(language => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', 'light'); }, language);
await page.setViewportSize({ width: language === 'nb' ? 390 : 1440, height: 1000 });
return headers;
}
test.beforeEach(async ({ context }) => {
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();
});
});
for (const language of ['en', 'nb'] as const) {
test(`[PRE-P10 A] ${language} blank CV survives rename, reload, template apply and empty export validation`, async ({ page, playwright }, info) => {
test.setTimeout(180_000);
const t = translations[language];
const admin = await playwright.request.newContext();
let headers: Record<string, string>;
try { headers = await owner(page, admin, language); } finally { await admin.dispose(); }
const sourceBefore = await (await page.request.get(`${api}/career/profile`)).json();
await page.goto('/career/builder');
await page.getByRole('button', { name: t.cvDashboardNewCv, exact: true }).click();
const dialog = page.getByRole('dialog', { name: t.cvDashboardCreateTitle, exact: true });
await expect(dialog).toBeVisible();
await dialog.getByRole('radio', { name: t.cvDashboardStartBlank, exact: true }).check();
await expect(dialog.getByRole('radio', { name: t.cvDashboardStartBlank, exact: true })).toBeChecked();
const dialogAxe = await new AxeBuilder({ page }).include('[role="dialog"]').withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
await info.attach('create-dialog-axe', { body: JSON.stringify({ violations: dialogAxe.violations, incomplete: dialogAxe.incomplete }), contentType: 'application/json' });
expect(dialogAxe.violations).toEqual([]);
await dialog.getByLabel(t.cvEditorName, { exact: false }).fill('New blank CV');
const createdResponse = page.waitForResponse(response => response.url() === `${api}/cv/variants` && response.request().method() === 'POST');
await dialog.getByRole('button', { name: t.cvDashboardCreateCv, exact: true }).click();
const response = await createdResponse;
expect(response.status()).toBe(200);
const created = await response.json();
await expect(page).toHaveURL(new RegExp(`/career/builder/${created.id}$`));
const read = async () => (await page.request.get(`${api}/cv/variants/${created.id}`)).json();
const assertBlank = async () => {
const saved = await read();
expect(saved.settings).toMatchObject({ sectionsExplicit: true, includeProfileContact: false, sections: [] });
const preview = await page.request.get(`${api}/cv/variants/${created.id}/preview`);
expect(preview.status()).toBe(200);
const rendered = await preview.json();
for (const marker of ['Canonical Identity Marker', 'Canonical Headline Marker', 'Canonical Summary Marker', 'Canonical Role Marker', 'canonical-contact@example.test']) {
expect(rendered.html.includes(marker), `Blank CV must not inherit ${marker}`).toBe(false);
}
return saved;
};
await expect(page.getByText(t.cvEditorEmptyBody, { exact: true })).toBeVisible();
await assertBlank();
await page.getByLabel(t.cvEditorName, { exact: true }).fill('Saved empty CV');
await expect.poll(async () => (await read()).name).toBe('Saved empty CV');
await page.reload();
await expect(page.getByLabel(t.cvEditorName, { exact: true })).toHaveValue('Saved empty CV');
await expect(page.getByRole('checkbox', { name: t.cvEditorIncludeProfileContact, exact: true })).not.toBeChecked();
await assertBlank();
await page.getByRole('tab', { name: t.cvEditorTemplate, exact: true }).click();
await page.getByRole('button', { name: t.cvTemplateMinimalName, exact: true }).click();
await page.getByRole('button', { name: t.cvEditorApplyTemplate, exact: true }).click();
await expect.poll(async () => (await read()).settings.themeId).toBe('minimal');
await assertBlank();
await page.getByRole('tab', { name: t.cvEditorContent, exact: true }).click();
const editorAxe = await new AxeBuilder({ page }).include('#cv-studio-panel-1').withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
await info.attach('blank-content-axe', { body: JSON.stringify({ violations: editorAxe.violations, incomplete: editorAxe.incomplete }), contentType: 'application/json' });
expect(editorAxe.violations).toEqual([]);
const exportResponse = page.waitForResponse(response => new URL(response.url()).pathname === `/api/cv/variants/${created.id}/export-pdf` && response.request().method() === 'POST');
await page.getByRole('button', { name: t.cvEditorDownloadPdf, exact: true }).click();
const exported = await exportResponse;
expect(exported.status()).toBe(400);
expect(await exported.json()).toEqual({ code: 'cv_empty_content' });
await expect(page.getByText(t.cvEmptyContent, { exact: true })).toBeVisible();
const saved = await assertBlank();
const independent = await page.request.post(`${api}/cv/variants/${created.id}/export-pdf?expectedVersion=${saved.version}`, { headers });
expect(independent.status()).toBe(400);
expect(await independent.json()).toEqual({ code: 'cv_empty_content' });
expect(await (await page.request.get(`${api}/career/profile`)).json()).toEqual(sourceBefore);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
await info.attach('blank-roundtrip', { body: JSON.stringify({ language, created, saved, exportStatus: exported.status(), sourceUnchanged: true }), contentType: 'application/json' });
await page.screenshot({ path: info.outputPath('blank-roundtrip.png'), fullPage: true });
});
test(`[PRE-P10 A] ${language} document undo and both redo shortcuts preserve native text undo`, async ({ page, playwright }, info) => {
test.setTimeout(180_000);
const t = translations[language];
const admin = await playwright.request.newContext();
let headers: Record<string, string>;
try { headers = await owner(page, admin, language); } finally { await admin.dispose(); }
const response = await page.request.post(`${api}/cv/variants`, { headers, data: { name: 'Keyboard undo runtime reproduction', settings: { themeId: 'minimal', sections: [{ key: 'summary' }, { key: 'experience' }] } } });
expect(response.status()).toBe(200);
const cv = await response.json();
await page.goto(`/career/builder/${cv.id}`);
const headline = page.getByLabel(t.cvEditorHeadlineOverride, { exact: true });
await expect(headline).toBeVisible();
const readOrder = async () => (await (await page.request.get(`${api}/cv/variants/${cv.id}`)).json()).settings.sections.slice(0, 2).map((section: { key: string }) => section.key);
await page.getByRole('button', { name: t.cvEditorMoveSectionDown.replace('{section}', t.cvSectionSummary), exact: true }).click();
const undo = page.getByRole('button', { name: t.cvEditorUndo, exact: true });
const redo = page.getByRole('button', { name: t.cvEditorRedo, exact: true });
await expect(undo).toBeEnabled();
await expect.poll(readOrder).toEqual(['experience', 'summary']);
for (const shortcut of ['Control+z', 'Control+y', 'Control+z', 'Control+Shift+z']) {
const reverting = shortcut === 'Control+z';
await (reverting ? undo : redo).focus();
await page.keyboard.press(shortcut);
await expect.poll(readOrder, { message: shortcut }).toEqual(reverting ? ['summary', 'experience'] : ['experience', 'summary']);
}
await headline.focus();
await headline.pressSequentially('Native input marker');
await expect(headline).toHaveValue('Native input marker');
await page.evaluate(() => { document.addEventListener('keydown', event => {
if (event.ctrlKey && event.key.toLowerCase() === 'z') document.documentElement.dataset.nativeUndoPrevented = String(event.defaultPrevented);
}); });
await page.keyboard.press('Control+z');
await expect(headline).not.toHaveValue('Native input marker');
await expect(headline).toBeFocused();
expect(await page.evaluate(() => document.documentElement.dataset.nativeUndoPrevented)).toBe('false');
expect(await readOrder()).toEqual(['experience', 'summary']);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
await info.attach('shortcut-roundtrip', { body: JSON.stringify({ language, finalOrder: await readOrder(), nativeInputValue: await headline.inputValue(), nativeUndoPrevented: false }), contentType: 'application/json' });
await page.screenshot({ path: info.outputPath('shortcut-roundtrip.png'), fullPage: true });
});
}