231 lines
17 KiB
TypeScript
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' });
|
|
});
|
|
}
|