176 lines
16 KiB
TypeScript
176 lines
16 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 };
|
||
}
|
||
|
||
test('[PRES-W01 W08 M01 E08] Job section loading and partial read failures stay explicit and recover locally', async ({ page }, info) => {
|
||
const headers = await login(page.request, 'e2e@example.test');
|
||
const company = await page.request.post(`${apiUrl}/companies`, { headers, data: { name: 'Section state fixture' } });
|
||
expect(company.status()).toBe(201);
|
||
const created = await page.request.post(`${apiUrl}/jobapplications`, { headers, data: { companyId: (await company.json()).id, jobTitle: 'Section state fixture', status: 'Saved', notes: 'Retained personal notes' } });
|
||
expect(created.status()).toBe(201); const id = (await created.json()).id;
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
let release: (() => void) | undefined; let fail = true;
|
||
const hold = new Promise<void>(resolve => { release = resolve; });
|
||
await page.route(`${apiUrl}/attachments/${id}`, async route => {
|
||
await hold;
|
||
return fail ? route.fulfill({ status: 503, json: { message: 'Temporary attachment read failure' } }) : route.continue();
|
||
});
|
||
await page.goto(`/jobs/${id}?section=documents`);
|
||
await expect(page.getByRole('region', { name: translations.en.workspaceCurrentDocuments })).toBeVisible();
|
||
await expect(page.getByRole('progressbar').first()).toBeVisible();
|
||
await capture(page, info, 'sections-documents-loading'); release!();
|
||
await expect(page.getByRole('alert').filter({ hasText: 'Temporary attachment read failure' })).toBeVisible();
|
||
await expect(page.getByText(translations.en.attachmentsEmpty, { exact: true })).toHaveCount(0);
|
||
await capture(page, info, 'sections-documents-error'); fail = false;
|
||
await page.getByRole('button', { name: 'Retry', exact: true }).click();
|
||
await expect(page.getByText(translations.en.attachmentsEmpty, { exact: true })).toBeVisible();
|
||
await capture(page, info, 'sections-documents-recovered');
|
||
await page.route(`${apiUrl}/correspondence/${id}`, route => route.fulfill({ status: 503, json: { message: 'Temporary correspondence read failure' } }));
|
||
await page.goto(`/jobs/${id}?section=communication&followMode=waiting-update`);
|
||
await expect(page.getByRole('alert').filter({ hasText: 'Temporary correspondence read failure' })).toBeVisible();
|
||
await expect(page.getByText(translations.en.correspondenceNoMessages, { exact: true })).toHaveCount(0);
|
||
await expect(page.getByRole('textbox', { name: translations.en.correspondenceLogNoteOrMessage })).toBeVisible();
|
||
await capture(page, info, 'sections-correspondence-error');
|
||
await page.goto(`/jobs/${id}?section=tasks`);
|
||
await expect(page.getByRole('region', { name: translations.en.workspaceNotes, exact: true })).toContainText('Retained personal notes');
|
||
await capture(page, info, 'sections-sparse-tasks');
|
||
await page.goto(`/jobs/${id}?section=interview`);
|
||
await expect(page.getByText(translations.en.interviewPrepEarly, { exact: true })).toBeVisible();
|
||
await capture(page, info, 'sections-sparse-interview');
|
||
});
|
||
async function capture(page: Page, info: TestInfo, name: string) {
|
||
await page.evaluate(() => document.fonts.ready);
|
||
const path = info.outputPath(`${name}.png`);
|
||
await page.screenshot({ path, fullPage: false, animations: 'disabled' });
|
||
await info.attach(name, { path, contentType: 'image/png' });
|
||
if (name.endsWith('en-light') && page.viewportSize()?.width === 1440 && ['sections-documents-', 'sections-correspondence-', 'sections-notes-tasks-', 'sections-interview-'].some(prefix => name.startsWith(prefix))) {
|
||
const scan = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
|
||
await info.attach(`${name}-axe`, { body: JSON.stringify({ violations: scan.violations, incomplete: scan.incomplete }), contentType: 'application/json' });
|
||
expect(scan.violations).toEqual([]);
|
||
}
|
||
}
|
||
|
||
for (const language of ['en', 'nb'] as const) for (const mode of ['light', 'dark'] as const) {
|
||
test(`[PRES-W01–W11 E08 E09] native Job sections retain drafts, frozen documents, tasks and history (${language}, ${mode})`, async ({ page, playwright }, info) => {
|
||
test.setTimeout(240_000);
|
||
const t = translations[language];
|
||
const admin = await playwright.request.newContext();
|
||
const adminHeaders = await login(admin, 'e2e@example.test');
|
||
const email = `job-sections-${language}-${mode}-${Date.now()}@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 page.route('**/*', route => ['localhost', '127.0.0.1'].includes(new URL(route.request().url()).hostname) || ['blob:', 'data:', 'about:'].includes(new URL(route.request().url()).protocol) ? route.continue() : route.abort());
|
||
await page.addInitScript(({ language, mode }) => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', mode); }, { language, mode });
|
||
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers, data: { language: language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204);
|
||
expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { profile: { version: '1', contact: { fullName: 'Åse Eksempel', headline: 'Recorded engineer' }, skills: ['C#', 'SQL'] } } })).status()).toBe(200);
|
||
const company = await page.request.post(`${apiUrl}/companies`, { headers, data: { name: 'Nordlys samfunnskritiske plattformer og inkluderende digitale tjenester æøå' } });
|
||
expect(company.status()).toBe(201); const companyId = (await company.json()).id;
|
||
const title = 'Senior utvikler og rådgiver for langsiktig plattformutvikling';
|
||
const created = await page.request.post(`${apiUrl}/jobapplications`, { headers, data: { companyId, jobTitle: title, status: 'Interview', description: 'Build C# and SQL services.', notes: 'Human notes — æøå', deadline: '2026-12-14T12:00:00Z', followUpAt: '2026-12-10T12:00:00Z' } });
|
||
expect(created.status()).toBe(201); const id = (await created.json()).id;
|
||
const jobUrl = `${apiUrl}/jobapplications/${id}`;
|
||
const cv = await page.request.post(`${apiUrl}/cv/variants`, { headers, data: { name: 'Application CV — Åse', jobApplicationId: id } });
|
||
expect(cv.status()).toBe(200); const variant = await cv.json();
|
||
expect((await page.request.put(`${jobUrl}/cv`, { headers, data: { variantId: variant.id } })).status()).toBe(200);
|
||
expect((await page.request.put(`${jobUrl}/cover-letter`, { headers, data: { text: 'Reviewed letter — æøå', source: 'manual' } })).status()).toBe(200);
|
||
expect((await page.request.put(`${jobUrl}/application-drafts`, { headers, data: { applicationAnswerDraft: 'Reviewed answer — æøå', recruiterMessageDraft: 'Message draft; never sent' } })).status()).toBe(204);
|
||
expect((await page.request.post(`${jobUrl}/checklist`, { headers, data: { title: 'Review employer questions — æøå', category: 'custom' } })).status()).toBe(200);
|
||
expect((await page.request.post(`${jobUrl}/interview-prep`, { headers, data: { title: 'Ask about sustainable delivery', category: 'question', content: 'My recorded question', source: 'manual' } })).status()).toBe(200);
|
||
expect((await page.request.post(`${apiUrl}/correspondence`, { headers, data: { jobApplicationId: id, from: 'Company', subject: 'Interview invitation — æøå', content: 'Recorded invitation; no inferred company facts', channel: 'Email' } })).ok()).toBe(true);
|
||
expect((await page.request.post(`${apiUrl}/attachments`, { headers, multipart: { jobId: String(id), files: { name: 'Employment evidence — æøå.txt', mimeType: 'text/plain', buffer: Buffer.from('Recorded evidence — æøå') } } })).ok()).toBe(true);
|
||
await page.setViewportSize({ width: 1440, height: 900 });
|
||
const entry = async (section: string, sub?: string) => {
|
||
await page.goto(`/jobs/${id}?section=${section}${sub ? `&sub=${sub}` : ''}&followMode=waiting-update`);
|
||
await expect(page.getByRole('heading', { name: title, exact: true })).toBeVisible();
|
||
await expect(page.getByRole('tablist', { name: t.workspaceSections }).getByRole('tab')).toHaveCount(7);
|
||
expect(new URL(page.url()).searchParams.get('followMode')).toBe('waiting-update');
|
||
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
|
||
};
|
||
await entry('overview'); await capture(page, info, `sections-overview-${language}-${mode}`);
|
||
await entry('analysis'); await expect(page.getByRole('heading', { name: t.workspaceCvMatch, exact: true })).toBeVisible();
|
||
await capture(page, info, `sections-match-${language}-${mode}`);
|
||
await entry('cv'); await expect(page.getByRole('combobox', { name: new RegExp(t.assetsAttachedCv) })).toBeVisible();
|
||
await expect(page.getByRole('link', { name: t.assetsEditPreviewExport })).toHaveAttribute('href', `/career/builder/${variant.id}`);
|
||
await expect(page.getByRole('button', { name: t.assetsPackageCapture, exact: true })).toHaveCount(0);
|
||
await capture(page, info, `sections-current-cv-${language}-${mode}`);
|
||
await entry('cover-letter'); await expect(page.getByRole('textbox', { name: t.jobDetailsCoverLetter, exact: true })).toHaveValue('Reviewed letter — æøå');
|
||
await capture(page, info, `sections-letter-${language}-${mode}`);
|
||
await entry('application', 'answers');
|
||
const answer = page.getByRole('textbox', { name: t.assetsApplicationAnswer, exact: true });
|
||
await expect(answer).toHaveValue('Reviewed answer — æøå');
|
||
await answer.fill('Edited answer, pending explicit save — æøå');
|
||
await page.getByRole('tab', { name: t.workspaceSubmission, exact: true }).click();
|
||
await expect(page.getByRole('dialog', { name: t.workspaceUnsavedTitle })).toBeVisible();
|
||
await page.getByRole('button', { name: t.workspaceKeepEditing, exact: true }).click();
|
||
await expect(answer).toHaveValue('Edited answer, pending explicit save — æøå');
|
||
const save = page.waitForResponse(r => r.url() === `${jobUrl}/application-drafts` && r.request().method() === 'PUT');
|
||
await page.getByRole('button', { name: t.assetsSaveDrafts, exact: true }).click(); expect((await save).status()).toBe(204);
|
||
await page.getByRole('tab', { name: t.workspaceSubmission, exact: true }).click();
|
||
const freeze = page.waitForResponse(r => r.url() === `${jobUrl}/submitted-packages` && r.request().method() === 'POST');
|
||
await page.getByRole('button', { name: t.assetsPackageCapture, exact: true }).click(); expect((await freeze).status()).toBe(200);
|
||
await expect(page.getByText(t.assetsSubmittedPackageVersion.replace('{version}', '1'), { exact: true })).toBeVisible();
|
||
await capture(page, info, `sections-submission-${language}-${mode}`);
|
||
await page.getByRole('button', { name: t.assetsPackageView, exact: true }).click();
|
||
await expect(page.getByRole('dialog').getByText(t.workspaceFrozenHelp, { exact: true })).toBeVisible();
|
||
await expect(page.getByRole('dialog').getByText('Reviewed letter — æøå', { exact: true })).toBeVisible();
|
||
await capture(page, info, `sections-frozen-${language}-${mode}`);
|
||
await page.keyboard.press('Escape'); await expect(page.getByRole('button', { name: t.assetsPackageView, exact: true })).toBeFocused();
|
||
await entry('documents'); await expect(page.getByRole('switch', { name: `${t.attachmentsAiUse}: Employment evidence — æøå.txt` })).toBeVisible();
|
||
await expect(page.getByRole('columnheader', { name: t.attachmentsUploaded, exact: true })).toBeVisible();
|
||
await expect(page.getByRole('button', { name: t.assetsPackageCapture, exact: true })).toHaveCount(0);
|
||
await capture(page, info, `sections-documents-${language}-${mode}`);
|
||
await entry('timeline'); await expect(page.getByRole('heading', { name: t.intelligenceTimeline, exact: true })).toBeVisible();
|
||
await expect(page.locator('time').first()).toBeVisible(); await capture(page, info, `sections-timeline-${language}-${mode}`);
|
||
await entry('communication'); await expect(page.getByText('Interview invitation — æøå', { exact: true })).toBeVisible();
|
||
await capture(page, info, `sections-correspondence-${language}-${mode}`);
|
||
await entry('notes');
|
||
await expect(page.getByRole('region', { name: t.workspaceNotes, exact: true })).toContainText('Human notes — æøå');
|
||
await expect(page.getByRole('region', { name: t.workspaceNotes, exact: true })).not.toContainText('Edited answer');
|
||
const complete = page.waitForResponse(r => r.url().includes('/checklist/') && r.request().method() === 'PATCH');
|
||
const task = page.getByRole('checkbox', { name: 'Review employer questions — æøå', exact: true });
|
||
await task.click(); expect((await complete).status()).toBe(200);
|
||
await expect(task).toBeChecked(); // Existing owner re-reads after the server write; no optimistic tick.
|
||
await capture(page, info, `sections-notes-tasks-${language}-${mode}`);
|
||
const notes = page.getByRole('region', { name: t.workspaceNotes, exact: true });
|
||
await notes.getByRole('button', { name: t.workspaceEdit, exact: true }).click();
|
||
await page.getByRole('dialog').getByRole('textbox', { name: t.editJobNotes, exact: true }).fill('Edited human notes — æøå');
|
||
const notesSave = page.waitForResponse(r => r.url() === jobUrl && r.request().method() === 'PUT');
|
||
await page.getByRole('dialog').getByRole('button', { name: t.save, exact: true }).click(); expect((await notesSave).status()).toBe(204);
|
||
await expect(notes).toContainText('Edited human notes — æøå');
|
||
expect((await (await page.request.get(`${jobUrl}/workspace`)).json()).applicationAnswerDraft).toBe('Edited answer, pending explicit save — æøå');
|
||
await entry('interview'); await expect(page.getByText('Ask about sustainable delivery', { exact: true })).toBeVisible();
|
||
await capture(page, info, `sections-interview-${language}-${mode}`);
|
||
for (const [width, height, section] of [[768, 1024, 'timeline'], [390, 844, 'communication'], [320, 844, 'documents'], [320, 844, 'application']] as const) {
|
||
await page.setViewportSize({ width, height }); await entry(section, section === 'application' ? 'submission' : undefined);
|
||
const selected = page.getByRole('tablist', { name: t.workspaceSections }).locator('[aria-selected=true]'); await selected.focus();
|
||
await expect(selected).toBeFocused();
|
||
await capture(page, info, `sections-${section}-${width}-${language}-${mode}`);
|
||
if (section === 'documents') {
|
||
const files = page.getByRole('region', { name: t.attachmentsTitle.replace('{count}', '1'), exact: true });
|
||
await files.focus(); await page.keyboard.press('ArrowRight');
|
||
await expect.poll(() => files.evaluate(node => node.scrollLeft)).toBeGreaterThan(0);
|
||
await capture(page, info, `sections-document-table-${width}-${language}-${mode}`);
|
||
}
|
||
}
|
||
const scan = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
|
||
await info.attach(`sections-axe-${language}-${mode}`, { body: JSON.stringify({ violations: scan.violations, incomplete: scan.incomplete }), contentType: 'application/json' });
|
||
expect(scan.violations).toEqual([]);
|
||
const sparse = await page.request.post(`${apiUrl}/jobapplications`, { headers, data: { companyId, jobTitle: 'Sparse sections', status: 'Saved' } });
|
||
expect(sparse.status()).toBe(201);
|
||
await page.goto(`/jobs/${(await sparse.json()).id}?section=documents`);
|
||
await expect(page.getByText(t.attachmentsEmpty, { exact: true })).toBeVisible();
|
||
await expect(page.getByText(t.assetsNoSubmittedPackages, { exact: true })).toBeVisible();
|
||
await capture(page, info, `sections-sparse-${language}-${mode}`);
|
||
await info.attach(`sections-native-invariants-${language}-${mode}`, { body: JSON.stringify({ packageCapture: true, dirtyAnswersKeptAndExplicitlySaved: true, currentVsFrozenSeparated: true, personalNotesSeparate: true, checklistWriteSucceeded: true, legacyCvLetterCommunicationNotes: true, followModePreserved: true }), contentType: 'application/json' });
|
||
});
|
||
}
|