feat(ui): redesign job workspace sections

This commit is contained in:
cesnimda
2026-10-07 01:58:31 +02:00
parent acd1d94684
commit ac6d78289d
123 changed files with 2842 additions and 205 deletions
@@ -0,0 +1,175 @@
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' });
});
}