Files
jobtrackingapp/job-tracker-ui/e2e/phase7-discovery.spec.ts
2026-10-13 00:49:24 +02:00

543 lines
41 KiB
TypeScript
Raw Permalink 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';
const apiUrl = 'http://localhost:5302/api';
const savedUrl = `${apiUrl}/job-discovery/saved-searches`;
const searchUrl = `${apiUrl}/job-discovery/search`;
const password = 'E2ePassword123!';
type Owner = { id: string; headers: Record<string, string> };
type SavedSearch = { id: number; name: string; query: string; location: string; isActive: boolean; resultCount: number; dismissedCount: number; lastRunAtUtc: string | null };
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[] };
const desktop: Sample = { language: 'en', theme: 'light', width: 1440, height: 900 };
const providerBoundary = 'Saved-search list/create/alert PATCH/delete and readbacks use the real isolated owner API and disposable SQLite. Browser vacancy-provider /search and owner /saved-searches/{id}/run responses are intercepted because the server provider has an external NAV base URL. Intercepted runs do not create SavedJobSearchResults: successful intercepted Dismiss establishes presentation only, never runner/history/dismiss persistence or alert delivery. A real missing-result Dismiss 404 is exercised separately. The unchanged saved-search-repair suite and backend fake-provider SQLite tests retain owner/anonymous and real runner/history contracts. External browser traffic and unregistered provider-dependent requests are blocked.';
async function json<T>(response: APIResponse | Response, status = 200): Promise<T> {
expect(response.status(), `${response.url()} returns ${status}`).toBe(status);
return response.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 } };
}
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 blockedProviderRequests: string[] = [];
const blockedExternalRequests: string[] = [];
page.on('request', request => {
const url = new URL(request.url());
if (!url.pathname.startsWith('/api/job-discovery/')) 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 email = `p7-discovery-${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);
const owner = await login(page.request, email);
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();
}
if (url.pathname === '/api/job-discovery/search' || /^\/api\/job-discovery\/saved-searches\/\d+\/run$/.test(url.pathname)) {
blockedProviderRequests.push(`${route.request().method()} ${url.pathname}`);
return route.fulfill({ status: 502, json: { message: 'Unregistered provider request blocked by isolated test guard' } });
}
return route.continue();
});
await provide({ owner, requests });
expect(blockedProviderRequests, 'Every provider-dependent request must have an explicit fixture; none reaches the real provider').toEqual([]);
} finally {
await info.attach('discovery-request-boundary', { body: JSON.stringify({ providerBoundary, requests, blockedProviderRequests, blockedExternalRequests,
qualification: 'Browser request log contains only method/path/query/JSON payload, with no cookies or authorization/CSRF/session headers. Direct API fixture reads are verified in individual tests; this log is not a server outbound-traffic measurement.' }), contentType: 'application/json' });
await admin.dispose();
}
},
});
async function nativeList(api: APIRequestContext) {
return json<SavedSearch[]>(await api.get(savedUrl));
}
async function createSaved(page: Page, owner: Owner, name: string, query = 'backend', location = 'Oslo') {
return json<SavedSearch>(await page.request.post(savedUrl, { headers: owner.headers, data: { name, query, location } }), 201);
}
function vacancy(id: string, title = `Provider fixture ${id}`) {
return { id, title, company: 'Eksempelbedriften for samfunnskritiske systemer Æ Ø Å', location: 'TRONDHEIM',
modifiedAt: '2026-10-09T10:00:00Z', deadline: '2026-10-20T23:59:59Z',
url: `https://arbeidsplassen.nav.no/stillinger/stilling/${encodeURIComponent(id)}`, source: 'nav', sourceName: 'NAV Arbeidsplassen',
acquisitionType: 'searched', retrievedAt: '2026-10-10T10:00:00Z', countryCode: 'NO' };
}
type Vacancy = ReturnType<typeof vacancy>;
async function runPayload(page: Page, saved: SavedSearch, jobs: Vacancy[]) {
const current = (await nativeList(page.request)).find(row => row.id === saved.id);
expect(current).toBeDefined();
return { search: current, jobs: jobs.map(job => ({ job, isNew: true, isDismissed: false })) };
}
function savedGroup(page: Page, saved: SavedSearch) { return page.getByRole('group', { name: saved.name, exact: true }); }
function article(page: Page, job: Vacancy) { return page.getByRole('article', { name: job.title, exact: true }); }
function results(page: Page, language: Sample['language'] = 'en') { return page.getByRole('region', { name: translations[language].jobDiscoveryResults, exact: true }); }
function count(requests: RequestRecord[], method: string, path: string) { return requests.filter(request => request.method === method && request.path === path).length; }
function response(page: Page, method: string, url: string) { return page.waitForResponse(result => result.url() === url && result.request().method() === method); }
function hold() { let release!: () => void; const pending = new Promise<void>(resolve => { release = resolve; }); return { pending, release }; }
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) {
const 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) };
});
await info.attach(`discovery-focus-${state}`, { body: JSON.stringify({ state, focused,
qualification: 'Actual document.activeElement in isolated Chromium, not a human screen-reader or physical-device acceptance claim.' }), contentType: 'application/json' });
}
async function enter(page: Page, owner: Owner, sample: Sample = desktop) {
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers: owner.headers,
data: { language: sample.language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204);
await page.addInitScript(({ language, theme }) => {
if (localStorage.getItem('uiLanguage') === null) localStorage.setItem('uiLanguage', language);
if (localStorage.getItem('jobtracker.themeMode') === null) localStorage.setItem('jobtracker.themeMode', theme);
}, sample);
await page.setViewportSize({ width: sample.width, height: sample.height });
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto('/discover?source=phase7-discovery#preserved');
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].discoverJobs, exact: true })).toBeVisible();
await expect(page.getByRole('status', { name: translations[sample.language].jobDiscoverySavedLoading, exact: true })).toHaveCount(0);
}
async function locked(page: Page, language: Sample['language'] = 'en') {
const t = translations[language];
for (const label of [t.jobDiscoveryRoleCompany, t.jobDiscoveryMunicipality, t.jobDiscoverySavedName]) await expect(page.getByLabel(label, { exact: true })).toBeDisabled();
for (const name of [t.jobDiscoverySearchNav, t.jobDiscoverySaveSearch, t.jobDiscoveryRunSaved, t.adminUsersDelete, t.jobDiscoveryDismiss]) {
for (const button of await page.getByRole('button', { name, exact: true }).all()) await expect(button).toBeDisabled();
}
for (const control of await page.getByRole('switch', { name: t.jobDiscoveryAutomaticAlerts, exact: true }).all()) await expect(control).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),
overflowElements: [...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, text: (element.textContent ?? '').slice(0, 100), left: rect.left, right: rect.right, width: rect.width }; })
.filter(element => element.width > 0 && (element.left < -1 || element.right > innerWidth + 1)).slice(0, 20),
}));
const axe = await new AxeBuilder({ page }).options({ rules: { 'target-size': { enabled: true } } }).analyze();
const name = `p7-discovery-${sample.language}-${sample.theme}-${sample.width}-${state}`;
await info.attach(`${name}-audit`, { body: JSON.stringify({ sample, state, geometry, ariaSnapshot: await page.locator('body').ariaSnapshot(),
axe: { scope: 'All enabled rules plus target-size; complete violations and incomplete findings retained', violations: axe.violations, incomplete: axe.incomplete,
passes: axe.passes.map(rule => ({ id: rule.id, nodes: rule.nodes.length })) }, providerBoundary,
qualifications: ['Finite browser states, not the Cartesian product of every width/language/theme/state.',
'Automated local Chromium and reduced-motion media only; axe incomplete findings require triage and do not establish human screen-reader acceptance.',
'Physical touch/software keyboard, actual browser zoom, deployed fonts/photos, real provider/alert delivery 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} must reflow without app chrome overflow`).toBe(0);
expect(axe.violations, `${name} complete axe violations`).toEqual([]);
}
test('[P7-05 Discovery] saved A to manual search clears saved-result context and preserves native saved rows', async ({ page, session }, info) => {
const t = translations.en;
const saved = await createSaved(page, session.owner, 'Recorded saved search A');
const savedJob = vacancy('saved-a'); const manualJob = vacancy('manual-only');
await page.route(`${savedUrl}/${saved.id}/run`, async route => route.fulfill({ json: await runPayload(page, saved, [savedJob]) }));
await page.route(`${searchUrl}**`, route => route.fulfill({ json: [manualJob] }));
await enter(page, session.owner);
await keyboardActivate(page, savedGroup(page, saved).getByRole('button', { name: t.jobDiscoveryRunSaved, exact: true }));
await expect(article(page, savedJob).getByRole('button', { name: t.jobDiscoveryDismiss, exact: true })).toBeVisible();
await page.getByLabel(t.jobDiscoveryRoleCompany, { exact: true }).fill('manual analyst');
await page.getByLabel(t.jobDiscoveryMunicipality, { exact: true }).fill('Bergen');
await keyboardActivate(page, page.getByRole('button', { name: t.jobDiscoverySearchNav, exact: true }));
await expect(article(page, manualJob)).toBeVisible();
await expect(article(page, savedJob)).toHaveCount(0);
await expect(results(page).getByRole('button', { name: t.jobDiscoveryDismiss, exact: true })).toHaveCount(0);
expect(session.requests.filter(request => request.path === '/api/job-discovery/search')).toEqual([
{ method: 'GET', path: '/api/job-discovery/search', query: { q: 'manual analyst', location: 'Bergen' }, body: null },
]);
expect(session.requests.filter(request => request.path.endsWith('/dismiss'))).toEqual([]);
expect(await nativeList(page.request)).toEqual([saved]);
await expect(page).toHaveURL(/\/discover\?source=phase7-discovery#preserved$/);
await capture(page, info, desktop, 'saved-to-manual');
});
test('[P7-05 Discovery] held saved B removes A rows, locks all mutations and starts one request', async ({ page, session }, info) => {
test.setTimeout(90_000);
const t = translations.en;
const a = await createSaved(page, session.owner, 'Recorded saved search A', 'A role', 'Oslo');
const b = await createSaved(page, session.owner, 'Recorded saved search B', 'B role', 'Bergen');
const aJob = vacancy('held-old-a'); const bJob = vacancy('held-current-b'); const gate = hold();
let starts = 0;
await page.route(`${savedUrl}/${a.id}/run`, async route => route.fulfill({ json: await runPayload(page, a, [aJob]) }));
await page.route(`${savedUrl}/${b.id}/run`, async route => { starts++; await gate.pending; await route.fulfill({ json: await runPayload(page, b, [bJob]) }); });
await enter(page, session.owner, { ...desktop, width: 360, height: 844 });
await keyboardActivate(page, savedGroup(page, a).getByRole('button', { name: t.jobDiscoveryRunSaved, exact: true }));
await expect(article(page, aJob)).toBeVisible();
try {
await keyboardActivate(page, savedGroup(page, b).getByRole('button', { name: t.jobDiscoveryRunSaved, exact: true }));
await expect.poll(() => starts).toBe(1);
await expect(article(page, aJob)).toHaveCount(0);
await expect(article(page, bJob)).toHaveCount(0);
await locked(page);
await expect(results(page).getByRole('progressbar', { name: t.loading, exact: true })).toBeVisible();
await expect(results(page).getByRole('status')).toHaveAttribute('aria-live', 'polite');
await expect(results(page).getByRole('status')).toHaveText(t.loading);
await page.keyboard.press('Enter');
expect(starts).toBe(1);
await capture(page, info, { ...desktop, width: 360, height: 844 }, 'held-saved-b');
} finally { gate.release(); }
await expect(article(page, bJob)).toBeVisible();
await expect(article(page, aJob)).toHaveCount(0);
await expect(page.getByLabel(t.jobDiscoveryRoleCompany, { exact: true })).toHaveValue(b.query);
await expect(page.getByLabel(t.jobDiscoveryMunicipality, { exact: true })).toHaveValue(b.location);
await expect(article(page, bJob).getByRole('button', { name: t.jobDiscoveryDismiss, exact: true })).toBeEnabled();
expect(starts).toBe(1);
expect(count(session.requests, 'GET', '/api/job-discovery/search')).toBe(0);
expect(await nativeList(page.request)).toEqual([b, a]);
});
test('[P7-05 Discovery] failed saved-run Retry posts the same ID with stable keyboard focus and held progress', async ({ page, session }, info) => {
test.setTimeout(90_000);
const t = translations.en; const sample = { ...desktop, theme: 'dark' as const, width: 390, height: 844 };
const saved = await createSaved(page, session.owner, 'Recorded retry saved B', 'saved backend', 'Bergen');
const job = vacancy('retry-saved-b'); const gate = hold(); let runs = 0;
await page.route(`${savedUrl}/${saved.id}/run`, async route => {
runs++;
if (runs === 1) return route.fulfill({ status: 500, json: { detail: 'Controlled provider read failure; do not expose internal detail' } });
await gate.pending; return route.fulfill({ json: await runPayload(page, saved, [job]) });
});
await enter(page, session.owner, sample);
await keyboardActivate(page, savedGroup(page, saved).getByRole('button', { name: t.jobDiscoveryRunSaved, exact: true }));
await expect(results(page).getByText(t.jobDiscoveryUnavailable, { exact: true })).toBeVisible();
await expect(page.getByText(/Controlled provider read failure/)).toHaveCount(0);
await page.getByLabel(t.jobDiscoveryRoleCompany, { exact: true }).fill('unsubmitted different query');
await page.getByLabel(t.jobDiscoveryMunicipality, { exact: true }).fill('unsubmitted Ålesund draft');
try {
await keyboardActivate(page, results(page).getByRole('button', { name: t.retry, exact: true }));
await expect.poll(() => runs).toBe(2);
await expect(results(page)).toBeFocused();
await locked(page);
await expect(results(page).getByRole('progressbar', { name: t.loading, exact: true })).toBeVisible();
await expect(results(page).getByRole('status')).toHaveText(t.loading);
await expect(results(page).getByRole('status')).toHaveAttribute('aria-live', 'polite');
await page.keyboard.press('Enter'); expect(runs).toBe(2);
await capture(page, info, sample, 'saved-retry-held');
} finally { gate.release(); }
await expect(article(page, job)).toBeVisible();
await expect(results(page)).toBeFocused();
await expect(results(page).getByRole('button', { name: t.retry, exact: true })).toHaveCount(0);
await expect(page.getByLabel(t.jobDiscoveryRoleCompany, { exact: true })).toHaveValue('unsubmitted different query');
await expect(page.getByLabel(t.jobDiscoveryMunicipality, { exact: true })).toHaveValue('unsubmitted Ålesund draft');
expect(runs).toBe(2);
expect(count(session.requests, 'POST', `/api/job-discovery/saved-searches/${saved.id}/run`)).toBe(2);
expect(count(session.requests, 'GET', '/api/job-discovery/search')).toBe(0);
expect(await nativeList(page.request)).toEqual([saved]);
await capture(page, info, sample, 'saved-retry-recovered');
});
test('[P7-05 Discovery] mutation failures retain drafts and current rows without offering an unrelated manual-search retry', async ({ page, session }, info) => {
test.setTimeout(120_000);
const t = translations.en;
const saved = await createSaved(page, session.owner, 'Recorded mutation recovery');
const job = vacancy('mutation-current');
await page.route(`${savedUrl}/${saved.id}/run`, async route => route.fulfill({ json: await runPayload(page, saved, [job]) }));
let createFails = true; let alertFails = true; let deleteFails = true;
await page.route(savedUrl, route => route.request().method() === 'POST' && createFails
? route.fulfill({ status: 500, json: { message: 'Controlled create failure' } }) : route.continue());
await page.route(`${savedUrl}/${saved.id}`, route => (route.request().method() === 'PATCH' && alertFails) || (route.request().method() === 'DELETE' && deleteFails)
? route.fulfill({ status: 500, json: { message: 'Controlled mutation failure' } }) : route.continue());
await enter(page, session.owner);
await keyboardActivate(page, savedGroup(page, saved).getByRole('button', { name: t.jobDiscoveryRunSaved, exact: true }));
await expect(article(page, job)).toBeVisible();
await page.getByLabel(t.jobDiscoveryRoleCompany, { exact: true }).fill('retained draft role');
await page.getByLabel(t.jobDiscoveryMunicipality, { exact: true }).fill('Ålesund');
await page.getByLabel(t.jobDiscoverySavedName, { exact: true }).fill('Retained failed create draft');
await keyboardActivate(page, page.getByRole('button', { name: t.jobDiscoverySaveSearch, exact: true }));
await expect(page.getByText(t.jobDiscoverySaveSearchFailed, { exact: true })).toBeVisible();
await expect(page.getByLabel(t.jobDiscoverySavedName, { exact: true })).toHaveValue('Retained failed create draft');
await expect(page.getByLabel(t.jobDiscoveryRoleCompany, { exact: true })).toHaveValue('retained draft role');
await expect(page.getByRole('button', { name: t.retry, exact: true })).toHaveCount(0);
expect(await nativeList(page.request)).toEqual([saved]);
createFails = false;
const createdResponse = response(page, 'POST', savedUrl);
await keyboardActivate(page, page.getByRole('button', { name: t.jobDiscoverySaveSearch, exact: true }));
const created = await json<SavedSearch>(await createdResponse, 201);
expect(created).toMatchObject({ name: 'Retained failed create draft', query: 'retained draft role', location: 'Ålesund' });
await keyboardActivate(page, savedGroup(page, saved).getByRole('switch', { name: t.jobDiscoveryAutomaticAlerts, exact: true }), 'Space');
await expect(page.getByText(t.jobDiscoveryUpdateAlertsFailed, { exact: true })).toBeVisible();
await expect(savedGroup(page, saved).getByRole('switch', { name: t.jobDiscoveryAutomaticAlerts, exact: true })).toBeChecked();
await expect(article(page, job)).toBeVisible();
await expect(page.getByRole('button', { name: t.retry, exact: true })).toHaveCount(0);
alertFails = false;
const updatedResponse = response(page, 'PATCH', `${savedUrl}/${saved.id}`);
await keyboardActivate(page, savedGroup(page, saved).getByRole('switch', { name: t.jobDiscoveryAutomaticAlerts, exact: true }), 'Space');
const updated = await json<SavedSearch>(await updatedResponse);
expect(updated).toEqual({ ...saved, isActive: false });
await keyboardActivate(page, savedGroup(page, saved).getByRole('button', { name: t.adminUsersDelete, exact: true }));
await keyboardActivate(page, page.getByRole('dialog').getByRole('button', { name: t.adminUsersDelete, exact: true }));
await expect(page.getByText(t.jobDiscoveryDeleteSavedFailed, { exact: true })).toBeVisible();
await expect(savedGroup(page, saved)).toBeVisible();
await expect(article(page, job)).toBeVisible();
await expect(page.getByRole('button', { name: t.retry, exact: true })).toHaveCount(0);
// Native listing sorts by UpdatedAt, and pausing the older row updates that timestamp.
// Compare every DTO value without inventing creation-order semantics for this readback.
expect((await nativeList(page.request)).sort((left, right) => left.id - right.id)).toEqual([created, updated].sort((left, right) => left.id - right.id));
expect(count(session.requests, 'GET', '/api/job-discovery/search')).toBe(0);
await capture(page, info, desktop, 'mutation-failure-retains-context');
deleteFails = false;
const deletedResponse = response(page, 'DELETE', `${savedUrl}/${saved.id}`);
await keyboardActivate(page, savedGroup(page, saved).getByRole('button', { name: t.adminUsersDelete, exact: true }));
await keyboardActivate(page, page.getByRole('dialog').getByRole('button', { name: t.adminUsersDelete, exact: true }));
expect((await deletedResponse).status()).toBe(204);
await expect(savedGroup(page, saved)).toHaveCount(0);
expect(await nativeList(page.request)).toEqual([created]);
expect(count(session.requests, 'GET', '/api/job-discovery/search')).toBe(0);
});
test('[P7-05 Discovery] keyboard native create and alert updates persist; delete Cancel restores focus and held delete starts once', async ({ page, session }, info) => {
test.setTimeout(120_000);
const t = translations.nb; const sample: Sample = { language: 'nb', theme: 'light', width: 360, height: 844 };
const currentJob = vacancy('native-delete-current-manual');
await page.route(`${searchUrl}**`, route => route.fulfill({ json: [currentJob] }));
const createGate = hold(); const deleteGate = hold(); let creates = 0; let deletes = 0;
await page.route(savedUrl, async route => {
if (route.request().method() === 'POST') { creates++; await createGate.pending; }
return route.continue();
});
await enter(page, session.owner, sample);
await expect(page.getByText(t.jobDiscoveryNoSavedSearches, { exact: true })).toBeVisible();
await page.getByLabel(t.jobDiscoveryRoleCompany, { exact: true }).fill(' utvikler ');
await page.getByLabel(t.jobDiscoveryMunicipality, { exact: true }).fill(' Tromsø ');
await page.getByLabel(t.jobDiscoverySavedName, { exact: true }).fill(' Gjennomgått utviklersøk Æ Ø Å ');
const createdResponse = response(page, 'POST', savedUrl);
try {
await keyboardActivate(page, page.getByRole('button', { name: t.jobDiscoverySaveSearch, exact: true }));
await expect.poll(() => creates).toBe(1);
await locked(page, 'nb'); await page.keyboard.press('Enter'); expect(creates).toBe(1);
await capture(page, info, sample, 'native-create-held');
} finally { createGate.release(); }
const saved = await json<SavedSearch>(await createdResponse, 201);
expect(saved).toMatchObject({ name: 'Gjennomgått utviklersøk Æ Ø Å', query: 'utvikler', location: 'Tromsø', isActive: true, resultCount: 0, dismissedCount: 0 });
await expect(savedGroup(page, saved)).toBeVisible();
expect(await nativeList(page.request)).toEqual([saved]);
await page.reload(); await expect(savedGroup(page, saved)).toBeVisible();
for (const isActive of [false, true]) {
const updatedResponse = response(page, 'PATCH', `${savedUrl}/${saved.id}`);
await keyboardActivate(page, savedGroup(page, saved).getByRole('switch', { name: t.jobDiscoveryAutomaticAlerts, exact: true }), 'Space');
expect(await json<SavedSearch>(await updatedResponse)).toEqual({ ...saved, isActive });
expect(await nativeList(page.request)).toEqual([{ ...saved, isActive }]);
await page.reload();
await expect(savedGroup(page, saved).getByRole('switch', { name: t.jobDiscoveryAutomaticAlerts, exact: true })).toBeChecked({ checked: isActive });
}
// Keep a legitimate results control after deleting the saved row so the next Tab
// has a concrete destination inside the stable region rather than browser chrome.
await keyboardActivate(page, page.getByRole('button', { name: t.jobDiscoverySearchNav, exact: true }));
await expect(article(page, currentJob)).toBeVisible();
const deleteButton = savedGroup(page, saved).getByRole('button', { name: t.adminUsersDelete, exact: true });
await keyboardActivate(page, deleteButton);
const dialog = page.getByRole('dialog');
await expect(dialog.getByRole('heading', { name: t.jobDiscoveryDeleteSavedTitle, exact: true })).toBeVisible();
await expect(dialog.getByText(t.jobDiscoveryDeleteSavedConfirm.replace('{name}', saved.name), { exact: true })).toBeVisible();
const cancel = dialog.getByRole('button', { name: t.cancel, exact: true });
await expect(cancel).toBeFocused();
await page.keyboard.press('Shift+Tab');
await expect(dialog.getByRole('button', { name: t.adminUsersDelete, exact: true })).toBeFocused();
await page.keyboard.press('Tab'); await expect(cancel).toBeFocused();
await page.keyboard.press('Enter');
await expect(dialog).toHaveCount(0); await expect(deleteButton).toBeFocused();
expect(count(session.requests, 'DELETE', `/api/job-discovery/saved-searches/${saved.id}`)).toBe(0);
expect(await nativeList(page.request)).toEqual([saved]);
await page.route(`${savedUrl}/${saved.id}`, async route => {
if (route.request().method() === 'DELETE') { deletes++; await deleteGate.pending; }
return route.continue();
});
const deletedResponse = response(page, 'DELETE', `${savedUrl}/${saved.id}`);
try {
await keyboardActivate(page, deleteButton);
await keyboardActivate(page, page.getByRole('dialog').getByRole('button', { name: t.adminUsersDelete, exact: true }));
await expect.poll(() => deletes).toBe(1);
await expect(page.getByRole('dialog')).toHaveCount(0);
await locked(page, 'nb'); await page.keyboard.press('Enter'); expect(deletes).toBe(1);
await capture(page, info, sample, 'native-delete-held');
} finally { deleteGate.release(); }
expect((await deletedResponse).status()).toBe(204);
await expect(savedGroup(page, saved)).toHaveCount(0);
await recordFocus(page, info, 'native-delete-control-removed');
await expect(results(page, 'nb')).toBeFocused();
await page.keyboard.press('Tab');
await recordFocus(page, info, 'native-delete-next-tab');
await expect(page.getByLabel(t.jobDiscoverySort, { exact: true })).toBeFocused();
await expect(article(page, currentJob)).toBeVisible();
expect(await nativeList(page.request)).toEqual([]);
await page.reload(); await expect(page.getByText(t.jobDiscoveryNoSavedSearches, { exact: true })).toBeVisible();
expect(creates).toBe(1); expect(deletes).toBe(1);
});
test('[P7-05 Discovery] contextual Dismiss preserves a row on real missing-result denial and qualifies intercepted UI recovery', async ({ page, session }, info) => {
test.setTimeout(90_000);
const t = translations.en; const sample = { ...desktop, theme: 'dark' as const, width: 390, height: 844 };
const saved = await createSaved(page, session.owner, 'Recorded dismiss owner context');
const job = vacancy('p7-dismiss-encoded Æ Ø Å');
const movedFocusJob = vacancy('p7-dismiss-second-moved-focus');
await page.route(`${savedUrl}/${saved.id}/run`, async route => route.fulfill({ json: await runPayload(page, saved, [job, movedFocusJob]) }));
await enter(page, session.owner, sample);
await keyboardActivate(page, savedGroup(page, saved).getByRole('button', { name: t.jobDiscoveryRunSaved, exact: true }));
await expect(article(page, job)).toBeVisible();
const dismissUrl = `${savedUrl}/${saved.id}/results/${encodeURIComponent(job.id)}/dismiss`;
const deniedResponse = response(page, 'PATCH', dismissUrl);
await keyboardActivate(page, article(page, job).getByRole('button', { name: t.jobDiscoveryDismiss, exact: true }));
expect((await deniedResponse).status()).toBe(404);
await expect(page.getByText(t.jobDiscoveryDismissFailed, { exact: true })).toBeVisible();
await expect(article(page, job)).toBeVisible();
await expect(page.getByRole('button', { name: t.retry, exact: true })).toHaveCount(0);
expect(await nativeList(page.request)).toEqual([saved]);
await capture(page, info, sample, 'dismiss-native-missing-denied');
const gate = hold(); let starts = 0;
await page.route(dismissUrl, async route => {
expect(route.request().method()).toBe('PATCH'); expect(route.request().postDataJSON()).toEqual({ isDismissed: true });
starts++; await gate.pending; return route.fulfill({ status: 204 });
});
try {
await keyboardActivate(page, article(page, job).getByRole('button', { name: t.jobDiscoveryDismiss, exact: true }));
await expect.poll(() => starts).toBe(1); await locked(page); await page.keyboard.press('Enter'); expect(starts).toBe(1);
} finally { gate.release(); }
await expect(article(page, job)).toHaveCount(0);
await expect(page.getByText(t.jobDiscoveryDismissFailed, { exact: true })).toHaveCount(0);
await recordFocus(page, info, 'dismiss-control-removed');
await expect(results(page)).toBeFocused();
await page.keyboard.press('Tab');
await recordFocus(page, info, 'dismiss-next-tab');
await expect(page.getByLabel(t.jobDiscoverySort, { exact: true })).toBeFocused();
await expect(article(page, movedFocusJob)).toBeVisible();
const movedGate = hold();
const movedUrl = `${savedUrl}/${saved.id}/results/${encodeURIComponent(movedFocusJob.id)}/dismiss`;
let movedStarts = 0;
await page.route(movedUrl, async route => {
expect(route.request().method()).toBe('PATCH'); expect(route.request().postDataJSON()).toEqual({ isDismissed: true });
movedStarts++; await movedGate.pending; return route.fulfill({ status: 204 });
});
const headerControl = page.locator('header').getByRole('button', { name: 'Norsk', exact: true });
try {
await keyboardActivate(page, article(page, movedFocusJob).getByRole('button', { name: t.jobDiscoveryDismiss, exact: true }));
await expect.poll(() => movedStarts).toBe(1); await locked(page);
await expect(headerControl).toBeEnabled(); await headerControl.focus(); await expect(headerControl).toBeFocused();
await recordFocus(page, info, 'held-dismiss-user-moved-focus');
} finally { movedGate.release(); }
await expect(article(page, movedFocusJob)).toHaveCount(0);
await recordFocus(page, info, 'dismiss-complete-user-focus-preserved');
await expect(headerControl).toBeFocused();
expect(movedStarts).toBe(1);
expect(await nativeList(page.request)).toEqual([saved]);
expect(count(session.requests, 'GET', '/api/job-discovery/search')).toBe(0);
const patches = session.requests.filter(request => request.path.endsWith('/dismiss'));
expect(patches).toHaveLength(3);
expect(patches.map(request => request.body)).toEqual([{ isDismissed: true }, { isDismissed: true }, { isDismissed: true }]);
await capture(page, info, sample, 'dismiss-intercepted-ui-only');
await page.reload();
await expect(savedGroup(page, saved).getByText(t.jobDiscoveryTrackedCount.replace('{count}', '0'), { exact: true })).toBeVisible();
expect(await nativeList(page.request)).toEqual([saved]);
await info.attach('dismiss-persistence-qualification', { body: JSON.stringify({ savedSearchId: saved.id, externalId: job.id,
actualMissingResultStatus: 404, interceptedRecoveryStatus: 204, actualReadback: await nativeList(page.request),
qualification: 'Provider-run fixture rows do not exist in SQLite. Native 404 proves the missing-result boundary. The next two PATCHes are explicitly intercepted for keyboard/UI recovery, fallback focus and preservation of user-moved focus; unchanged real counts and reload are not successful dismissal persistence evidence.' }), contentType: 'application/json' });
});
test('[P7-05 Discovery] eight finite responsive locale/theme states retain source labels and all-enabled accessibility vectors', async ({ page, session }, info) => {
test.setTimeout(270_000);
const saved = await createSaved(page, session.owner,
'Gjennomgått søk etter utviklere for samfunnskritiske tjenester, integrasjoner og offentlige plattformer — Æ Ø Å med lang forklaring',
'Senior systemutvikler for robuste og samfunnskritiske plattformer', 'TRONDHEIM');
const longJob = vacancy('finite-long', 'Senior systemutvikler for robuste og samfunnskritiske plattformer med ansvar for offentlige tjenester');
const shortJob = vacancy('finite-analyst', 'Analyst');
let jobs: Vacancy[] = [longJob, shortJob];
await page.route(`${searchUrl}**`, route => route.fulfill({ json: jobs }));
const samples: Array<Sample & { empty: boolean }> = [
{ language: 'en', theme: 'light', width: 320, height: 740, empty: false },
{ language: 'nb', theme: 'dark', width: 360, height: 844, empty: false },
{ language: 'en', theme: 'dark', width: 390, height: 844, empty: true },
{ language: 'nb', theme: 'light', width: 768, height: 1024, empty: false },
{ language: 'en', theme: 'light', width: 1024, height: 768, empty: false },
{ language: 'nb', theme: 'dark', width: 1280, height: 320, empty: true },
{ language: 'en', theme: 'dark', width: 1440, height: 900, empty: false },
{ language: 'nb', theme: 'light', width: 1920, height: 1080, empty: false },
];
for (const sample of samples) await test.step(`${sample.language}-${sample.theme}-${sample.width}-${sample.empty ? 'empty' : 'loaded'}`, async () => {
const t = translations[sample.language]; jobs = sample.empty ? [] : [longJob, shortJob];
await enter(page, session.owner, sample);
await expect(savedGroup(page, saved)).toBeVisible();
await page.getByLabel(t.jobDiscoveryRoleCompany, { exact: true }).fill('samfunnskritisk');
await page.getByLabel(t.jobDiscoveryMunicipality, { exact: true }).fill('Trondheim');
await keyboardActivate(page, page.getByRole('button', { name: t.jobDiscoverySearchNav, exact: true }));
if (sample.empty) await expect(results(page, sample.language).getByText(t.jobDiscoveryNoResults, { exact: true })).toBeVisible();
else {
await expect(article(page, longJob).getByRole('heading', { level: 3, name: longJob.title, exact: true })).toBeVisible();
await expect(article(page, longJob).getByText('NAV Arbeidsplassen', { exact: true })).toBeVisible();
await expect(article(page, longJob).getByText(t.jobDiscoveryWorkArrangementMissing, { exact: true })).toBeVisible();
await expect(article(page, longJob).getByRole('link', { name: t.jobDiscoveryViewListing, exact: true })).toHaveAttribute('href', longJob.url);
// MUI includes the selected value in aria-labelledby, so label lookup names
// the field precisely without assuming its complete accessible name is label-only.
await page.getByLabel(t.jobDiscoverySort, { exact: true }).click();
await keyboardActivate(page, page.getByRole('option', { name: t.jobDiscoverySortTitle, exact: true }));
await expect(page.getByRole('combobox', { name: `${t.jobDiscoverySort} ${t.jobDiscoverySortTitle}`, exact: true })).toBeFocused();
await expect(results(page, sample.language).getByRole('article').first()).toHaveAttribute('aria-label', shortJob.title);
}
await expect(results(page, sample.language).getByRole('button', { name: t.jobDiscoveryDismiss, exact: true })).toHaveCount(0);
expect(await nativeList(page.request)).toEqual([saved]);
await capture(page, info, sample, sample.empty ? 'finite-empty' : 'finite-loaded');
});
await info.attach('discovery-finite-samples', { body: JSON.stringify({ samples, providerBoundary,
qualification: 'Exactly eight configured width/language/theme states, including two empty states and a 1280×320 landscape state. Both themes and languages are represented without claiming every combination.' }), contentType: 'application/json' });
});
test('[P7-05 Discovery] header locale change retains drafts, selected saved source and rows without discovery refetch', async ({ page, session }, info) => {
const en = translations.en; const nb = translations.nb;
const saved = await createSaved(page, session.owner, 'Recorded language-independent source', 'canonical saved role', 'Bergen');
const job = vacancy('locale-source');
await page.route(`${savedUrl}/${saved.id}/run`, async route => route.fulfill({ json: await runPayload(page, saved, [job]) }));
await enter(page, session.owner);
await keyboardActivate(page, savedGroup(page, saved).getByRole('button', { name: en.jobDiscoveryRunSaved, exact: true }));
await expect(article(page, job)).toBeVisible();
await page.getByLabel(en.jobDiscoveryRoleCompany, { exact: true }).fill('unsubmitted role Æ Ø Å');
await page.getByLabel(en.jobDiscoveryMunicipality, { exact: true }).fill('Ålesund draft');
await page.getByLabel(en.jobDiscoverySavedName, { exact: true }).fill('Unsubmitted saved-name draft');
const before = [...session.requests];
const savedPreference = response(page, 'PUT', `${apiUrl}/auth/preferences/language`);
await keyboardActivate(page, page.locator('header').getByRole('button', { name: 'Norsk', exact: true }));
expect((await savedPreference).status()).toBe(204);
await expect(page.locator('html')).toHaveAttribute('lang', 'nb-NO');
await expect(page.getByLabel(nb.jobDiscoveryRoleCompany, { exact: true })).toHaveValue('unsubmitted role Æ Ø Å');
await expect(page.getByLabel(nb.jobDiscoveryMunicipality, { exact: true })).toHaveValue('Ålesund draft');
await expect(page.getByLabel(nb.jobDiscoverySavedName, { exact: true })).toHaveValue('Unsubmitted saved-name draft');
await expect(article(page, job).getByRole('button', { name: nb.jobDiscoveryDismiss, exact: true })).toBeVisible();
await expect(savedGroup(page, saved).getByRole('button', { name: nb.jobDiscoveryRunSaved, exact: true })).toBeVisible();
await expect(savedGroup(page, saved).getByRole('switch', { name: nb.jobDiscoveryAutomaticAlerts, exact: true })).toBeChecked();
await expect(results(page, 'nb').getByRole('heading', { level: 2, name: nb.jobDiscoveryResults, exact: true })).toBeVisible();
expect(session.requests).toEqual(before);
expect(await nativeList(page.request)).toEqual([saved]);
await capture(page, info, { ...desktop, language: 'nb' }, 'locale-retained-source');
});