import AxeBuilder from '@axe-core/playwright'; import { createHash } from 'node:crypto'; import { readFile } from 'node:fs/promises'; import { expect, test as base, type APIRequestContext, type APIResponse, type Locator, type Page, type Response, type TestInfo } from '@playwright/test'; import { translations } from '../src/i18n/translations'; import { emptyStructuredCv } from '../src/profileCv'; import type { CvVariant, CvVariantSummary } from '../src/cvBuilder'; import type { ApplicationCv, CoverLetter, SubmittedPackage, SubmittedPackageDetail, WorkspaceOverview } from '../src/applicationWorkspace'; import { removeApplicationAnswerDraft } from '../src/applicationDrafts'; const apiUrl = 'http://localhost:5302/api'; const password = 'E2ePassword123!'; type Owner = { id: string; headers: Record }; type Sample = { language: 'en' | 'nb'; theme: 'light' | 'dark'; width: number; height: number }; type Subsection = 'cv' | 'letter' | 'answers' | 'submission'; type Job = { id: number; jobTitle: string; notes: string | null }; type RequestRecord = { method: string; path: string; body: unknown }; type Session = { owner: Owner; admin: APIRequestContext; administrator: Owner; requests: RequestRecord[] }; const desktop: Sample = { language: 'en', theme: 'light', width: 1440, height: 900 }; const boundary = 'Actual owner HTTP fixtures use disposable isolated SQLite and existing application/CV/attachment APIs. Free accounts disable AI; the serial runtime disables model, delivery and alert workers. No provider/model/generation/send endpoint, raw SQL, schema or production write. Controlled precommit HTTP failures and held requests qualify scoped presentation/recovery, not network-uncertain idempotency or production reliability. Request metadata includes only fixture application/CV/file paths and bodies; no auth/CSRF/session headers. Existing seven workspace sections, CV renderer, immutable packages, one-job CV association and append-only cover history are preserved. Existing P2-D01 remains a separately executed ordinary preservation case; this file supplements that corpus. Duplicate-for-job remains two writes; created-copy/failed-attach is explicitly qualified, with no automatic second duplicate. Current AttachmentsTable and its separate ID0-download debt are outside this slice.'; function jobUrl(id: number) { return `${apiUrl}/jobapplications/${id}`; } function interpolate(template: string, params: Record) { return template.replace(/\{(\w+)\}/g, (_, key: string) => String(params[key] ?? `{${key}}`)); } function hold() { let release!: () => void; const pending = new Promise(resolve => { release = resolve; }); return { pending, release }; } async function json(response: APIResponse | Response, status = 200): Promise { expect(response.status(), response.url()).toBe(status); return response.json(); } function response(page: Page, method: string, url: string) { return page.waitForResponse(value => value.url() === url && value.request().method() === method); } async function keyboard(page: Page, control: Locator) { await control.focus(); await expect(control).toBeFocused(); await page.keyboard.press('Enter'); } function count(session: Session, method: string, path: string) { return session.requests.filter(value => value.method === method && value.path === path); } function mutations(session: Session) { return session.requests.filter(value => ['POST', 'PUT', 'PATCH', 'DELETE'].includes(value.method)); } function assetReads(session: Session, id: number) { const start = `/api/jobapplications/${id}/`; return session.requests.filter(value => value.method === 'GET' && value.path.startsWith(start) && ['cv', 'tailoring', 'cover-letter', 'submitted-packages'].includes(value.path.slice(start.length).split('/')[0])); } function section(page: Page, title: string) { return page.getByRole('region', { name: title, exact: true }); } function childFocus(page: Page, title: string) { return page.getByRole('group', { name: title, exact: true }); } function letter(page: Page, language: Sample['language'] = 'en') { return page.getByRole('textbox', { name: translations[language].jobDetailsCoverLetter, exact: true }); } function attachedCv(page: Page, language: Sample['language'] = 'en') { const label = translations[language].assetsAttachedCv.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // MUI names its Select from the field label and the current selected value. return page.getByRole('combobox', { name: new RegExp(`^${label}(?:\\s|$)`) }); } function drafts(page: Page, language: Sample['language'] = 'en') { return { answer: page.getByRole('textbox', { name: translations[language].assetsApplicationAnswer, exact: true }), message: page.getByRole('textbox', { name: translations[language].assetsRecruiterMessage, exact: true }), }; } async function login(api: APIRequestContext, email: string): Promise { expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200); const me = await json<{ id: string; sessionContext: string }>(await api.get(`${apiUrl}/auth/me`)); const csrf = (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN'); expect(csrf).toBeDefined(); return { id: me.id, headers: { 'X-CSRF-TOKEN': csrf!.value, 'X-Session-Context': me.sessionContext } }; } async function account(admin: APIRequestContext, owner: Owner) { const email = `p706-forms-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`; expect((await admin.post(`${apiUrl}/users`, { headers: owner.headers, data: { email, password, roles: [] } })).status()).toBe(200); return email; } const test = base.extend<{ session: Session }>({ session: async ({ page, context, playwright }, provide, info) => { expect(process.env.PLAYWRIGHT_REUSE_SERVERS).not.toBe('true'); expect(info.config.metadata.isolatedDataRoot).toEqual(expect.any(String)); const admin = await playwright.request.newContext(); const requests: RequestRecord[] = []; const blocked: string[] = []; page.on('request', request => { const url = new URL(request.url()); if (!/^\/api\/(?:jobapplications(?:\/|$)|cv\/variants(?:\/|$)|attachments(?:\/|$))/.test(url.pathname)) return; requests.push({ method: request.method(), path: url.pathname, body: request.postData() ? request.postDataJSON() : null }); }); try { const administrator = await login(admin, 'e2e@example.test'); const owner = await login(page.request, await account(admin, administrator)); expect((await json<{ entitlements: { ai: boolean } }>(await page.request.get(`${apiUrl}/auth/me`))).entitlements.ai).toBe(false); await context.route('**/*', route => { const url = new URL(route.request().url()); if (['localhost', '127.0.0.1'].includes(url.hostname) || ['blob:', 'data:', 'about:'].includes(url.protocol)) return route.continue(); blocked.push(url.origin + url.pathname); return route.abort(); }); await provide({ owner, admin, administrator, requests }); } finally { await info.attach('application-forms-boundary', { body: JSON.stringify({ boundary, requests, blockedExternalRequests: blocked }), contentType: 'application/json' }); await admin.dispose(); } }, }); function profile(headline = 'Original reviewed career evidence') { const empty = emptyStructuredCv(); return { ...empty, contact: { ...empty.contact, fullName: 'Åse Recorded Application Identity', headline }, summary: ['Canonical owner-reviewed evidence — æøå'], skills: ['C#', 'SQL'] }; } async function source(api: APIRequestContext, owner: Owner, headline?: string) { return json(await api.put(`${apiUrl}/career/profile`, { headers: owner.headers, data: { profile: profile(headline), cvText: 'Exact owner-reviewed source — æøå' } })); } async function createJob(api: APIRequestContext, owner: Owner, title = 'Recorded application material — æøå') { const company = await json<{ id: number }>(await api.post(`${apiUrl}/companies`, { headers: owner.headers, data: { name: 'Recorded application company — æøå', source: 'User' } }), 201); return json(await api.post(`${apiUrl}/jobapplications`, { headers: owner.headers, data: { companyId: company.id, jobTitle: title, status: 'Saved', notes: 'Human notes remain separate — æøå', description: 'Recorded C# and SQL role; fixture facts only.', } }), 201); } async function variant(api: APIRequestContext, owner: Owner, name: string) { return json(await api.post(`${apiUrl}/cv/variants`, { headers: owner.headers, data: { name, settings: { themeId: 'minimal', language: 'nb-NO', showPhoto: false, showPageNumbers: false, showIcons: true, sections: [], overrides: {}, customSections: [] }, } })); } async function saveLetter(api: APIRequestContext, owner: Owner, id: number, text: string) { return json(await api.put(`${jobUrl(id)}/cover-letter`, { headers: owner.headers, data: { text, source: 'manual' } })); } async function saveDrafts(api: APIRequestContext, owner: Owner, id: number, answer: string, message: string) { expect((await api.put(`${jobUrl(id)}/application-drafts`, { headers: owner.headers, data: { applicationAnswerDraft: answer, recruiterMessageDraft: message } })).status()).toBe(204); } async function upload(api: APIRequestContext, owner: Owner, id: number) { const bytes = Buffer.from('Exact archived application evidence — æøå\nOriginal bytes retained.\n', 'utf8'); const name = 'Recorded application evidence — æøå.txt'; expect((await api.post(`${apiUrl}/attachments`, { headers: owner.headers, multipart: { jobId: String(id), files: { name, mimeType: 'text/plain', buffer: bytes } } })).ok()).toBe(true); const [attachment] = await json<{ id: number; fileName: string }[]>(await api.get(`${apiUrl}/attachments/${id}`)); expect(attachment.id).toBeGreaterThan(0); return { attachment, bytes, name }; } async function fixture(page: Page, owner: Owner, long = false) { await source(page.request, owner); const job = await createJob(page.request, owner, long ? 'Langsiktig søknad om samfunnskritisk plattformutvikling med mange samarbeidspartnere og særnorske bokstaver Æ Ø Å' : undefined); const cv = await variant(page.request, owner, long ? 'CV med lang dokumenttittel for samfunnskritisk plattformutvikling og inkluderende arbeidsprosesser Æ Ø Å' : 'Recorded attached application CV'); await json(await page.request.put(`${jobUrl(job.id)}/cv`, { headers: owner.headers, data: { variantId: cv.id } })); const originalLetter = long ? ('Reviewed long cover letter — æøå. Existing storage remains plain markdown.\n\n').repeat(55).trim() : 'Original reviewed cover letter — æøå'; const answer = long ? ('Recorded motivation and application answer — æøå.\n').repeat(24).trim() : 'Original application answer — æøå'; const message = long ? ('Reviewed recruiter message draft; never sent — æøå.\n').repeat(14).trim() : 'Original recruiter message; never sent'; await saveLetter(page.request, owner, job.id, originalLetter); await saveDrafts(page.request, owner, job.id, answer, message); return { job, cv, originalLetter, answer, message }; } async function frozenFixture(page: Page, owner: Owner, long = false) { const seeded = await fixture(page, owner, long); const file = await upload(page.request, owner, seeded.job.id); const preserved = await json(await page.request.post(`${jobUrl(seeded.job.id)}/submitted-packages`, { headers: owner.headers })); const detail = await json(await page.request.get(`${jobUrl(seeded.job.id)}/submitted-packages/${preserved.id}`)); return { ...seeded, ...file, preserved, detail }; } async function enter(page: Page, owner: Owner, job: Job, sub: Subsection, sample: Sample = desktop) { expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers: owner.headers, data: { language: sample.language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204); await page.addInitScript(({ language, theme }) => { if (localStorage.getItem('uiLanguage') === null) localStorage.setItem('uiLanguage', language); if (localStorage.getItem('jobtracker.themeMode') === null) localStorage.setItem('jobtracker.themeMode', theme); }, sample); await page.setViewportSize({ width: sample.width, height: sample.height }); await page.emulateMedia({ reducedMotion: 'reduce' }); await page.goto(`/jobs/${job.id}?section=application&sub=${sub}&source=phase7-forms&followMode=waiting-update#materials`); const stored = await page.evaluate(() => ({ language: localStorage.getItem('uiLanguage'), theme: localStorage.getItem('jobtracker.themeMode') })); if (stored.language !== sample.language || stored.theme !== sample.theme) { await page.evaluate(({ language, theme }) => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', theme); }, sample); await page.reload(); } const t = translations[sample.language]; await expect(page.locator('html')).toHaveAttribute('lang', sample.language === 'nb' ? 'nb-NO' : 'en'); await expect(page.locator('html')).toHaveAttribute('data-color-scheme', sample.theme); await expect(page.getByRole('heading', { level: 1 })).toHaveCount(1); await expect(page.getByRole('heading', { level: 2, name: job.jobTitle, exact: true })).toBeVisible(); await expect(page.getByRole('tablist', { name: t.workspaceSections, exact: true }).getByRole('tab')).toHaveCount(7); await expect(page.getByRole('tablist', { name: t.workspaceApplicationSections, exact: true }).getByRole('tab')).toHaveCount(4); expect(new URL(page.url()).searchParams.get('followMode')).toBe('waiting-update'); } async function selectCv(page: Page, value: CvVariant | null, language: Sample['language'] = 'en') { await keyboard(page, attachedCv(page, language)); const option = value ? page.getByRole('option').filter({ hasText: value.name }) : page.getByRole('option', { name: translations[language].assetsNone, exact: true }); await keyboard(page, option); } test('[P7-06 PRES-W01 W04 W10] same-route job navigation waits for owned answers and preserves both jobs on save', async ({ page, session }, info) => { const first = await fixture(page, session.owner); const original = await json<{ companyId: number }>(await page.request.get(jobUrl(first.job.id))); const second = await json(await page.request.post(`${apiUrl}/jobapplications`, { headers: session.owner.headers, data: { companyId: original.companyId, jobTitle: 'Second independent application draft owner', status: 'Saved', notes: 'Independent second-job notes', } }), 201); const secondAnswer = 'Owned second-job answer — æøå'; const secondMessage = 'Owned second-job recruiter message; never sent'; await saveDrafts(page.request, session.owner, second.id, secondAnswer, secondMessage); await enter(page, session.owner, first.job, 'answers'); await expect(drafts(page).answer).toHaveValue(first.answer); const origin = await page.evaluate(() => performance.timeOrigin); const held = hold(); const reached = hold(); const workspaceUrl = `${jobUrl(second.id)}/workspace`; await page.route(workspaceUrl, async route => { reached.release(); await held.pending; await route.continue(); }); try { await keyboard(page, page.getByRole('button', { name: new RegExp(translations.en.quickSearch) })); const commands = page.getByRole('dialog', { name: translations.en.quickSearch }); await commands.getByRole('textbox', { name: translations.en.quickSearch }).fill(second.jobTitle); await keyboard(page, commands.getByRole('button').filter({ hasText: second.jobTitle })); await reached.pending; await expect(page).toHaveURL(new RegExp(`/jobs/${second.id}$`)); expect(await page.evaluate(() => performance.timeOrigin)).toBe(origin); await keyboard(page, page.getByRole('tab', { name: translations.en.workspaceApplication, exact: true })); await keyboard(page, page.getByRole('tab', { name: translations.en.workspaceAnswers, exact: true })); await expect(drafts(page).answer).toHaveCount(0); await expect(drafts(page).message).toHaveCount(0); expect(count(session, 'PUT', `/api/jobapplications/${second.id}/application-drafts`)).toHaveLength(0); await capture(page, info, desktop, 'held-second-job-aggregate-no-foreign-drafts'); const loaded = response(page, 'GET', workspaceUrl); held.release(); await loaded; await expect(drafts(page).answer).toHaveValue(secondAnswer); await expect(drafts(page).message).toHaveValue(secondMessage); const reviewed = 'Edited second-job answer only — æøå'; await drafts(page).answer.fill(reviewed); const saved = response(page, 'PUT', `${jobUrl(second.id)}/application-drafts`); await keyboard(page, page.getByRole('button', { name: translations.en.assetsSaveDrafts, exact: true })); expect((await saved).status()).toBe(204); await expect(drafts(page).message).toHaveValue(secondMessage); const persistedSecond = await json(await page.request.get(workspaceUrl)); expect(persistedSecond.applicationAnswerDraft).toBe(reviewed); expect(persistedSecond.recruiterMessageDraft).toBe(secondMessage); const persistedFirst = await json(await page.request.get(`${jobUrl(first.job.id)}/workspace`)); expect(persistedFirst.applicationAnswerDraft).toBe(first.answer); expect(persistedFirst.recruiterMessageDraft).toBe(first.message); expect(count(session, 'PUT', `/api/jobapplications/${second.id}/application-drafts`)).toEqual([{ method: 'PUT', path: `/api/jobapplications/${second.id}/application-drafts`, body: { applicationAnswerDraft: reviewed, recruiterMessageDraft: secondMessage } }]); await capture(page, info, desktop, 'second-job-answer-saved-first-job-preserved'); } finally { held.release(); await page.unroute(workspaceUrl); } }); async function capture(page: Page, info: TestInfo, sample: Sample, state: string) { await page.evaluate(async () => { await document.fonts.ready; await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); }); const geometry = await page.evaluate(() => ({ width: innerWidth, height: innerHeight, rootOverflow: Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth), focused: { tag: document.activeElement?.tagName, role: document.activeElement?.getAttribute('role'), label: document.activeElement?.getAttribute('aria-label') } })); const axe = await new AxeBuilder({ page }).options({ rules: { 'target-size': { enabled: true } } }).analyze(); const name = `p706-forms-${sample.language}-${sample.theme}-${sample.width}-${state}`; await info.attach(`${name}-audit`, { body: JSON.stringify({ sample, state, geometry, boundary, ariaSnapshot: await page.locator('body').ariaSnapshot(), axe: { scope: 'All default-enabled rules plus target-size, no exclusions', violations: axe.violations, incomplete: axe.incomplete, passes: axe.passes.map(rule => ({ id: rule.id, nodes: rule.nodes.length })) }, qualifications: ['Finite Chromium states, not all combinations or physical devices.', 'All incomplete vectors retained for contextual triage; not human screen-reader, touch/software keyboard or real browser zoom acceptance.', 'Full-page screenshots do not establish real viewport focus occlusion or every-pixel contrast.', 'Print iframe/canvas may scroll internally; outer app root reflow measured separately. Frozen HTML unchanged, not a substitute for existing full fidelity corpus.'], }), contentType: 'application/json' }); await info.attach(name, { body: await page.screenshot({ fullPage: true, animations: 'disabled' }), contentType: 'image/png' }); expect(geometry.rootOverflow).toBe(0); expect(axe.violations).toEqual([]); } test('[P7-06 forms] held CV read Retry preserves its request and actual attach/replace/detach remain owner scoped', async ({ page, session, playwright }, info) => { test.setTimeout(120_000); await source(page.request, session.owner); const job = await createJob(page.request, session.owner); const first = await variant(page.request, session.owner, 'First reusable CV'); const second = await variant(page.request, session.owner, 'Second reusable CV'); const gate = hold(); let reads = 0; await page.route(`${jobUrl(job.id)}/cv`, async route => { if (route.request().method() !== 'GET') return route.continue(); reads += 1; if (reads === 1) { await gate.pending; await route.fulfill({ status: 503, json: {} }); } else return route.continue(); }); try { await enter(page, session.owner, job, 'cv'); await expect(section(page, translations.en.workspaceCv).getByRole('status')).toContainText(translations.en.loading); await capture(page, info, desktop, 'held-cv-read'); gate.release(); await expect(section(page, translations.en.workspaceCv).getByRole('alert')).toContainText(translations.en.assetsLoadFailed); await keyboard(page, section(page, translations.en.workspaceCv).getByRole('button', { name: translations.en.retry, exact: true })); await expect(childFocus(page, translations.en.workspaceCv)).toBeFocused(); await expect.poll(() => reads).toBe(2); await expect(attachedCv(page)).toBeVisible(); for (const current of [first, second, null]) { const written = response(page, 'PUT', `${jobUrl(job.id)}/cv`); await selectCv(page, current); expect((await written).status()).toBe(200); await expect(attachedCv(page)).toBeEnabled(); expect((await json(await page.request.get(`${jobUrl(job.id)}/cv`))).attachedVariantId).toBe(current?.id ?? null); } expect(count(session, 'PUT', `/api/jobapplications/${job.id}/cv`).map(value => value.body)).toEqual([{ variantId: first.id }, { variantId: second.id }, { variantId: null }]); const available = await json(await page.request.get(`${apiUrl}/cv/variants`)); expect(available.map(value => value.id).sort((a, b) => a - b)).toEqual([first.id, second.id].sort((a, b) => a - b)); expect(available.every(value => value.jobApplicationId === null)).toBe(true); const foreign = await playwright.request.newContext(); try { const other = await login(foreign, await account(session.admin, session.administrator)); const foreignVariant = await variant(foreign, other, 'Foreign private variant'); expect((await page.request.put(`${jobUrl(job.id)}/cv`, { headers: session.owner.headers, data: { variantId: foreignVariant.id } })).status()).toBe(404); expect((await page.request.put(`${jobUrl(job.id)}/cv`, { headers: session.owner.headers, data: { variantId: 2147483647 } })).status()).toBe(404); for (const suffix of ['cv', 'cover-letter', 'submitted-packages']) expect((await foreign.get(`${jobUrl(job.id)}/${suffix}`)).status()).toBe(404); expect((await foreign.put(`${jobUrl(job.id)}/cv`, { headers: other.headers, data: { variantId: null } })).status()).toBe(404); expect((await json(await page.request.get(`${jobUrl(job.id)}/cv`))).availableVariants.some(value => value.id === foreignVariant.id)).toBe(false); } finally { await foreign.dispose(); } await capture(page, info, desktop, 'cv-detached-preserved'); } finally { gate.release(); } }); test('[P7-06 forms] long letter failed held Save retains snapshot and dirty restore Cancel writes nothing before additive explicit restore', async ({ page, session }, info) => { test.setTimeout(120_000); const seeded = await fixture(page, session.owner); const url = `${jobUrl(seeded.job.id)}/cover-letter`; await saveLetter(page.request, session.owner, seeded.job.id, 'Second reviewed letter — æøå'); const before = await json(await page.request.get(url)); await enter(page, session.owner, seeded.job, 'letter'); const draft = ' ' + ('Long manually reviewed application letter — æøå. Preserve every sentence and markdown **evidence**.\n\n').repeat(60) + ' '; await letter(page).fill(draft); const gate = hold(); let saves = 0; await page.route(url, async route => { if (route.request().method() !== 'PUT') return route.continue(); saves += 1; if (saves === 1) { await gate.pending; await route.fulfill({ status: 503, json: {} }); } else return route.continue(); }); try { const body = section(page, translations.en.workspaceCoverLetter); await keyboard(page, body.getByRole('button', { name: translations.en.save, exact: true })); await expect.poll(() => saves).toBe(1); await expect(letter(page)).toBeDisabled(); await expect(childFocus(page, translations.en.workspaceCoverLetter)).toBeFocused(); await expect(body.getByRole('button', { name: translations.en.assetsDiscardChanges, exact: true })).toBeDisabled(); expect(count(session, 'PUT', `/api/jobapplications/${seeded.job.id}/cover-letter`).map(value => value.body)).toEqual([{ text: draft, source: 'manual' }]); await capture(page, info, desktop, 'held-letter-save'); gate.release(); await expect(body.getByRole('alert')).toContainText(translations.en.assetsCoverSaveFailed); await expect(letter(page)).toHaveValue(draft); expect(await json(await page.request.get(url))).toEqual(before); expect(saves).toBe(1); const saved = response(page, 'PUT', url); await keyboard(page, body.getByRole('button', { name: translations.en.save, exact: true })); const current = await json(await saved); expect(current.text).toBe(draft.trim()); expect(current.currentVersion).toBe(3); expect(current.versions.map(value => [value.version, value.source, value.aiAction])).toEqual([[3, 'manual', null], [2, 'manual', null], [1, 'manual', null]]); await expect(letter(page)).toHaveValue(draft.trim()); await letter(page).fill('Unsaved changes must survive Cancel — æøå'); const restore = page.getByRole('button', { name: interpolate(translations.en.assetsRestoreVersionNumber, { version: 1 }), exact: true }); await keyboard(page, restore); const dialog = page.getByRole('dialog', { name: translations.en.assetsRestoreDiscardTitle, exact: true }); await expect(dialog).toContainText(translations.en.assetsRestoreDiscardMessage); await keyboard(page, dialog.getByRole('button', { name: translations.en.cancel, exact: true })); await expect(dialog).toHaveCount(0); await expect(restore).toBeFocused(); await expect(letter(page)).toHaveValue('Unsaved changes must survive Cancel — æøå'); expect(count(session, 'POST', `/api/jobapplications/${seeded.job.id}/cover-letter/versions/1/restore`)).toHaveLength(0); expect(await json(await page.request.get(url))).toEqual(current); await keyboard(page, restore); const restored = response(page, 'POST', `${url}/versions/1/restore`); await keyboard(page, dialog.getByRole('button', { name: translations.en.assetsRestoreConfirm, exact: true })); const readback = await json(await restored); expect(readback.text).toBe(seeded.originalLetter); expect(readback.currentVersion).toBe(4); expect(readback.versions.map(value => [value.version, value.source, value.aiAction])).toEqual([[4, 'restore', null], [3, 'manual', null], [2, 'manual', null], [1, 'manual', null]]); await expect(letter(page)).toHaveValue(seeded.originalLetter); await capture(page, info, desktop, 'restored-additive-letter-history'); } finally { gate.release(); } }); test('[P7-06 forms] failed held Answers save preserves both drafts and ordinary Notes through explicit recovery and reload', async ({ page, session }, info) => { test.setTimeout(90_000); const seeded = await fixture(page, session.owner); const url = `${jobUrl(seeded.job.id)}/application-drafts`; const original = await json(await page.request.get(jobUrl(seeded.job.id))); await enter(page, session.owner, seeded.job, 'answers'); const fields = drafts(page); const answer = 'Reviewed answer with **specific evidence** — æøå\nSecond line.'; const message = 'Reviewed recruiter note — æøå; draft only, never sent.'; await fields.answer.fill(answer); await fields.message.fill(message); const gate = hold(); let writes = 0; await page.route(url, async route => { writes += 1; if (writes === 1) { await gate.pending; await route.fulfill({ status: 503, json: {} }); } else return route.continue(); }); try { const body = section(page, translations.en.assetsApplicationDrafts); await keyboard(page, body.getByRole('button', { name: translations.en.assetsSaveDrafts, exact: true })); await expect.poll(() => writes).toBe(1); await expect(fields.answer).toBeDisabled(); await expect(fields.message).toBeDisabled(); await expect(childFocus(page, translations.en.assetsApplicationDrafts)).toBeFocused(); expect(count(session, 'PUT', `/api/jobapplications/${seeded.job.id}/application-drafts`).map(value => value.body)).toEqual([{ applicationAnswerDraft: answer, recruiterMessageDraft: message }]); await capture(page, info, desktop, 'held-two-draft-save'); gate.release(); await expect(body.getByRole('alert')).toContainText(translations.en.assetsDraftsSaveFailed); await expect(fields.answer).toHaveValue(answer); await expect(fields.message).toHaveValue(message); expect((await json(await page.request.get(`${jobUrl(seeded.job.id)}/workspace`))).applicationAnswerDraft).toBe(seeded.answer); expect(writes).toBe(1); const saved = response(page, 'PUT', url); await keyboard(page, body.getByRole('button', { name: translations.en.assetsSaveDrafts, exact: true })); expect((await saved).status()).toBe(204); await expect(body.getByRole('button', { name: translations.en.assetsSaveDrafts, exact: true })).toBeDisabled(); await page.reload(); await expect(drafts(page).answer).toHaveValue(answer); await expect(drafts(page).message).toHaveValue(message); const readback = await json(await page.request.get(`${jobUrl(seeded.job.id)}/workspace`)); expect(readback.applicationAnswerDraft).toBe(answer); expect(readback.recruiterMessageDraft).toBe(message); const stored = await json(await page.request.get(jobUrl(seeded.job.id))); expect(removeApplicationAnswerDraft(stored.notes)).toBe(removeApplicationAnswerDraft(original.notes)); await keyboard(page, page.getByRole('tablist', { name: translations.en.workspaceSections, exact: true }).getByRole('tab', { name: translations.en.workspaceTasks, exact: true })); await expect(section(page, translations.en.workspaceNotes)).toContainText('Human notes remain separate — æøå'); await expect(section(page, translations.en.workspaceNotes)).not.toContainText(answer); expect(count(session, 'PUT', `/api/jobapplications/${seeded.job.id}/application-drafts`)).toHaveLength(2); await capture(page, info, desktop, 'drafts-saved-personal-notes-preserved'); } finally { gate.release(); } }); test('[P7-06 forms] actual native capture freezes CV and exact archive bytes after current sources change without renderer mutation', async ({ page, session }, info) => { test.setTimeout(120_000); const seeded = await fixture(page, session.owner); const file = await upload(page.request, session.owner, seeded.job.id); await enter(page, session.owner, seeded.job, 'submission'); const captured = response(page, 'POST', `${jobUrl(seeded.job.id)}/submitted-packages`); await keyboard(page, section(page, translations.en.assetsSubmittedPackages).getByRole('button', { name: translations.en.assetsPackageCapture, exact: true })); const summary = await json(await captured); expect(summary.version).toBe(1); expect(summary.attachments).toHaveLength(1); const preservedUrl = `${jobUrl(seeded.job.id)}/submitted-packages/${summary.id}`; const before = await json(await page.request.get(preservedUrl)); expect(before.renderedCvHtml).toContain('Original reviewed career evidence'); expect(before.coverLetterText).toBe(seeded.originalLetter); expect(summary.cvVariantId).toBe(seeded.cv.id); expect(summary.cvVariantVersion).toBe(seeded.cv.version); expect(summary.attachments[0].sha256).toBe(createHash('sha256').update(file.bytes).digest('hex')); await saveLetter(page.request, session.owner, seeded.job.id, 'Later current letter — æøå'); await saveDrafts(page.request, session.owner, seeded.job.id, 'Later answer', 'Later recruiter draft'); const cv = await json(await page.request.get(`${apiUrl}/cv/variants/${seeded.cv.id}`)); const changedCv = await json(await page.request.put(`${apiUrl}/cv/variants/${cv.id}`, { headers: session.owner.headers, data: { name: 'Later current CV', settings: { ...cv.settings, headline: 'Later CV-only headline' }, expectedVersion: cv.version, source: 'manual' } })); await source(page.request, session.owner, 'Later canonical headline'); expect((await page.request.delete(`${apiUrl}/attachments/${file.attachment.id}`, { headers: session.owner.headers })).status()).toBe(204); expect(await json(await page.request.get(preservedUrl))).toEqual(before); const archiveUrl = `${preservedUrl}/attachments/${summary.attachments[0].id}`; const archived = await page.request.get(archiveUrl); expect(archived.status()).toBe(200); expect(await archived.body()).toEqual(file.bytes); await page.reload(); const view = section(page, translations.en.assetsSubmittedPackages).getByRole('button', { name: translations.en.assetsPackageView, exact: true }); await keyboard(page, view); const dialog = page.getByRole('dialog', { name: interpolate(translations.en.assetsSubmittedPackageVersion, { version: 1 }), exact: true }); await expect(dialog).toContainText(seeded.originalLetter); await expect(dialog).toContainText(seeded.answer); await expect(dialog).not.toContainText('Later current letter'); const scrollRegion = dialog.getByRole('region', { name: interpolate(translations.en.assetsSubmittedPackageVersion, { version: 1 }), exact: true }); await scrollRegion.focus(); await expect(scrollRegion).toBeFocused(); const beforeScroll = await scrollRegion.evaluate(node => node.scrollTop); await page.keyboard.press('PageDown'); await expect.poll(() => scrollRegion.evaluate(node => node.scrollTop)).toBeGreaterThan(beforeScroll); await page.keyboard.press('Home'); await expect.poll(() => scrollRegion.evaluate(node => node.scrollTop)).toBe(0); const iframe = dialog.getByTitle(translations.en.assetsPackageCvPreview, { exact: true }); await expect(iframe).toHaveAttribute('sandbox', ''); await expect(iframe).toHaveAttribute('srcdoc', before.renderedCvHtml!); expect(await iframe.evaluate(node => getComputedStyle(node).backgroundColor)).toBe('rgb(255, 255, 255)'); await capture(page, info, desktop, 'frozen-original-white-print-preview'); await page.keyboard.press('Escape'); await expect(view).toBeFocused(); const download = page.waitForEvent('download'); await keyboard(page, section(page, translations.en.assetsSubmittedPackages).getByRole('button').filter({ hasText: file.name })); const result = await download; expect(result.suggestedFilename()).toBe(file.name); const path = await result.path(); expect(path).not.toBeNull(); expect(await readFile(path!)).toEqual(file.bytes); expect(count(session, 'POST', `/api/jobapplications/${seeded.job.id}/submitted-packages`)).toHaveLength(1); expect(await json(await page.request.get(preservedUrl))).toEqual(before); await info.attach('application-frozen-bytes', { body: JSON.stringify({ boundary, packageId: summary.id, recordedFileSha256: summary.attachments[0].sha256, downloadSha256: createHash('sha256').update(file.bytes).digest('hex'), preservedCvVersion: summary.cvVariantVersion, currentCvVersion: changedCv.version, qualification: 'Exact scoped real-HTTP current-source changes and archive-byte checks; existing ordinary P2-D01/full renderer fidelity runs remain required separately.' }), contentType: 'application/json' }); }); test('[P7-06 forms] failed preserved detail and download retain history and Retry only the original owned read', async ({ page, session }, info) => { test.setTimeout(120_000); const seeded = await frozenFixture(page, session.owner); const detailUrl = `${jobUrl(seeded.job.id)}/submitted-packages/${seeded.preserved.id}`; const downloadUrl = `${detailUrl}/attachments/${seeded.preserved.attachments[0].id}`; await enter(page, session.owner, seeded.job, 'submission'); const body = section(page, translations.en.assetsSubmittedPackages); await expect(body.getByRole('button', { name: translations.en.assetsPackageView, exact: true })).toBeVisible(); const gate = hold(); let details = 0; let downloads = 0; await page.route(detailUrl, async route => { details += 1; if (details === 1) await route.fulfill({ status: 503, json: {} }); else { await gate.pending; await route.continue(); } }); await page.route(downloadUrl, async route => { downloads += 1; if (downloads === 1) await route.fulfill({ status: 503, json: {} }); else return route.continue(); }); try { await keyboard(page, body.getByRole('button', { name: translations.en.assetsPackageView, exact: true })); await expect(body.getByRole('alert')).toContainText(translations.en.assetsPackageLoadFailed); await expect(body.getByText(interpolate(translations.en.assetsSubmittedPackageVersion, { version: 1 }), { exact: true })).toBeVisible(); await expect(body.getByRole('button', { name: translations.en.assetsPackageView, exact: true })).toBeVisible(); expect(mutations(session)).toEqual([]); const baseline = assetReads(session, seeded.job.id).length; await keyboard(page, body.getByRole('button', { name: translations.en.retry, exact: true })); await expect.poll(() => details).toBe(2); await expect(childFocus(page, translations.en.assetsSubmittedPackages)).toBeFocused(); await capture(page, info, desktop, 'held-original-package-detail-retry'); gate.release(); const dialog = page.getByRole('dialog'); await expect(dialog).toContainText(seeded.originalLetter); await page.keyboard.press('Escape'); await expect(body.getByRole('button', { name: translations.en.assetsPackageView, exact: true })).toBeFocused(); expect(assetReads(session, seeded.job.id)).toHaveLength(baseline + 1); await keyboard(page, body.getByRole('button').filter({ hasText: seeded.name })); await expect(body.getByRole('alert')).toContainText(translations.en.assetsPackageDownloadFailed); await expect(body.getByText(interpolate(translations.en.assetsSubmittedPackageVersion, { version: 1 }), { exact: true })).toBeVisible(); const download = page.waitForEvent('download'); await keyboard(page, body.getByRole('button', { name: translations.en.retry, exact: true })); const result = await download; const path = await result.path(); expect(path).not.toBeNull(); expect(await readFile(path!)).toEqual(seeded.bytes); expect(details).toBe(2); expect(downloads).toBe(2); expect(mutations(session)).toEqual([]); expect(await json(await page.request.get(detailUrl))).toEqual(seeded.detail); await capture(page, info, desktop, 'original-archive-retry-history-retained'); } finally { gate.release(); } }); test('[P7-06 forms] live EN/Bokmal retains dirty letter and both answer drafts without asset GET or writes', async ({ page, session }, info) => { test.setTimeout(90_000); const seeded = await fixture(page, session.owner); await enter(page, session.owner, seeded.job, 'letter'); await expect(letter(page)).toHaveValue(seeded.originalLetter); await letter(page).fill('Dirty owner text remains the same across UI locale — æøå'); let reads = assetReads(session, seeded.job.id).length; const firstLocale = response(page, 'PUT', `${apiUrl}/auth/preferences/language`); await keyboard(page, page.locator('header').first().getByRole('button', { name: 'Norsk', exact: true })); const firstLocaleResponse = await firstLocale; await info.attach('application-locale-first-http', { body: JSON.stringify({ status: firstLocaleResponse.status(), body: await firstLocaleResponse.text() }), contentType: 'application/json' }); expect(firstLocaleResponse.status()).toBe(204); await expect(page.locator('html')).toHaveAttribute('lang', 'nb-NO'); await expect(letter(page, 'nb')).toHaveValue('Dirty owner text remains the same across UI locale — æøå'); expect(assetReads(session, seeded.job.id)).toHaveLength(reads); expect(mutations(session)).toEqual([]); await capture(page, info, { ...desktop, language: 'nb' }, 'dirty-letter-live-locale'); await keyboard(page, section(page, translations.nb.workspaceCoverLetter).getByRole('button', { name: translations.nb.assetsDiscardChanges, exact: true })); await keyboard(page, page.getByRole('tablist', { name: translations.nb.workspaceApplicationSections, exact: true }).getByRole('tab', { name: translations.nb.workspaceAnswers, exact: true })); const fields = drafts(page, 'nb'); await expect(fields.answer).toHaveValue(seeded.answer); await fields.answer.fill('Ulagret søknadssvar — æøå'); await fields.message.fill('Ulagret melding; ikke sendt — æøå'); reads = assetReads(session, seeded.job.id).length; const secondLocale = response(page, 'PUT', `${apiUrl}/auth/preferences/language`); await keyboard(page, page.locator('header').first().getByRole('button', { name: 'English', exact: true })); const secondLocaleResponse = await secondLocale; await info.attach('application-locale-second-http', { body: JSON.stringify({ status: secondLocaleResponse.status(), body: await secondLocaleResponse.text() }), contentType: 'application/json' }); expect(secondLocaleResponse.status()).toBe(204); await expect(page.locator('html')).toHaveAttribute('lang', 'en'); await expect(drafts(page).answer).toHaveValue('Ulagret søknadssvar — æøå'); await expect(drafts(page).message).toHaveValue('Ulagret melding; ikke sendt — æøå'); expect(assetReads(session, seeded.job.id)).toHaveLength(reads); expect(mutations(session)).toEqual([]); await capture(page, info, desktop, 'dirty-two-drafts-live-locale'); await info.attach('application-locale-contract', { body: JSON.stringify({ boundary, assetGetDelta: 0, writes: 0, qualification: 'Assets only: existing workspace aggregate may independently reload on locale. Persisted document/draft language is not rewritten by UI language; no model calls.' }), contentType: 'application/json' }); }); test('[P7-06 forms] real duplicate then failed attach preserves created copy and original without automatic duplicate retry', async ({ page, session }, info) => { test.setTimeout(90_000); const seeded = await fixture(page, session.owner); const original = await json(await page.request.get(`${apiUrl}/cv/variants/${seeded.cv.id}`)); await enter(page, session.owner, seeded.job, 'cv'); const attachUrl = `${jobUrl(seeded.job.id)}/cv`; let attaches = 0; await page.route(attachUrl, async route => { if (route.request().method() !== 'PUT') return route.continue(); attaches += 1; return attaches === 1 ? route.fulfill({ status: 503, json: {} }) : route.continue(); }); const copyResponse = response(page, 'POST', `${apiUrl}/cv/variants/${seeded.cv.id}/duplicate`); await keyboard(page, section(page, translations.en.workspaceCv).getByRole('button', { name: translations.en.assetsDuplicateForJob, exact: true })); const copy = await json(await copyResponse); await expect(section(page, translations.en.workspaceCv).getByRole('alert').filter({ hasText: translations.en.assetsAttachCvFailed })).toBeVisible(); await expect(section(page, translations.en.workspaceCv).getByText(translations.en.assetsCopyCreatedHelp, { exact: true })).toBeVisible(); await expect(attachedCv(page)).toContainText(seeded.cv.name); // DuplicateAsync preserves JobApplicationId before the second attach write. The // existing read projects the newest associated variant, even while this UI // still holds its pre-write selection. Explicit attach normalises cardinality. expect((await json(await page.request.get(attachUrl))).attachedVariantId).toBe(copy.id); expect(await json(await page.request.get(`${apiUrl}/cv/variants/${seeded.cv.id}`))).toEqual(original); const partial = await json(await page.request.get(`${apiUrl}/cv/variants`)); expect(partial.map(value => value.id).sort((a, b) => a - b)).toEqual([seeded.cv.id, copy.id].sort((a, b) => a - b)); expect(partial.filter(value => value.jobApplicationId === seeded.job.id).map(value => value.id).sort((a, b) => a - b)).toEqual([seeded.cv.id, copy.id].sort((a, b) => a - b)); await expect(section(page, translations.en.workspaceCv).getByRole('button', { name: translations.en.retry, exact: true })).toHaveCount(0); await capture(page, info, desktop, 'created-copy-failed-attach-preserved'); expect(count(session, 'POST', `/api/cv/variants/${seeded.cv.id}/duplicate`)).toHaveLength(1); expect(attaches).toBe(1); const recovery = response(page, 'PUT', attachUrl); const builderRead = response(page, 'GET', `${apiUrl}/cv/variants/${copy.id}`); await keyboard(page, section(page, translations.en.workspaceCv).getByRole('button', { name: translations.en.assetsAttachCreatedCopy, exact: true })); expect((await recovery).status()).toBe(200); await expect(page).toHaveURL(new RegExp(`/career/builder/${copy.id}$`)); expect(await json(await builderRead)).toMatchObject({ id: copy.id, name: copy.name }); expect((await json(await page.request.get(attachUrl))).attachedVariantId).toBe(copy.id); expect((await json(await page.request.get(`${apiUrl}/cv/variants`))).filter(value => value.jobApplicationId === seeded.job.id).map(value => value.id)).toEqual([copy.id]); expect(count(session, 'POST', `/api/cv/variants/${seeded.cv.id}/duplicate`)).toHaveLength(1); expect(attaches).toBe(2); await info.attach('application-duplicate-partial-write', { body: JSON.stringify({ boundary, originalId: seeded.cv.id, createdCopyId: copy.id, duplicatePostCount: 1, attachAttempts: 2, recovery: 'Explicit Attach created copy reuses its exact ID, normalises association cardinality and opens its existing builder; no second duplicate.', qualification: 'Existing duplicate retains JobApplicationId on both original and copy before explicit attach; newest-copy read projection can differ from stale UI. Two nontransactional HTTP writes remain, without rollback/idempotency/version guarantee.' }), contentType: 'application/json' }); }); test('[P7-06 forms] eight finite bilingual theme widths keep long CV, letter, drafts and frozen preview usable with keyboard', async ({ page, session }, info) => { test.setTimeout(240_000); const seeded = await frozenFixture(page, session.owner, true); const samples: (Sample & { sub: Subsection })[] = [ { language: 'en', theme: 'light', width: 320, height: 740, sub: 'answers' }, { language: 'nb', theme: 'dark', width: 360, height: 844, sub: 'letter' }, { language: 'en', theme: 'dark', width: 390, height: 844, sub: 'submission' }, { language: 'nb', theme: 'light', width: 768, height: 1024, sub: 'cv' }, { language: 'en', theme: 'dark', width: 1024, height: 768, sub: 'answers' }, { language: 'nb', theme: 'light', width: 1280, height: 320, sub: 'letter' }, { language: 'en', theme: 'light', width: 1440, height: 900, sub: 'submission' }, { language: 'nb', theme: 'dark', width: 1920, height: 1080, sub: 'cv' }, ]; for (const sample of samples) { await enter(page, session.owner, seeded.job, sample.sub, sample); const t = translations[sample.language]; let letterHint: { anchor: Locator; hint: Locator; id: string } | null = null; const selected = page.getByRole('tablist', { name: t.workspaceApplicationSections, exact: true }).locator('[aria-selected=true]'); await selected.focus(); await expect(selected).toBeFocused(); if (sample.sub === 'letter') { await expect(letter(page, sample.language)).toHaveValue(seeded.originalLetter); await letter(page, sample.language).focus(); await expect(letter(page, sample.language)).toBeFocused(); for (const title of [t.richTextBold, t.richTextItalic, t.richTextUnderline, t.richTextLink]) { const button = section(page, t.workspaceCoverLetter).getByRole('button', { name: title, exact: true }); await button.focus(); await expect(button).toBeFocused(); } const anchor = section(page, t.workspaceCoverLetter).getByRole('button', { name: t.richTextLink, exact: true }); await expect(anchor).toHaveAttribute('aria-label', t.richTextLink); const visibleHint = page.getByRole('tooltip', { name: t.richTextLink, exact: true }); await expect(visibleHint).toBeVisible(); // Existing MUI Tooltip names the child; describeChild is false, so there // is no aria-describedby contract to invent for this toolbar button. const id = await visibleHint.getAttribute('id'); expect(id).not.toBeNull(); const hint = page.locator(`[id="${id}"]`); await expect(hint).toHaveAttribute('role', 'tooltip'); await expect(hint).toBeVisible(); letterHint = { anchor, hint, id: id! }; } else if (sample.sub === 'answers') { const fields = drafts(page, sample.language); await expect(fields.answer).toHaveValue(seeded.answer); await expect(fields.message).toHaveValue(seeded.message); await fields.answer.focus(); await expect(fields.answer).toBeFocused(); await fields.message.focus(); await expect(fields.message).toBeFocused(); } else if (sample.sub === 'cv') { const control = attachedCv(page, sample.language); await expect(control).toContainText(seeded.cv.name); await control.focus(); await expect(control).toBeFocused(); await expect(page.getByRole('link', { name: t.assetsEditPreviewExport, exact: true })).toHaveAttribute('href', `/career/builder/${seeded.cv.id}`); } else { const view = section(page, t.assetsSubmittedPackages).getByRole('button', { name: t.assetsPackageView, exact: true }); await keyboard(page, view); const dialog = page.getByRole('dialog', { name: interpolate(t.assetsSubmittedPackageVersion, { version: 1 }), exact: true }); await expect(dialog).toContainText(seeded.originalLetter); const iframe = dialog.getByTitle(t.assetsPackageCvPreview, { exact: true }); await expect(iframe).toHaveAttribute('srcdoc', seeded.detail.renderedCvHtml!); await expect(iframe).toHaveAttribute('sandbox', ''); expect(await iframe.evaluate(node => getComputedStyle(node).backgroundColor)).toBe('rgb(255, 255, 255)'); const close = dialog.getByRole('button', { name: t.close, exact: true }); await close.focus(); await expect(close).toBeFocused(); } await page.evaluate(() => scrollTo(0, 0)); if (letterHint) { await expect(letterHint.anchor).toBeFocused(); const geometry = await letterHint.anchor.evaluate(node => { const rect = node.getBoundingClientRect(); return { top: rect.top, bottom: rect.bottom, left: rect.left, right: rect.right, viewportWidth: innerWidth, viewportHeight: innerHeight, whollyOutsideViewport: rect.bottom <= 0 || rect.top >= innerHeight || rect.right <= 0 || rect.left >= innerWidth }; }); await expect(letterHint.hint).toBeAttached(); if (geometry.whollyOutsideViewport) await expect(letterHint.hint).toBeHidden(); else await expect(letterHint.hint).toBeVisible(); await info.attach(`application-letter-hint-${sample.language}-${sample.width}`, { body: JSON.stringify({ matchingTooltipId: letterHint.id, anchorAccessibleName: t.richTextLink, geometry, expectation: geometry.whollyOutsideViewport ? 'Focused anchor outside viewport: matching hint hidden' : 'Focused anchor intersects viewport: matching hint visible' }), contentType: 'application/json' }); } await capture(page, info, sample, `long-${sample.sub}`); if (sample.sub === 'submission') { await page.keyboard.press('Escape'); await expect(section(page, t.assetsSubmittedPackages).getByRole('button', { name: t.assetsPackageView, exact: true })).toBeFocused(); } } expect(mutations(session)).toEqual([]); }); test('[P7-06 forms] controlled language HTTP503 preserves local dirty drafts and Retry only repeats the language request', async ({ page, session }, info) => { test.setTimeout(90_000); const seeded = await fixture(page, session.owner); await enter(page, session.owner, seeded.job, 'letter'); await expect(letter(page)).toHaveValue(seeded.originalLetter); const persistedBefore = await json<{ uiLanguage: string }>(await page.request.get(`${apiUrl}/auth/me`)); expect(persistedBefore.uiLanguage).toBe('en'); const preferenceUrl = `${apiUrl}/auth/preferences/language`; const preferenceAttempts: { method: string; body: unknown }[] = []; await page.route(preferenceUrl, async route => { if (route.request().method() !== 'PUT') return route.continue(); preferenceAttempts.push({ method: route.request().method(), body: route.request().postDataJSON() }); return route.fulfill({ status: 503, json: {} }); }); const dirtyLetter = 'Controlled-failure draft; retain my exact unsaved letter — æøå'; await letter(page).fill(dirtyLetter); let reads = assetReads(session, seeded.job.id).length; const firstResponse = response(page, 'PUT', preferenceUrl); await keyboard(page, page.locator('header').first().getByRole('button', { name: 'Norsk', exact: true })); expect((await firstResponse).status()).toBe(503); await expect(page.locator('html')).toHaveAttribute('lang', 'nb-NO'); await expect(letter(page, 'nb')).toHaveValue(dirtyLetter); const nbFeedback = page.getByRole('alert').filter({ hasText: translations.nb.settingsLanguageSaveFailed }); await expect(nbFeedback).toBeVisible(); const firstRetry = response(page, 'PUT', preferenceUrl); await keyboard(page, nbFeedback.getByRole('button', { name: translations.nb.retry, exact: true })); expect((await firstRetry).status()).toBe(503); await expect(nbFeedback).toBeVisible(); await expect(letter(page, 'nb')).toHaveValue(dirtyLetter); expect(preferenceAttempts).toEqual([{ method: 'PUT', body: { language: 'nb-NO' } }, { method: 'PUT', body: { language: 'nb-NO' } }]); expect(assetReads(session, seeded.job.id)).toHaveLength(reads); expect(mutations(session)).toEqual([]); await capture(page, info, { ...desktop, language: 'nb' }, 'controlled-language503-dirty-letter'); await keyboard(page, section(page, translations.nb.workspaceCoverLetter).getByRole('button', { name: translations.nb.assetsDiscardChanges, exact: true })); await keyboard(page, page.getByRole('tablist', { name: translations.nb.workspaceApplicationSections, exact: true }).getByRole('tab', { name: translations.nb.workspaceAnswers, exact: true })); await expect(drafts(page, 'nb').answer).toHaveValue(seeded.answer); const dirtyAnswer = 'Ulagret søknadssvar under kontrollert språkfeil — æøå'; const dirtyMessage = 'Ulagret melding; ikke sendt under språkfeilen — æøå'; await drafts(page, 'nb').answer.fill(dirtyAnswer); await drafts(page, 'nb').message.fill(dirtyMessage); reads = assetReads(session, seeded.job.id).length; const secondResponse = response(page, 'PUT', preferenceUrl); await keyboard(page, page.locator('header').first().getByRole('button', { name: 'English', exact: true })); expect((await secondResponse).status()).toBe(503); await expect(page.locator('html')).toHaveAttribute('lang', 'en'); await expect(drafts(page).answer).toHaveValue(dirtyAnswer); await expect(drafts(page).message).toHaveValue(dirtyMessage); const enFeedback = page.getByRole('alert').filter({ hasText: translations.en.settingsLanguageSaveFailed }); await expect(enFeedback).toBeVisible(); const secondRetry = response(page, 'PUT', preferenceUrl); await keyboard(page, enFeedback.getByRole('button', { name: translations.en.retry, exact: true })); expect((await secondRetry).status()).toBe(503); await expect(enFeedback).toBeVisible(); await expect(drafts(page).answer).toHaveValue(dirtyAnswer); await expect(drafts(page).message).toHaveValue(dirtyMessage); expect(preferenceAttempts).toEqual([{ method: 'PUT', body: { language: 'nb-NO' } }, { method: 'PUT', body: { language: 'nb-NO' } }, { method: 'PUT', body: { language: 'en' } }, { method: 'PUT', body: { language: 'en' } }]); expect(assetReads(session, seeded.job.id)).toHaveLength(reads); expect(mutations(session)).toEqual([]); const persistedAfter = await json<{ uiLanguage: string }>(await page.request.get(`${apiUrl}/auth/me`)); expect(persistedAfter.uiLanguage).toBe(persistedBefore.uiLanguage); expect((await json(await page.request.get(`${jobUrl(seeded.job.id)}/cover-letter`))).text).toBe(seeded.originalLetter); const workspace = await json(await page.request.get(`${jobUrl(seeded.job.id)}/workspace`)); expect(workspace.applicationAnswerDraft).toBe(seeded.answer); expect(workspace.recruiterMessageDraft).toBe(seeded.message); await capture(page, info, desktop, 'controlled-language503-dirty-answer-drafts'); await info.attach('application-controlled-language503-boundary', { body: JSON.stringify({ boundary, controlledResponseStatus: 503, preferenceAttempts, persistedBefore: persistedBefore.uiLanguage, persistedAfter: persistedAfter.uiLanguage, qualification: 'Page-only language PUT is deliberately fulfilled503 before server execution. Proves local presentation, retained drafts and exact Retry request without asset GET/job writes; it does not prove account preference persistence or close RD11. The separate ordinary real-server locale case keeps its strict204 assertions and observed500 failures.' }), contentType: 'application/json' }); });