Files
jobtrackingapp/job-tracker-ui/e2e/pre-p10-template-selection.spec.ts
T

231 lines
17 KiB
TypeScript

import { expect, test, type APIRequestContext, type Page } from '@playwright/test';
import { translations } from '../src/i18n/translations';
import { emptyStructuredCv } from '../src/profileCv';
const api = 'http://localhost:5302/api';
const password = 'E2ePassword123!';
const themeNames = {
modern: 'cvTemplateModernName', minimal: 'cvTemplateMinimalName', executive: 'cvTemplateExecutiveName',
technical: 'cvTemplateTechnicalName', 'ats-classic': 'cvTemplateAtsClassicName', nordic: 'cvTemplateNordicName',
elegant: 'cvTemplateElegantName', creative: 'cvTemplateCreativeName', code: 'cvTemplateCodeName',
} as const;
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') {
const adminHeaders = await login(admin, 'e2e@example.test');
const email = `template-selection-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`;
expect((await admin.post(`${api}/users`, { headers: adminHeaders, data: { email, password, roles: ['Premium'] } })).status()).toBe(200);
const headers = await login(page.request, email);
const initial = await (await page.request.get(`${api}/career/profile`)).json();
const profile = { ...emptyStructuredCv(), contact: { ...emptyStructuredCv().contact, fullName: 'Owner Canonical Marker', headline: 'Canonical headline marker', email: 'synthetic@example.test' },
summary: ['Canonical summary marker'], skills: ['TypeScript', 'Documented evidence'] };
expect((await page.request.put(`${api}/career/profile`, { headers, data: { expectedVersion: initial.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 });
const source = await (await page.request.get(`${api}/career/profile`)).json();
return { headers, source };
}
async function createCv(page: Page, headers: Record<string, string>, showPhoto = false) {
const response = await page.request.post(`${api}/cv/variants`, { headers, data: { name: 'Template selection CV', settings: {
themeId: 'modern', sectionsExplicit: true, includeProfileContact: true, sections: [{ key: 'summary' }], headline: 'Authored CV headline marker', showPhoto,
} } });
expect(response.status()).toBe(200);
return response.json();
}
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 B] ${language} actual nine-theme gallery and candidate preview remain read-only until Apply`, async ({ page, playwright }, info) => {
test.setTimeout(180_000);
const t = translations[language];
const admin = await playwright.request.newContext();
let fixture: Awaited<ReturnType<typeof owner>>;
try { fixture = await owner(page, admin, language); } finally { await admin.dispose(); }
const cv = await createCv(page, fixture.headers);
const cvUrl = `${api}/cv/variants/${cv.id}`;
const read = async () => (await page.request.get(cvUrl)).json();
const requests: { path: string; status: number }[] = [];
let writes = 0;
page.on('response', response => { if (/\/cv\/themes\/[^/]+\/preview$/.test(new URL(response.url()).pathname)) requests.push({ path: new URL(response.url()).pathname, status: response.status() }); });
page.on('request', request => { if (request.url() === cvUrl && request.method() === 'PUT') writes++; });
await page.goto(`/career/builder/${cv.id}`);
await page.getByRole('tab', { name: t.cvEditorTemplate, exact: true }).click();
await page.getByRole('button', { name: t.cvEditorAllTemplates, exact: true }).click();
for (const [id, key] of Object.entries(themeNames)) {
const card = page.getByRole('button', { name: t[key], exact: true });
await card.scrollIntoViewIfNeeded();
await expect(card).toHaveAttribute('aria-disabled', 'false');
const frame = card.frameLocator('iframe');
await expect(frame.locator('.name')).toHaveText('Alex Morgan');
await expect(frame.locator('body')).not.toContainText('Owner Canonical Marker');
await expect.poll(() => requests.some(request => request.path === `/api/cv/themes/${id}/preview` && request.status === 200)).toBe(true);
}
const candidate = page.getByRole('button', { name: t.cvTemplateTechnicalName, exact: true });
await candidate.click();
const dialog = page.getByRole('dialog');
await expect(dialog.frameLocator('iframe').locator('.name')).toHaveText('Alex Morgan');
await expect(dialog.getByRole('button', { name: t.cvEditorApplyTemplate, exact: true })).toBeEnabled();
expect(await read()).toEqual(cv);
expect(writes).toBe(0);
await dialog.getByRole('button', { name: t.cancel, exact: true }).click();
expect(await read()).toEqual(cv);
await candidate.click();
await expect(dialog.frameLocator('iframe').locator('.name')).toHaveText('Alex Morgan');
const applied = page.waitForResponse(response => response.url() === cvUrl && response.request().method() === 'PUT');
await dialog.getByRole('button', { name: t.cvEditorApplyTemplate, exact: true }).click();
expect((await applied).status()).toBe(200);
await expect.poll(async () => (await read()).settings.themeId).toBe('technical');
const saved = await read();
expect(saved.settings).toMatchObject({ designVersion: 2, themeId: 'technical', sectionsExplicit: true, includeProfileContact: true,
sections: [{ key: 'summary' }], headline: 'Authored CV headline marker' });
expect(saved.version).toBe(cv.version + 1);
if (language === 'nb') await page.getByRole('button', { name: t.cvEditorPreview, exact: true }).click();
const live = page.frameLocator(`iframe[title="${t.cvEditorPreviewPage.replace('{page}', '1')}"]`);
await expect(live.locator('.name')).toHaveText('Owner Canonical Marker');
await expect(live.locator('body')).toContainText('Authored CV headline marker');
await expect.poll(() => live.locator('body').evaluate(node => getComputedStyle(node, '::before').backgroundColor)).toBe('rgb(15, 76, 92)');
expect(await (await page.request.get(`${api}/career/profile`)).json()).toEqual(fixture.source);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
await info.attach('actual-template-selection', { body: JSON.stringify({ language, samples: requests, savedSettings: saved.settings, writes }), contentType: 'application/json' });
await page.screenshot({ path: info.outputPath('template-selection.png'), fullPage: true });
});
test(`[PRE-P10 B] ${language} actual letter template Apply previews current authored text and preserves saved source`, async ({ page, playwright }, info) => {
test.setTimeout(180_000);
const t = translations[language];
const admin = await playwright.request.newContext();
let fixture: Awaited<ReturnType<typeof owner>>;
try { fixture = await owner(page, admin, language); } finally { await admin.dispose(); }
const companyResponse = await page.request.post(`${api}/companies`, { headers: fixture.headers, data: { name: 'Letter Selection Synthetic Company', source: 'User' } });
expect(companyResponse.status()).toBe(201);
const company = await companyResponse.json();
const jobResponse = await page.request.post(`${api}/jobapplications`, { headers: fixture.headers, data: { companyId: company.id, jobTitle: 'Letter template fixture', status: 'Saved', description: 'Synthetic controlled fixture' } });
expect(jobResponse.status()).toBe(201);
const job = await jobResponse.json();
const letterUrl = `${api}/jobapplications/${job.id}/cover-letter`;
const original = await (await page.request.get(letterUrl)).json();
const documentSettings = { schemaVersion: 1, senderName: 'Letter sender marker', senderTitle: 'Reviewed title', senderEmail: 'letter@example.test', senderPhone: '+47 12345678', senderLocation: 'Oslo',
recipient: 'Preserved recipient marker', subject: 'Preserved subject marker', date: '2026-10-27', themeId: 'modern', accentColor: '#2563eb', bodyFont: 'Arial, Helvetica, sans-serif',
baseFontSizePt: 10, lineHeight: 1.42, pageMarginMm: 16, pageSize: 'letter', language: language === 'nb' ? 'nb-NO' : 'en' };
expect((await page.request.put(letterUrl, { headers: fixture.headers, data: { text: 'Saved letter body marker', source: 'manual', expectedVersion: original.revision, documentSettings } })).status()).toBe(200);
const saved = await (await page.request.get(letterUrl)).json();
let writes = 0;
const cvSamples: string[] = [];
page.on('request', request => {
if (request.method() === 'PUT' && request.url() === letterUrl) writes++;
if (/\/cv\/themes\/[^/]+\/preview$/.test(new URL(request.url()).pathname)) cvSamples.push(request.url());
});
await page.goto(`/jobs/${job.id}?section=application&sub=letter#materials`);
const body = page.getByRole('textbox', { name: t.jobDetailsCoverLetter, exact: true });
await expect(body).toHaveValue('Saved letter body marker');
const draft = 'Current authored letter marker — Åse Østby';
await body.fill(draft);
await page.getByRole('button', { name: t.letterChooseTemplate, exact: true }).click();
const dialog = page.getByRole('dialog');
await expect(dialog.getByRole('button', { name: t.letterTemplateApply, exact: true })).toBeEnabled();
await dialog.getByRole('combobox', { name: new RegExp(`^${t.letterTemplate}(?: |$)`) }).click();
await expect(page.getByRole('option')).toHaveCount(9);
await page.getByRole('option', { name: 'Nordic', exact: true }).click();
await dialog.getByRole('button', { name: t.cancel, exact: true }).click();
await expect(body).toHaveValue(draft);
expect(await (await page.request.get(letterUrl)).json()).toEqual(saved);
await page.getByRole('button', { name: t.letterChooseTemplate, exact: true }).click();
await expect(dialog.getByRole('button', { name: t.letterTemplateApply, exact: true })).toBeEnabled();
await dialog.getByRole('combobox', { name: new RegExp(`^${t.letterTemplate}(?: |$)`) }).click();
await page.getByRole('option', { name: 'Nordic', exact: true }).click();
const previewResponse = page.waitForResponse(response => response.url() === `${letterUrl}/preview` && response.request().method() === 'POST'
&& response.request().postDataJSON()?.documentSettings?.themeId === 'nordic');
await dialog.getByRole('button', { name: t.letterTemplateApply, exact: true }).click();
await expect(body).toHaveValue(draft);
if (language === 'nb') await page.getByRole('tab', { name: t.assetsLetterPreview, exact: true }).click();
const preview = await previewResponse;
expect(preview.status()).toBe(200);
const submitted = preview.request().postDataJSON();
expect(submitted.text).toBe(draft);
expect(submitted.documentSettings).toMatchObject({ designVersion: 2, themeId: 'nordic', pageSize: 'letter', language: documentSettings.language,
senderName: documentSettings.senderName, recipient: documentSettings.recipient, date: documentSettings.date, subject: documentSettings.subject });
const frame = page.frameLocator(`iframe[title="${t.letterDocumentPreview}"]`);
await expect(frame.locator('.letter-theme-nordic')).toBeVisible();
await expect(frame.locator('body')).toContainText(draft);
await expect(frame.locator('body')).toContainText(documentSettings.recipient);
expect(await (await page.request.get(letterUrl)).json()).toEqual(saved);
expect(await (await page.request.get(`${api}/career/profile`)).json()).toEqual(fixture.source);
expect(writes).toBe(0);
expect(cvSamples).toEqual([]);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
await info.attach('actual-letter-template-preview', { body: JSON.stringify({ language, submitted, unchangedSavedRevision: saved.revision, writes, cvSamples }), contentType: 'application/json' });
await page.screenshot({ path: info.outputPath('letter-template-selection.png'), fullPage: true });
});
test(`[PRE-P10 B] ${language} keyboard crop uses existing avatar storage and CV photo remains optional`, async ({ page, playwright }, info) => {
test.setTimeout(180_000);
const t = translations[language];
const admin = await playwright.request.newContext();
let fixture: Awaited<ReturnType<typeof owner>>;
try { fixture = await owner(page, admin, language); } finally { await admin.dispose(); }
const cv = await createCv(page, fixture.headers, true);
await page.goto(`/career/builder/${cv.id}`);
await page.getByRole('tab', { name: t.cvEditorCustomize, exact: true }).click();
await page.getByRole('link', { name: t.cvEditorManagePhoto, exact: true }).click();
await expect(page).toHaveURL(/\/career\?section=profile$/);
await expect(page.getByRole('button', { name: t.profileChangeImage, exact: true })).toBeEnabled();
const png = await page.evaluate(() => {
const canvas = document.createElement('canvas'); canvas.width = 512; canvas.height = 384;
const draw = canvas.getContext('2d')!;
draw.fillStyle = '#0f766e'; draw.fillRect(0, 0, 256, 384);
draw.fillStyle = '#f59e0b'; draw.fillRect(256, 0, 256, 384);
return canvas.toDataURL('image/png').split(',')[1];
});
await page.locator('input[type=file]').nth(1).setInputFiles({ name: 'synthetic-nonhuman-color-blocks.png', mimeType: 'image/png', buffer: Buffer.from(png, 'base64') });
const crop = page.getByRole('dialog');
await expect(crop.getByAltText(t.cropDialogPreviewAlt, { exact: true })).toBeVisible();
const zoom = crop.getByRole('slider', { name: t.cropDialogZoom, exact: true });
await zoom.focus(); await zoom.press('End');
await expect(zoom).toHaveAttribute('aria-valuenow', '3');
const horizontal = crop.getByRole('slider', { name: t.cropDialogHorizontal, exact: true });
const vertical = crop.getByRole('slider', { name: t.cropDialogVertical, exact: true });
for (const slider of [horizontal, vertical]) {
await expect(slider).toBeEnabled();
await slider.focus(); await slider.press('Home');
await expect.poll(async () => Number(await slider.getAttribute('aria-valuenow'))).toBeLessThan(0);
await slider.press('End');
await expect(slider).toHaveAttribute('aria-valuenow', '0');
}
const uploadResponse = page.waitForResponse(response => new URL(response.url()).pathname === '/api/auth/avatar' && response.request().method() === 'POST');
await crop.getByRole('button', { name: t.cropDialogSave, exact: true }).press('Enter');
expect((await uploadResponse).status()).toBe(200);
await expect(crop).not.toBeVisible();
const avatar = (await (await page.request.get(`${api}/auth/me`)).json()).avatarImageDataUrl;
expect(avatar).toBeTruthy();
await page.goto(`/career/builder/${cv.id}`);
if (language === 'nb') await page.getByRole('button', { name: t.cvEditorPreview, exact: true }).click();
const live = page.frameLocator(`iframe[title="${t.cvEditorPreviewPage.replace('{page}', '1')}"]`);
await expect(live.locator('.photo img')).toBeVisible();
if (language === 'nb') await page.getByRole('button', { name: t.cvEditorEdit, exact: true }).click();
await page.getByRole('tab', { name: t.cvEditorCustomize, exact: true }).click();
await page.getByRole('switch', { name: t.cvEditorShowPhoto, exact: true }).uncheck();
await expect.poll(async () => (await (await page.request.get(`${api}/cv/variants/${cv.id}`)).json()).settings.showPhoto).toBe(false);
if (language === 'nb') await page.getByRole('button', { name: t.cvEditorPreview, exact: true }).click();
await expect(live.locator('.photo img')).toHaveCount(0);
expect((await (await page.request.get(`${api}/auth/me`)).json()).avatarImageDataUrl).toBe(avatar);
expect(await (await page.request.get(`${api}/career/profile`)).json()).toEqual(fixture.source);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
await page.screenshot({ path: info.outputPath('optional-photo-after-keyboard-crop.png'), fullPage: true });
await info.attach('keyboard-crop', { body: JSON.stringify({ language, syntheticNonhumanFixture: true, keyboardZoom: 3, keyboardAxes: ['horizontal', 'vertical'], optionalCvPhoto: false, avatarRetained: true }), contentType: 'application/json' });
});
}