feat(ui): migrate company relationships and save recovery
This commit is contained in:
@@ -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' });
|
||||
});
|
||||
Reference in New Issue
Block a user