import AxeBuilder from '@axe-core/playwright'; 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 type { Company } from '../src/types'; const apiUrl = 'http://localhost:5302/api'; const companiesUrl = `${apiUrl}/companies`; const password = 'E2ePassword123!'; type Owner = { id: string; headers: Record }; type Sample = { language: 'en' | 'nb'; theme: 'light' | 'dark'; width: number; height: number }; type RequestRecord = { method: string; path: string; query: Record; body: unknown }; type Session = { owner: Owner; requests: RequestRecord[] }; type Job = { id: number; jobTitle: string; status: string }; type Contact = { id: number; jobApplicationId: number; subject: string; date: string; from: string; channel: string; direction: string }; type Relationship = { company: Company; applications: Array<{ id: number; jobTitle: string; status: string; dateApplied: string | null; followUpAt: string | null; messageCount: number; lastContactAt: string | null }>; recentContacts: Array; }; type Draft = { name: string; location: string; source: string; recruiterName: string; recruiterEmail: string; recruiterLinkedIn: string; pipelineStage: string; lastContactedAt: string; nextContactAt: string }; const desktop: Sample = { language: 'en', theme: 'light', width: 1440, height: 900 }; const boundary = 'Company creation is an API fixture: the existing Companies page has no standalone create interface. Company list/detail/nine-field PUT, job creation/soft deletion and manual correspondence use the actual isolated owner HTTP API and disposable SQLite. Route-held requests continue to that API when released; explicitly controlled failures establish presentation and recovery only. No provider/model/external service is invoked. Browser request records contain only Companies method/path/query/JSON payload; direct fixture/readback requests are verified separately and no session or CSRF headers are recorded.'; async function json(result: APIResponse | Response, status = 200): Promise { expect(result.status(), `${result.url()} returns ${status}`).toBe(status); return result.json(); } 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 createAccount(admin: APIRequestContext, administrator: Owner) { const email = `p7-companies-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`; expect((await admin.post(`${apiUrl}/users`, { headers: administrator.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 blockedExternalRequests: string[] = []; page.on('request', request => { const url = new URL(request.url()); if (!/^\/api\/companies(?:\/|$)/.test(url.pathname)) return; requests.push({ method: request.method(), path: url.pathname, query: Object.fromEntries(url.searchParams), body: request.postData() ? request.postDataJSON() : null }); }); try { const administrator = await login(admin, 'e2e@example.test'); const owner = await login(page.request, await createAccount(admin, administrator)); await context.route('**/*', route => { const url = new URL(route.request().url()); if (!['localhost', '127.0.0.1'].includes(url.hostname) && !['data:', 'blob:', 'about:'].includes(url.protocol)) { blockedExternalRequests.push(url.origin + url.pathname); return route.abort(); } return route.continue(); }); await provide({ owner, requests }); } finally { await info.attach('companies-request-boundary', { body: JSON.stringify({ boundary, requests, blockedExternalRequests }), contentType: 'application/json' }); await admin.dispose(); } }, }); async function createCompany(page: Page, owner: Owner, name: string, location = 'Oslo', source = 'User') { return json(await page.request.post(companiesUrl, { headers: owner.headers, data: { name, location, source } }), 201); } async function nativeCompany(page: Page, company: Company) { return json(await page.request.get(`${companiesUrl}/${company.id}`)); } async function nativeList(api: APIRequestContext) { return json(await api.get(companiesUrl)); } async function createJob(page: Page, owner: Owner, company: Company, title: string, status = 'Saved') { return json(await page.request.post(`${apiUrl}/jobapplications`, { headers: owner.headers, data: { companyId: company.id, jobTitle: title, status } }), 201); } async function createContact(page: Page, owner: Owner, job: Job, index: number, subject = `Recorded contact ${String(index).padStart(2, '0')}`) { return json(await page.request.post(`${apiUrl}/correspondence`, { headers: owner.headers, data: { jobApplicationId: job.id, from: `Recruiter ${index}`, content: `Owner-recorded correspondence ${index}; no external email delivery.`, subject, channel: 'email', direction: 'inbound', date: new Date(Date.UTC(2026, 10, 1, 10, index)).toISOString() } }), 201); } function edit(page: Page, company: Company, language: Sample['language'] = 'en') { return page.getByRole('button', { name: `${translations[language].jobTableEdit}: ${company.name}`, exact: true }); } function relationshipAction(page: Page, company: Company, language: Sample['language'] = 'en') { return page.getByRole('button', { name: `${translations[language].companiesRelationship}: ${company.name}`, exact: true }); } function editDialog(page: Page, language: Sample['language'] = 'en') { return page.getByRole('dialog', { name: translations[language].companiesEdit, exact: true }); } function editRegion(dialog: Locator, language: Sample['language'] = 'en') { return dialog.getByRole('region', { name: translations[language].companiesEdit, exact: true }); } function relationshipRegion(dialog: Locator, language: Sample['language'] = 'en') { return dialog.getByRole('region', { name: translations[language].companiesRelationship, exact: true }); } function fields(language: Sample['language']) { const t = translations[language]; return { name: t.companiesName, location: t.companiesLocation, source: t.companiesSource, recruiterName: t.companiesRecruiterName, recruiterEmail: t.companiesRecruiterEmail, recruiterLinkedIn: t.companiesRecruiterLinkedIn, pipelineStage: t.companiesPipelineStage, lastContactedAt: t.companiesLastContacted, nextContactAt: t.companiesNextContactField }; } async function fillDraft(dialog: Locator, draft: Draft, language: Sample['language'] = 'en') { const labels = fields(language); for (const key of Object.keys(labels) as Array) await dialog.getByLabel(labels[key], { exact: true }).fill(draft[key]); } async function expectDraft(dialog: Locator, draft: Draft, language: Sample['language'] = 'en') { const labels = fields(language); for (const key of Object.keys(labels) as Array) await expect(dialog.getByLabel(labels[key], { exact: true })).toHaveValue(draft[key]); } function snapshot(draft: Draft) { return { ...draft, recruiterName: draft.recruiterName.trim() || null, recruiterEmail: draft.recruiterEmail.trim() || null, recruiterLinkedIn: draft.recruiterLinkedIn.trim() || null, pipelineStage: draft.pipelineStage.trim() || null, lastContactedAt: draft.lastContactedAt || null, nextContactAt: draft.nextContactAt || null, location: draft.location || null, source: draft.source || null }; } function draftFor(company: Company, name = `${company.name} reviewed`): Draft { return { name, location: ' Bergen ', source: 'https://www.linkedin.com/', recruiterName: ' Rekrutterer Æ Ø Å ', recruiterEmail: ' reviewed@example.test ', recruiterLinkedIn: ' https://example.test/recruiter ', pipelineStage: ' Interview ', lastContactedAt: '2026-11-21', nextContactAt: '2026-11-23' }; } function hold() { let release!: () => void; const pending = new Promise(resolve => { release = resolve; }); return { pending, release }; } function response(page: Page, method: string, url: string) { return page.waitForResponse(result => result.url() === url && result.request().method() === method); } function count(requests: RequestRecord[], method: string, path: string) { return requests.filter(request => request.method === method && request.path === path).length; } async function keyboardActivate(page: Page, locator: Locator, key = 'Enter') { await locator.focus(); await expect(locator).toBeFocused(); await page.keyboard.press(key); } async function recordFocus(page: Page, info: TestInfo, state: string) { await info.attach(`companies-focus-${state}`, { body: JSON.stringify({ state, focused: await page.evaluate(() => { const element = document.activeElement; return { tag: element?.tagName, id: element?.id, role: element?.getAttribute('role'), label: element?.getAttribute('aria-label'), text: element?.textContent?.trim().slice(0, 140) }; }), qualification: 'Actual isolated Chromium focus; this is not human screen-reader or physical-device acceptance.' }), contentType: 'application/json' }); } async function enter(page: Page, owner: Owner, sample: Sample = desktop, url = '/companies?source=phase7-companies#preserved') { 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(url); 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(); } 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: 1, name: translations[sample.language].companies, exact: true })).toBeVisible(); } async function locked(dialog: Locator, language: Sample['language'] = 'en') { const t = translations[language]; for (const label of Object.values(fields(language))) await expect(dialog.getByLabel(label, { exact: true })).toBeDisabled(); await expect(dialog.getByRole('button', { name: t.cancel, exact: true })).toBeDisabled(); await expect(dialog.getByRole('button', { name: t.save, exact: true })).toBeDisabled(); } 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), visibleHorizontalBounds: [...document.querySelectorAll('body *')] .filter(element => element.getClientRects().length > 0 && getComputedStyle(element).visibility !== 'hidden' && !element.closest('[hidden],[aria-hidden="true"]')) .map(element => { const rect = element.getBoundingClientRect(); return { tag: element.tagName, role: element.getAttribute('role'), label: element.getAttribute('aria-label'), text: element.textContent?.slice(0, 100), left: rect.left, right: rect.right, width: rect.width, insideNamedScrollRegion: element.closest('[role="region"][aria-label]')?.getAttribute('aria-label') ?? null }; }) .filter(element => element.width > 0 && (element.left < -1 || element.right > innerWidth + 1)).slice(0, 30), scrollRegions: [...document.querySelectorAll('[role="region"][aria-label]')].map(element => { const rect = element.getBoundingClientRect(); return { label: element.getAttribute('aria-label'), tabIndex: element.tabIndex, clientWidth: element.clientWidth, scrollWidth: element.scrollWidth, clientHeight: element.clientHeight, scrollHeight: element.scrollHeight, left: rect.left, right: rect.right, overflowX: getComputedStyle(element).overflowX, overflowY: getComputedStyle(element).overflowY }; }), })); const axe = await new AxeBuilder({ page }).options({ rules: { 'target-size': { enabled: true } } }).analyze(); const name = `p7-companies-${sample.language}-${sample.theme}-${sample.width}-${state}`; await info.attach(`${name}-audit`, { body: JSON.stringify({ sample, state, geometry, ariaSnapshot: await page.locator('body').ariaSnapshot(), boundary, axe: { scope: 'All enabled rules plus target-size; all violations/incomplete vectors retained', violations: axe.violations, incomplete: axe.incomplete, passes: axe.passes.map(rule => ({ id: rule.id, nodes: rule.nodes.length })) }, qualifications: ['Finite Chromium states, not a Cartesian width/language/theme/state claim.', 'A named bounded desktop company table may legitimately scroll in two dimensions; its geometry is recorded separately from app-root overflow.', 'Axe incomplete findings require contextual triage; automated keyboard/focus checks do not establish human screen-reader acceptance.', 'Real zoom, physical touch/software keyboard, deployed fonts/photos and production acceptance remain unverified.'], }), contentType: 'application/json' }); await info.attach(name, { body: await page.screenshot({ fullPage: true, animations: 'disabled' }), contentType: 'image/png' }); expect(geometry.rootOverflow, `${name} app chrome reflows`).toBe(0); expect(axe.violations, `${name} complete axe violations`).toEqual([]); } test('[P7-05 Companies] nine-field save persists and owned relationship projects latest20 excluding deleted jobs', async ({ page, session, playwright }, info) => { test.setTimeout(120_000); const company = await createCompany(page, session.owner, 'Recorded company relationship'); const first = await createJob(page, session.owner, company, 'Owned application A', 'Applied'); const second = await createJob(page, session.owner, company, 'Owned application B', 'Interview'); const deleted = await createJob(page, session.owner, company, 'Deleted application must remain absent'); const contacts: Contact[] = []; for (let index = 0; index < 22; index++) contacts.push(await createContact(page, session.owner, index % 2 === 0 ? first : second, index)); const deletedContact = await createContact(page, session.owner, deleted, 90, 'Deleted-job private contact must remain absent'); expect((await page.request.delete(`${apiUrl}/jobapplications/${deleted.id}`, { headers: session.owner.headers })).status()).toBe(204); await enter(page, session.owner); await keyboardActivate(page, edit(page, company)); const draft = draftFor(company); await fillDraft(editDialog(page), draft); const savedResponse = response(page, 'PUT', `${companiesUrl}/${company.id}`); await keyboardActivate(page, editDialog(page).getByRole('button', { name: translations.en.save, exact: true })); const saved = await json(await savedResponse); expect(saved).toMatchObject({ id: company.id, name: draft.name, location: 'Bergen', source: 'LinkedIn', recruiterName: 'Rekrutterer Æ Ø Å', recruiterEmail: 'reviewed@example.test', recruiterLinkedIn: 'https://example.test/recruiter', pipelineStage: 'Interview' }); expect(saved.lastContactedAt?.slice(0, 10)).toBe(draft.lastContactedAt); expect(saved.nextContactAt?.slice(0, 10)).toBe(draft.nextContactAt); expect(session.requests.filter(request => request.method === 'PUT' && request.path === `/api/companies/${company.id}`)).toEqual([ { method: 'PUT', path: `/api/companies/${company.id}`, query: {}, body: snapshot(draft) }, ]); expect(await nativeCompany(page, saved)).toEqual(saved); await expect(editDialog(page)).toHaveCount(0); await expect(edit(page, saved)).toBeFocused(); await page.reload(); await expect(edit(page, saved)).toBeVisible(); expect(await nativeList(page.request)).toEqual([saved]); await capture(page, info, desktop, 'saved-reloaded'); const loaded = response(page, 'GET', `${companiesUrl}/${company.id}/relationship`); await keyboardActivate(page, relationshipAction(page, saved)); const relationship = await json(await loaded); expect(relationship.company).toEqual(saved); expect(relationship.applications.map(row => row.id).sort((a, b) => a - b)).toEqual([first.id, second.id].sort((a, b) => a - b)); for (const job of [first, second]) { const expected = contacts.filter(contact => contact.jobApplicationId === job.id); const application = relationship.applications.find(row => row.id === job.id); expect(application).toMatchObject({ jobTitle: job.jobTitle, status: job.status, messageCount: 11 }); // SQLite reloads DateTime without its Kind suffix. Check every recorded UTC // calendar second without inventing a retained JSON-Z formatting contract. expect(application?.lastContactAt?.slice(0, 19)).toBe(expected.at(-1)!.date.slice(0, 19)); } expect(relationship.recentContacts.map(contact => ({ ...contact, date: contact.date.slice(0, 19) }))).toEqual(contacts.slice(2).reverse().map(contact => ({ id: contact.id, jobApplicationId: contact.jobApplicationId, jobTitle: contact.jobApplicationId === first.id ? first.jobTitle : second.jobTitle, date: contact.date.slice(0, 19), direction: contact.direction, channel: contact.channel, subject: contact.subject, from: contact.from }))); const dialog = page.getByRole('dialog', { name: saved.name, exact: true }); for (const contact of contacts.slice(2)) await expect(dialog.getByText(contact.subject, { exact: true })).toBeVisible(); for (const excluded of [contacts[0].subject, contacts[1].subject, deleted.jobTitle, deletedContact.subject]) await expect(dialog.getByText(excluded, { exact: true })).toHaveCount(0); await expect(dialog.getByText(translations.en.companiesMessageCount.replace('{count}', '11'), { exact: false })).toHaveCount(2); await capture(page, info, desktop, 'latest20-owned-relationship'); await keyboardActivate(page, dialog.getByRole('button', { name: translations.en.close, exact: true })); await expect(relationshipAction(page, saved)).toBeFocused(); const admin = await playwright.request.newContext(); const foreign = await playwright.request.newContext(); const anonymous = await playwright.request.newContext(); const denials: Array<{ operation: string; status: number }> = []; try { const administrator = await login(admin, 'e2e@example.test'); const other = await login(foreign, await createAccount(admin, administrator)); expect(await nativeList(foreign)).toEqual([]); expect(await json(await foreign.get(`${companiesUrl}?ownerUserId=${session.owner.id}&id=${saved.id}`))).toEqual([]); for (const [operation, method, suffix, data] of [ ['foreign detail', 'GET', `/${saved.id}`, undefined], ['foreign relationship', 'GET', `/${saved.id}/relationship`, undefined], ['foreign nine-field update', 'PUT', `/${saved.id}?ownerUserId=${session.owner.id}`, { ...snapshot(draft), name: 'Forbidden rename', ownerUserId: session.owner.id }], ] as const) { const denied = await foreign.fetch(`${companiesUrl}${suffix}`, { method, headers: other.headers, data }); denials.push({ operation, status: denied.status() }); expect(denied.status(), operation).toBe(404); } const foreignCompany = await json(await foreign.post(companiesUrl, { headers: other.headers, data: { name: 'Other account company', location: 'Tromsø', source: 'User', ownerUserId: session.owner.id } }), 201); expect(await nativeList(foreign)).toEqual([foreignCompany]); expect((await page.request.get(`${companiesUrl}/${foreignCompany.id}/relationship`)).status()).toBe(404); expect((await page.request.put(`${companiesUrl}/${foreignCompany.id}`, { headers: session.owner.headers, data: snapshot(draft) })).status()).toBe(404); expect((await foreign.post(`${apiUrl}/jobapplications`, { headers: other.headers, data: { companyId: saved.id, jobTitle: 'Forbidden association', status: 'Saved' } })).status()).toBe(400); for (const [operation, method, suffix, data] of [ ['anonymous list', 'GET', '', undefined], ['anonymous detail', 'GET', `/${saved.id}`, undefined], ['anonymous relationship', 'GET', `/${saved.id}/relationship`, undefined], ['anonymous create', 'POST', '', { name: 'Unauthorized', location: null, source: null }], ['anonymous update', 'PUT', `/${saved.id}`, snapshot(draft)], ] as const) { const denied = await anonymous.fetch(`${companiesUrl}${suffix}`, { method, data }); denials.push({ operation, status: denied.status() }); expect(denied.status(), operation).toBe(401); } expect(await nativeList(page.request)).toEqual([saved]); expect(await nativeCompany(page, saved)).toEqual(saved); expect(await json(await page.request.get(`${companiesUrl}/${saved.id}/relationship`))).toEqual(relationship); await info.attach('companies-native-persistence-and-isolation', { body: JSON.stringify({ company: saved, relationship, deletedJobId: deleted.id, excludedDeletedContactId: deletedContact.id, denials, boundary, qualification: 'Actual native owner/foreign/anonymous HTTP and SQLite readbacks; no intercepted response in these admission/projection checks.' }), contentType: 'application/json' }); } finally { await admin.dispose(); await foreign.dispose(); await anonymous.dispose(); } }); test('[P7-05 Companies] held save locks one snapshot, retains a failed draft and recovers through its original Save', async ({ page, session }, info) => { test.setTimeout(120_000); const sample: Sample = { language: 'nb', theme: 'dark', width: 360, height: 844 }; const t = translations.nb; const company = await createCompany(page, session.owner, 'Recorded held company'); const draft = draftFor(company); const failure = hold(); const recovery = hold(); let writes = 0; await page.route(`${companiesUrl}/${company.id}`, async route => { if (route.request().method() !== 'PUT') return route.continue(); writes++; if (writes === 1) { await failure.pending; return route.fulfill({ status: 503, json: {} }); } await recovery.pending; return route.continue(); }); await enter(page, session.owner, sample); await keyboardActivate(page, edit(page, company, 'nb')); const dialog = editDialog(page, 'nb'); await fillDraft(dialog, draft, 'nb'); const failedResponse = response(page, 'PUT', `${companiesUrl}/${company.id}`); try { await keyboardActivate(page, dialog.getByRole('button', { name: t.save, exact: true })); await expect.poll(() => writes).toBe(1); await locked(dialog, 'nb'); await expect(editRegion(dialog, 'nb')).toBeFocused(); await recordFocus(page, info, 'held-save-stable-region'); await expect(dialog.getByRole('progressbar', { name: t.loading, exact: true })).toBeVisible(); await expect(dialog.getByRole('status')).toHaveText(t.loading); await expect(dialog.getByRole('status')).toHaveAttribute('aria-live', 'polite'); await page.keyboard.press('Enter'); await page.keyboard.press('Escape'); await expect(dialog).toBeVisible(); expect(writes).toBe(1); expect(await nativeCompany(page, company)).toEqual(company); await capture(page, info, sample, 'held-save'); } finally { failure.release(); } expect((await failedResponse).status()).toBe(503); await expect(dialog.getByRole('alert')).toHaveText(t.companiesUpdateFailed); await expectDraft(dialog, draft, 'nb'); await expect(dialog.getByRole('button', { name: t.save, exact: true })).toBeEnabled(); await expect(dialog.getByRole('button', { name: t.cancel, exact: true })).toBeEnabled(); await expect(dialog.getByRole('button', { name: t.retry, exact: true })).toHaveCount(0); expect(await nativeCompany(page, company)).toEqual(company); await capture(page, info, sample, 'failed-save-durable-draft'); const recoveredResponse = response(page, 'PUT', `${companiesUrl}/${company.id}`); try { await keyboardActivate(page, dialog.getByRole('button', { name: t.save, exact: true })); await expect.poll(() => writes).toBe(2); await locked(dialog, 'nb'); await expect(editRegion(dialog, 'nb')).toBeFocused(); await page.keyboard.press('Enter'); expect(writes).toBe(2); await capture(page, info, sample, 'recovery-save-held'); } finally { recovery.release(); } const saved = await json(await recoveredResponse); await expect(dialog).toHaveCount(0); await expect(edit(page, saved, 'nb')).toBeFocused(); await recordFocus(page, info, 'recovered-save-invoker'); expect(session.requests.filter(request => request.method === 'PUT')).toEqual([1, 2].map(() => ({ method: 'PUT', path: `/api/companies/${company.id}`, query: {}, body: snapshot(draft) }))); expect(await nativeCompany(page, saved)).toEqual(saved); await page.reload(); await expect(edit(page, saved, 'nb')).toBeVisible(); await keyboardActivate(page, edit(page, saved, 'nb')); await page.keyboard.press('Escape'); await expect(editDialog(page, 'nb')).toHaveCount(0); await expect(edit(page, saved, 'nb')).toBeFocused(); expect(writes).toBe(2); }); test('[P7-05 Companies] late relationship A cannot replace B and Retry reads the same company with stable focus', async ({ page, session }, info) => { test.setTimeout(150_000); const t = translations.en; const sample: Sample = { ...desktop, width: 390, height: 844, theme: 'dark' }; const a = await createCompany(page, session.owner, 'Recorded relationship A'); const b = await createCompany(page, session.owner, 'Recorded relationship B'); const aJob = await createJob(page, session.owner, a, 'A application remains separate'); const bJob = await createJob(page, session.owner, b, 'B current application', 'Interview'); const aUrl = `${companiesUrl}/${a.id}/relationship`; const bUrl = `${companiesUrl}/${b.id}/relationship`; await enter(page, session.owner, sample); await expect(edit(page, a)).toBeVisible(); const listReads = count(session.requests, 'GET', '/api/companies'); for (const outcome of ['success', 'error'] as const) { const gate = hold(); let starts = 0; await page.route(aUrl, async route => { starts++; await gate.pending; return outcome === 'success' ? route.continue() : route.fulfill({ status: 503, json: {} }); }); const lateResponse = response(page, 'GET', aUrl); try { await keyboardActivate(page, relationshipAction(page, a)); const pending = page.getByRole('dialog', { name: a.name, exact: true }); await expect.poll(() => starts).toBe(1); await expect(pending.getByRole('progressbar', { name: t.loading, exact: true })).toBeVisible(); await expect(pending.getByText(aJob.jobTitle, { exact: true })).toHaveCount(0); await keyboardActivate(page, pending.getByRole('button', { name: t.close, exact: true })); await expect(pending).toHaveCount(0); await expect(relationshipAction(page, a)).toBeFocused(); await keyboardActivate(page, relationshipAction(page, b)); const current = page.getByRole('dialog', { name: b.name, exact: true }); await expect(current.getByText(bJob.jobTitle, { exact: true })).toBeVisible(); gate.release(); expect((await lateResponse).status()).toBe(outcome === 'success' ? 200 : 503); await expect(current).toBeVisible(); await expect(current.getByText(bJob.jobTitle, { exact: true })).toBeVisible(); await expect(current.getByText(aJob.jobTitle, { exact: true })).toHaveCount(0); await expect(current.getByRole('alert')).toHaveCount(0); await keyboardActivate(page, current.getByRole('button', { name: t.close, exact: true })); await expect(current).toHaveCount(0); await expect(relationshipAction(page, b)).toBeFocused(); } finally { gate.release(); await page.unroute(aUrl); } } const retryGate = hold(); let attempts = 0; let forbidden = false; await page.route(bUrl, async route => { attempts++; if (forbidden) return route.fulfill({ status: 403, json: {} }); if (attempts === 1) return route.fulfill({ status: 503, json: {} }); await retryGate.pending; return route.continue(); }); await keyboardActivate(page, relationshipAction(page, b)); const dialog = page.getByRole('dialog', { name: b.name, exact: true }); await expect(dialog.getByRole('alert')).toContainText(t.companiesRelationshipLoadFailed); await expect(dialog.getByText(bJob.jobTitle, { exact: true })).toHaveCount(0); const retryResponse = response(page, 'GET', bUrl); try { await keyboardActivate(page, dialog.getByRole('button', { name: t.retry, exact: true })); await expect.poll(() => attempts).toBe(2); await expect(relationshipRegion(dialog)).toBeFocused(); await recordFocus(page, info, 'relationship-retry-held'); await expect(dialog.getByRole('progressbar', { name: t.loading, exact: true })).toBeVisible(); await expect(dialog.getByRole('status')).toHaveAttribute('aria-live', 'polite'); await expect(dialog.getByRole('status')).toHaveText(t.loading); await page.keyboard.press('Enter'); expect(attempts).toBe(2); await capture(page, info, sample, 'relationship-retry-held'); } finally { retryGate.release(); } expect((await retryResponse).status()).toBe(200); await expect(dialog.getByText(bJob.jobTitle, { exact: true })).toBeVisible(); await expect(relationshipRegion(dialog)).toBeFocused(); await expect(dialog.getByRole('button', { name: t.retry, exact: true })).toHaveCount(0); expect(count(session.requests, 'GET', '/api/companies')).toBe(listReads); expect(count(session.requests, 'GET', `/api/companies/${a.id}/relationship`)).toBe(2); expect(count(session.requests, 'GET', `/api/companies/${b.id}/relationship`)).toBe(4); await capture(page, info, sample, 'relationship-recovered'); await keyboardActivate(page, dialog.getByRole('button', { name: t.close, exact: true })); await expect(dialog).toHaveCount(0); forbidden = true; await keyboardActivate(page, relationshipAction(page, b)); const denied = page.getByRole('dialog', { name: b.name, exact: true }); await expect(denied.getByRole('alert')).toContainText(t.companiesRelationshipLoadFailed); await expect(denied.getByRole('button', { name: t.retry, exact: true })).toHaveCount(0); await expect(denied.getByText(bJob.jobTitle, { exact: true })).toHaveCount(0); await info.attach('companies-relationship-read-scope', { body: JSON.stringify({ aId: a.id, bId: b.id, listReads, attempts, qualification: 'A success and B Retry continue to native owner GET; late A503/B503/B403 are explicit presentation fixtures. Real authorization denials are exercised separately.' }), contentType: 'application/json' }); }); test('[P7-05 Companies] edit deep link preserves query hash state and Back; open drafts survive767/768 transition', async ({ page, session }, info) => { test.setTimeout(90_000); const sample: Sample = { language: 'nb', theme: 'light', width: 767, height: 900 }; const t = translations.nb; const company = await createCompany(page, session.owner, 'Recorded deep-link company'); const job = await createJob(page, session.owner, company, 'Recorded relationship navigation'); await enter(page, session.owner, sample); await expect(edit(page, company, 'nb')).toBeVisible(); await page.goto('/jobs?previous=companies-context#prior'); await expect(page.getByRole('heading', { level: 1 })).toBeVisible(); await page.addInitScript(() => { if (location.pathname === '/companies' && new URLSearchParams(location.search).has('edit')) { window.history.replaceState({ ...window.history.state, usr: { source: 'relationship-history', preservedMarker: 'p7-companies' } }, ''); } }); await page.goto(`/companies?edit=${company.id}&source=relationships&filter=active#preserved-context`); const dialog = editDialog(page, 'nb'); await expect(dialog).toBeVisible(); await expect(page).toHaveURL(/\/companies\?source=relationships&filter=active#preserved-context$/); expect(await page.evaluate(() => window.history.state.usr)).toEqual({ source: 'relationship-history', preservedMarker: 'p7-companies' }); const reads = count(session.requests, 'GET', '/api/companies'); const name = dialog.getByLabel(t.companiesName, { exact: true }); const recruiter = dialog.getByLabel(t.companiesRecruiterName, { exact: true }); await name.fill('Unsaved responsive company draft Æ Ø Å'); await recruiter.fill('Unsaved responsive recruiter'); await recruiter.focus(); await expect(recruiter).toBeFocused(); expect(await dialog.evaluate(element => element.classList.contains('MuiDialog-paperFullScreen'))).toBe(true); await page.setViewportSize({ width: 768, height: 900 }); await expect.poll(() => dialog.evaluate(element => element.classList.contains('MuiDialog-paperFullScreen'))).toBe(false); await expect(name).toHaveValue('Unsaved responsive company draft Æ Ø Å'); await expect(recruiter).toHaveValue('Unsaved responsive recruiter'); await expect(recruiter).toBeFocused(); expect(count(session.requests, 'GET', '/api/companies')).toBe(reads); await capture(page, info, { ...sample, width: 768 }, 'open-draft-breakpoint-transition'); await keyboardActivate(page, dialog.getByRole('button', { name: t.cancel, exact: true })); await expect(dialog).toHaveCount(0); await recordFocus(page, info, 'responsive-cancel-invoker'); await expect(edit(page, company, 'nb')).toBeFocused(); expect(count(session.requests, 'PUT', `/api/companies/${company.id}`)).toBe(0); expect(await nativeCompany(page, company)).toEqual(company); // This second opening has an actual focused desktop invoker. Its row is // replaced by the mobile article while the same edit draft stays mounted. await keyboardActivate(page, edit(page, company, 'nb')); await expect(dialog).toBeVisible(); await name.fill('Manually opened responsive draft Æ Ø Å'); await recruiter.fill('Manually opened responsive recruiter'); await recruiter.focus(); await expect(recruiter).toBeFocused(); expect(await dialog.evaluate(element => element.classList.contains('MuiDialog-paperFullScreen'))).toBe(false); await page.setViewportSize({ width: 767, height: 900 }); await expect.poll(() => dialog.evaluate(element => element.classList.contains('MuiDialog-paperFullScreen'))).toBe(true); await expect(name).toHaveValue('Manually opened responsive draft Æ Ø Å'); await expect(recruiter).toHaveValue('Manually opened responsive recruiter'); await expect(recruiter).toBeFocused(); expect(count(session.requests, 'GET', '/api/companies')).toBe(reads); await capture(page, info, sample, 'manual-open-draft-breakpoint-transition'); await keyboardActivate(page, dialog.getByRole('button', { name: t.cancel, exact: true })); await expect(dialog).toHaveCount(0); await recordFocus(page, info, 'manual-responsive-cancel-invoker'); await expect(edit(page, company, 'nb')).toBeFocused(); expect(count(session.requests, 'PUT', `/api/companies/${company.id}`)).toBe(0); expect(await nativeCompany(page, company)).toEqual(company); await keyboardActivate(page, relationshipAction(page, company, 'nb')); const historyDialog = page.getByRole('dialog', { name: company.name, exact: true }); await expect(historyDialog.getByText(job.jobTitle, { exact: true })).toBeVisible(); await keyboardActivate(page, historyDialog.getByRole('button', { name: t.notificationsOpen, exact: true })); await expect(page).toHaveURL(new RegExp(`/jobs/${job.id}$`)); await page.goBack(); await expect(edit(page, company, 'nb')).toBeVisible(); await expect(editDialog(page, 'nb')).toHaveCount(0); await expect(page).toHaveURL(/\/companies\?source=relationships&filter=active#preserved-context$/); expect(await page.evaluate(() => window.history.state.usr)).toEqual({ source: 'relationship-history', preservedMarker: 'p7-companies' }); await page.goBack(); await expect(page).toHaveURL(/\/jobs\?previous=companies-context#prior$/); await info.attach('companies-route-context', { body: JSON.stringify({ source: 'BrowserRouter history.state.usr set before the deep-link mount to represent an existing router entry.', companyId: company.id, jobId: job.id, qualification: 'Real browser edit-parameter replacement, retained query/hash/state and relationship-to-job Back journey; no new navigation is introduced.' }), contentType: 'application/json' }); }); test('[P7-05 Companies] live locale changes translate failures and relationship status without refetching or losing drafts', async ({ page, context, session }, info) => { test.setTimeout(90_000); const en = translations.en; const nb = translations.nb; const company = await createCompany(page, session.owner, 'Recorded language-independent company'); const job = await createJob(page, session.owner, company, 'Recorded bilingual relationship', 'Interview'); await enter(page, session.owner); await expect(edit(page, company)).toBeVisible(); const reads = count(session.requests, 'GET', '/api/companies'); const preference = response(page, 'PUT', `${apiUrl}/auth/preferences/language`); await keyboardActivate(page, page.locator('header').getByRole('button', { name: 'Norsk', exact: true })); expect((await preference).status()).toBe(204); await expect(page.locator('html')).toHaveAttribute('lang', 'nb-NO'); await expect(edit(page, company, 'nb')).toBeVisible(); expect(count(session.requests, 'GET', '/api/companies')).toBe(reads); await keyboardActivate(page, relationshipAction(page, company, 'nb')); const relationship = page.getByRole('dialog', { name: company.name, exact: true }); await expect(relationship.getByText(job.jobTitle, { exact: true })).toBeVisible(); await expect(relationship.getByText(new RegExp(`^${nb.statusInterview} · `))).toBeVisible(); await keyboardActivate(page, relationship.getByRole('button', { name: nb.close, exact: true })); await expect(relationship).toHaveCount(0); await page.route(`${companiesUrl}/${company.id}`, route => route.request().method() === 'PUT' ? route.fulfill({ status: 503, json: {} }) : route.continue()); await keyboardActivate(page, edit(page, company, 'nb')); const dialog = editDialog(page, 'nb'); const draft = draftFor(company, 'Retained locale draft Æ Ø Å'); await fillDraft(dialog, draft, 'nb'); await keyboardActivate(page, dialog.getByRole('button', { name: nb.save, exact: true })); await expect(dialog.getByRole('alert')).toHaveText(nb.companiesUpdateFailed); const before = [...session.requests]; const otherTab = await context.newPage(); try { await otherTab.goto('/companies'); await expect(otherTab.getByRole('heading', { level: 1, name: nb.companies, exact: true })).toBeVisible(); await otherTab.evaluate(() => localStorage.setItem('uiLanguage', 'en')); await expect(page.locator('html')).toHaveAttribute('lang', 'en'); const translated = editDialog(page); await expect(translated.getByRole('alert')).toHaveText(en.companiesUpdateFailed); await expectDraft(translated, draft); await expect(translated.getByRole('button', { name: en.save, exact: true })).toBeEnabled(); expect(session.requests).toEqual(before); expect(count(session.requests, 'GET', '/api/companies')).toBe(reads); await capture(page, info, desktop, 'live-cross-tab-locale-retained-failure'); await keyboardActivate(page, translated.getByRole('button', { name: en.cancel, exact: true })); await expect(translated).toHaveCount(0); await expect(edit(page, company)).toBeFocused(); expect(await nativeCompany(page, company)).toEqual(company); } finally { await otherTab.close(); } await info.attach('companies-locale-boundary', { body: JSON.stringify({ initialHeaderChange: 'Actual named Norsk header control and native language-preference204.', modalChange: 'Actual same-origin second browser tab changes localStorage, producing a storage event while the primary modal stays mounted.', recordedCompaniesReads: reads, qualification: 'The primary page request log proves no Companies refetch or duplicate PUT during its locale transition. The second tab has its own independent initial read; no hidden modal-background control was forced.' }), contentType: 'application/json' }); }); test('[P7-05 Companies] eight finite language/theme widths reflow with named table scrolling and keyboard dialogs', async ({ page, session }, info) => { test.setTimeout(270_000); const company = await createCompany(page, session.owner, 'Samfunnskritiske systemer og rekruttering for nordnorske virksomheter Æ Ø Å — langsiktig samarbeid', 'Tromsø og internasjonalt samarbeid med flere steder i Nord-Norge'); const draft: Draft = { name: company.name, location: company.location!, source: 'LinkedIn', recruiterName: 'Rekrutterer med et langt meningsfullt navn Æ Ø Å', recruiterEmail: 'lang-rekruttererkontakt-for-virksomheten@example.test', recruiterLinkedIn: 'https://example.test/recruiter/long-recorded-contact-context', pipelineStage: 'Oppfølging av langsiktig relasjon', lastContactedAt: '2026-11-01', nextContactAt: '2026-12-01' }; const saved = await json(await page.request.put(`${companiesUrl}/${company.id}`, { headers: session.owner.headers, data: snapshot(draft) })); const job = await createJob(page, session.owner, saved, 'Senior utvikler for samfunnskritiske systemer med ansvar for kvalitet Æ Ø Å', 'Interview'); await createContact(page, session.owner, job, 1, 'Oppfølging av en langsiktig og meningsfull søknadsprosess med et langt emne Æ Ø Å'); const samples: Sample[] = [ { language: 'en', theme: 'light', width: 320, height: 740 }, { language: 'nb', theme: 'dark', width: 360, height: 844 }, { language: 'en', theme: 'dark', width: 390, height: 844 }, { language: 'nb', theme: 'light', width: 767, height: 900 }, { language: 'en', theme: 'dark', width: 768, height: 1024 }, { language: 'nb', theme: 'light', width: 1024, height: 320 }, { language: 'en', theme: 'light', width: 1440, height: 900 }, { language: 'nb', theme: 'dark', width: 1920, height: 1080 }, ]; for (const sample of samples) { const t = translations[sample.language]; await enter(page, session.owner, sample); await expect(edit(page, saved, sample.language)).toBeVisible(); if (sample.width < 768) { const article = page.getByRole('article', { name: saved.name, exact: true }); await expect(article).toBeVisible(); await expect(article.getByRole('heading', { level: 2, name: saved.name, exact: true })).toBeVisible(); await expect(page.getByRole('table', { name: t.companies, exact: true })).toHaveCount(0); } else { const table = page.getByRole('table', { name: t.companies, exact: true }); const region = page.getByRole('region', { name: t.companies, exact: true }); await expect(table).toBeVisible(); await expect(region).toHaveAttribute('tabindex', '0'); const geometry = await region.evaluate(element => ({ width: element.clientWidth, scrollWidth: element.scrollWidth, left: element.getBoundingClientRect().left, right: element.getBoundingClientRect().right, viewport: innerWidth })); expect(geometry.left).toBeGreaterThanOrEqual(0); expect(geometry.right).toBeLessThanOrEqual(geometry.viewport); await region.focus(); await expect(region).toBeFocused(); if (geometry.scrollWidth > geometry.width + 1) { await page.keyboard.press('ArrowRight'); await expect.poll(() => region.evaluate(element => element.scrollLeft)).toBeGreaterThan(0); await region.evaluate(element => { element.scrollLeft = 0; }); } } await capture(page, info, sample, 'loaded-finite-sample'); if (sample.width === 320) { await keyboardActivate(page, edit(page, saved, sample.language)); const dialog = editDialog(page, sample.language); const save = dialog.getByRole('button', { name: t.save, exact: true }); const cancel = dialog.getByRole('button', { name: t.cancel, exact: true }); await save.focus(); await page.keyboard.press('Tab'); await expect(dialog.getByLabel(t.companiesName, { exact: true })).toBeFocused(); await page.keyboard.press('Shift+Tab'); await expect(save).toBeFocused(); await capture(page, info, sample, 'mobile-edit-keyboard-trap'); await keyboardActivate(page, cancel); await expect(dialog).toHaveCount(0); await expect(edit(page, saved, sample.language)).toBeFocused(); } if (sample.width === 1024) { await keyboardActivate(page, relationshipAction(page, saved, sample.language)); const dialog = page.getByRole('dialog', { name: saved.name, exact: true }); await expect(dialog.getByText(job.jobTitle, { exact: true })).toBeVisible(); const region = relationshipRegion(dialog, sample.language); const scroll = await region.evaluate(element => ({ clientHeight: element.clientHeight, scrollHeight: element.scrollHeight })); expect(scroll.scrollHeight).toBeGreaterThan(scroll.clientHeight); await region.focus(); await page.keyboard.press('PageDown'); await expect.poll(() => region.evaluate(element => element.scrollTop)).toBeGreaterThan(0); const close = dialog.getByRole('button', { name: t.close, exact: true }); await close.focus(); await page.keyboard.press('Tab'); await expect(dialog.getByRole('button', { name: t.notificationsOpen, exact: true })).toBeFocused(); await page.keyboard.press('Shift+Tab'); await expect(close).toBeFocused(); await capture(page, info, sample, 'landscape-relationship-scroll-trap'); await keyboardActivate(page, close); await expect(dialog).toHaveCount(0); await recordFocus(page, info, 'landscape-relationship-close'); await expect(relationshipAction(page, saved, sample.language)).toBeFocused(); } expect(await nativeCompany(page, saved)).toEqual(saved); } expect(count(session.requests, 'PUT', `/api/companies/${saved.id}`)).toBe(0); await info.attach('companies-finite-samples', { body: JSON.stringify({ samples, boundary, qualification: 'Exactly eight balanced English/Bokmål and light/dark samples including767/768 and1024×320 landscape, plus two keyboard modal states. Named table geometry/keyboard horizontal scrolling is measured when it overflows. No Cartesian, real zoom, human AT or physical-device claim.' }), contentType: 'application/json' }); });