Files
jobtrackingapp/job-tracker-ui/e2e/phase7-companies.spec.ts
T

552 lines
47 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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' });
});