feat(ui): migrate company relationships and save recovery

This commit is contained in:
cesnimda
2026-10-13 08:59:02 +02:00
parent 34a4d6dc6b
commit a8aba85299
14 changed files with 5717 additions and 62 deletions
+551
View File
@@ -0,0 +1,551 @@
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<string, string> };
type Sample = { language: 'en' | 'nb'; theme: 'light' | 'dark'; width: number; height: number };
type RequestRecord = { method: string; path: string; query: Record<string, string>; 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<Contact & { jobTitle: string }>;
};
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<T>(result: APIResponse | Response, status = 200): Promise<T> {
expect(result.status(), `${result.url()} returns ${status}`).toBe(status);
return result.json();
}
async function login(api: APIRequestContext, email: string): Promise<Owner> {
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<Company>(await page.request.post(companiesUrl, { headers: owner.headers, data: { name, location, source } }), 201);
}
async function nativeCompany(page: Page, company: Company) { return json<Company>(await page.request.get(`${companiesUrl}/${company.id}`)); }
async function nativeList(api: APIRequestContext) { return json<Company[]>(await api.get(companiesUrl)); }
async function createJob(page: Page, owner: Owner, company: Company, title: string, status = 'Saved') {
return json<Job>(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<Contact>(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<keyof Draft>) 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<keyof Draft>) 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<void>(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<void>(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<HTMLElement>('[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<Company>(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<Relationship>(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<Company[]>(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<Company>(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<Relationship>(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<Company>(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<Company>(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' });
});