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

308 lines
28 KiB
TypeScript

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 { JobApplication } from '../src/types';
const apiUrl = 'http://localhost:5302/api';
const jobsUrl = `${apiUrl}/jobapplications`;
const remindersUrl = `${jobsUrl}/reminders`;
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: { [key: string]: string }; body: unknown };
type Session = { owner: Owner; admin: APIRequestContext; administrator: Owner; requests: RequestRecord[] };
type FollowUpEvent = { id: number; type: string; oldValue: string | null; newValue: string | null };
const desktop: Sample = { language: 'en', theme: 'light', width: 1440, height: 900 };
const fixedTime = '2026-10-12T23:30:00-08:00';
const boundary = 'Actual owner HTTP fixtures, follow-up PATCH204, reloads, history and ownership checks use disposable isolated SQLite. Free account entitlements disable model access; the serial runtime disables AI, follow-up delivery and saved-search alert workers. No provider/model/send endpoint, raw SQL or schema is invoked. Controlled PATCH/GET failure and held requests qualify presentation and recovery, not production reliability. Browser request metadata excludes session/CSRF headers. The Shell independently requests the identical reminders?upcomingDays=14 endpoint initially and every60s; observed counts retain that source and do not claim one total GET per page. Existing absolute-millisecond-to-UTC-date scheduling and followMode route metadata are preserved; no DST/calendar or deferred composer qualification is claimed.';
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 } };
}
async function account(admin: APIRequestContext, administrator: Owner) {
const email = `p7-reminders-${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\/jobapplications(?:\/|$)/.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 account(admin, administrator));
expect((await json<{ entitlements: { ai: boolean } }>(await page.request.get(`${apiUrl}/auth/me`))).entitlements.ai).toBe(false);
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, admin, administrator, requests });
} finally {
await info.attach('reminders-request-boundary', { body: JSON.stringify({ boundary, requests, blockedExternalRequests }), contentType: 'application/json' });
await admin.dispose();
}
},
});
async function company(api: APIRequestContext, owner: Owner, name: string) {
return json<{ id: number; name: string }>(await api.post(`${apiUrl}/companies`, { headers: owner.headers, data: { name, location: 'Oslo', source: 'User' } }), 201);
}
async function job(api: APIRequestContext, owner: Owner, companyId: number, jobTitle: string, fields: Partial<JobApplication> = {}) {
return json<JobApplication>(await api.post(jobsUrl, { headers: owner.headers, data: { companyId, jobTitle, status: 'Applied', ...fields } }), 201);
}
async function read(api: APIRequestContext) { return json<JobApplication[]>(await api.get(remindersUrl, { params: { upcomingDays: 14 } })); }
async function nativeJob(api: APIRequestContext, id: number) { return json<JobApplication>(await api.get(`${jobsUrl}/${id}`)); }
async function history(api: APIRequestContext, id: number) {
return (await json<FollowUpEvent[]>(await api.get(`${jobsUrl}/${id}/history`))).filter(event => event.type === 'FollowUpSet').sort((a, b) => a.id - b.id);
}
async function groups(api: APIRequestContext, owner: Owner, prefix = 'Recorded reminder') {
const source = await company(api, owner, `${prefix} company`);
const future = new Date(Date.now() + 3 * 86_400_000).toISOString();
const rows = [
await job(api, owner, source.id, `${prefix} package work`, { dateApplied: '2026-01-01T12:00:00Z' }),
await job(api, owner, source.id, `${prefix} interview prep`, { status: 'Interview', followUpAt: '2026-01-01T12:00:00Z' }),
await job(api, owner, source.id, `${prefix} follow-up`, { status: 'Rejected', feedbackRequestedAt: '2026-01-01T12:00:00Z', followUpAt: '2026-01-01T12:00:00Z' }),
await job(api, owner, source.id, `${prefix} scheduled prospect`, { status: 'Saved', followUpAt: future }),
];
expect(rows.map(row => row.workflowSignal?.actionKey)).toEqual(['package-work', 'interview-prep', 'follow-up', 'review-readiness']);
return { source, rows };
}
function region(page: Page, language: Sample['language'] = 'en') { return page.getByRole('region', { name: translations[language].remindersTitle, exact: true }); }
function article(page: Page, title: string) { return page.getByRole('article', { name: title, exact: true }); }
function text(template: string, params: { [key: string]: string | number }) { return template.replace(/\{(\w+)\}/g, (_, key: string) => String(params[key] ?? `{${key}}`)); }
function snooze(page: Page, row: JobApplication, days: number, language: Sample['language'] = 'en') {
return article(page, row.jobTitle).getByRole('button', { name: text(translations[language].remindersSnoozeForJob, { days, job: row.jobTitle }), exact: true });
}
function clear(page: Page, row: JobApplication, language: Sample['language'] = 'en') {
return article(page, row.jobTitle).getByRole('button', { name: text(translations[language].remindersClearForJob, { job: row.jobTitle }), exact: true });
}
function action(row: JobApplication, language: Sample['language'] = 'en') {
const t = translations[language];
const signal = row.workflowSignal;
const label = !signal?.needsAttention ? t.remindersOpen : signal.actionKey === 'package-work' ? t.jobTablePackageWork
: signal.actionKey === 'interview-prep' ? t.jobTableInterviewStage : signal.actionKey === 'follow-up' ? t.jobTableFollowUp : t.jobTableReadiness;
return `${label}: ${row.jobTitle}`;
}
function requests(session: Session, method: string, path: string) { return session.requests.filter(request => request.method === method && request.path === path); }
function hold() { let release!: () => void; const pending = new Promise<void>(resolve => { release = resolve; }); return { pending, release }; }
async function keyboard(page: Page, control: Locator) { await control.focus(); await expect(control).toBeFocused(); await page.keyboard.press('Enter'); }
function response(page: Page, method: string, url: string) { return page.waitForResponse(value => value.url() === url && value.request().method() === method); }
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('/reminders?source=phase7-reminders#groups');
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].reminders, exact: true })).toBeVisible();
await expect(page.getByRole('heading', { level: 2, name: translations[sample.language].remindersTitle, exact: true })).toBeVisible();
}
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),
focused: { tag: document.activeElement?.tagName, role: document.activeElement?.getAttribute('role'), label: document.activeElement?.getAttribute('aria-label') },
}));
const axe = await new AxeBuilder({ page }).options({ rules: { 'target-size': { enabled: true } } }).analyze();
const name = `p7-reminders-${sample.language}-${sample.theme}-${sample.width}-${state}`;
await info.attach(`${name}-audit`, { body: JSON.stringify({ sample, state, geometry, boundary, ariaSnapshot: await page.locator('body').ariaSnapshot(),
axe: { scope: 'All default-enabled rules plus target-size', violations: axe.violations, incomplete: axe.incomplete, passes: axe.passes.map(rule => ({ id: rule.id, nodes: rule.nodes.length })) },
qualifications: ['Finite isolated Chromium states, not all width/language/theme/state combinations.',
'Every violation/incomplete vector is retained for context review. Keyboard/focus checks are not human AT, physical touch/software keyboard or real zoom acceptance.',
'Full-page images retain sticky/scroll/focus context; they do not establish precise contrast or focus occlusion throughout the real viewport.',
'Provider/model/delivery, deployed fonts/photos, production readiness and DST/calendar qualification remain separate.'],
}), contentType: 'application/json' });
await info.attach(name, { body: await page.screenshot({ fullPage: true, animations: 'disabled' }), contentType: 'image/png' });
expect(geometry.rootOverflow, `${name} root reflow`).toBe(0); expect(axe.violations, `${name} complete axe violations`).toEqual([]);
}
test('[P7-05 Reminders] actual empty and four workflow groups preserve counts, reasons, routes and owner boundaries', async ({ page, session, playwright }, info) => {
test.setTimeout(120_000);
await enter(page, session.owner); await expect(region(page).getByText(translations.en.remindersNothing, { exact: true })).toBeVisible();
await capture(page, info, desktop, 'actual-empty');
const { source, rows } = await groups(page.request, session.owner);
const deleted = await job(page.request, session.owner, source.id, 'Deleted reminder must remain absent');
expect((await page.request.delete(`${jobsUrl}/${deleted.id}`, { headers: session.owner.headers })).status()).toBe(204);
const foreignApi = await playwright.request.newContext(); const anonymous = await playwright.request.newContext();
try {
const foreign = await login(foreignApi, await account(session.admin, session.administrator));
const foreignCompany = await company(foreignApi, foreign, 'Foreign reminder company');
const foreignJob = await job(foreignApi, foreign, foreignCompany.id, 'Foreign reminder must remain absent');
const authoritative = await read(page.request);
expect(authoritative.map(row => row.id).sort((a, b) => a - b)).toEqual(rows.map(row => row.id).sort((a, b) => a - b));
expect((await page.request.patch(`${jobsUrl}/${foreignJob.id}/followup`, { headers: session.owner.headers, data: { followUpAt: null } })).status()).toBe(404);
expect(await history(foreignApi, foreignJob.id)).toEqual([]); expect((await anonymous.get(remindersUrl)).status()).toBe(401);
await page.reload(); await expect(region(page).getByRole('article')).toHaveCount(4);
const titles = [translations.en.remindersMissingTailoredCv, translations.en.remindersMissingInterviewPrep, translations.en.remindersFollowUpDue, translations.en.remindersOther];
expect(await region(page).getByRole('heading', { level: 3 }).allTextContents()).toEqual(titles);
for (let index = 0; index < rows.length; index++) {
const row = authoritative.find(value => value.id === rows[index].id)!;
const group = region(page).getByRole('region', { name: titles[index], exact: true });
await expect(group.getByRole('article')).toHaveCount(1); await expect(group.getByRole('article', { name: row.jobTitle, exact: true })).toBeVisible();
await expect(article(page, row.jobTitle)).toContainText(row.workflowSignal!.reason);
await expect(article(page, row.jobTitle).getByRole('heading', { level: 4 })).toContainText(row.jobTitle);
}
await expect(article(page, deleted.jobTitle)).toHaveCount(0); await expect(article(page, foreignJob.jobTitle)).toHaveCount(0);
await capture(page, info, desktop, 'actual-four-groups');
const paths = [`/jobs/${rows[0].id}?section=cv`, `/jobs/${rows[1].id}?section=interview`,
`/jobs/${rows[2].id}?section=communication&followMode=waiting-update`, `/jobs/${rows[3].id}?section=checklist`];
for (let index = 0; index < rows.length; index++) {
const loaded = response(page, 'GET', `${jobsUrl}/${rows[index].id}/workspace`);
await keyboard(page, article(page, rows[index].jobTitle).getByRole('button', { name: action(rows[index]), exact: true }));
await expect.poll(() => new URL(page.url()).pathname + new URL(page.url()).search).toBe(paths[index]);
expect(await json<{ id: number; jobTitle: string }>(await loaded)).toMatchObject({ id: rows[index].id, jobTitle: rows[index].jobTitle });
await page.goBack(); await expect(page).toHaveURL(/\/reminders\?source=phase7-reminders#groups$/); await expect(article(page, rows[index].jobTitle)).toBeVisible();
}
expect(session.requests.filter(request => /followup-draft|send-followup/.test(request.path))).toEqual([]);
await info.attach('reminders-real-groups', { body: JSON.stringify({ boundary, rows: authoritative, groupCounts: [1, 1, 1, 1], existingPaths: paths,
qualification: 'The four groups follow actual server signals and current frontend grouping. followMode remains metadata only; no composer qualification, generation or delivery occurs.' }), contentType: 'application/json' });
} finally { await foreignApi.dispose(); await anonymous.dispose(); }
});
test('[P7-05 Reminders] plus3, plus7 and clear persist actual dates and one history event per write with existing UTC-date semantics', async ({ page, session }, info) => {
test.setTimeout(90_000); await page.clock.setFixedTime(new Date(fixedTime));
const source = await company(page.request, session.owner, 'Recorded scheduling company');
const row = await job(page.request, session.owner, source.id, 'Scheduled prospect removed after clear', { status: 'Saved', followUpAt: '2026-10-16T00:00:00Z' });
expect(await history(page.request, row.id)).toEqual([]); await enter(page, session.owner); await expect(article(page, row.jobTitle)).toBeVisible();
for (const [days, date] of [[3, '2026-10-16'], [7, '2026-10-20']] as const) {
const completed = response(page, 'PATCH', `${jobsUrl}/${row.id}/followup`);
await keyboard(page, snooze(page, row, days)); expect((await completed).status()).toBe(204);
await expect(snooze(page, row, days)).toBeEnabled(); expect((await nativeJob(page.request, row.id)).followUpAt?.slice(0, 10)).toBe(date);
expect(requests(session, 'PATCH', `/api/jobapplications/${row.id}/followup`).at(-1)?.body).toEqual({ followUpAt: date });
}
const cleared = response(page, 'PATCH', `${jobsUrl}/${row.id}/followup`);
await keyboard(page, clear(page, row)); expect((await cleared).status()).toBe(204);
await expect(article(page, row.jobTitle)).toHaveCount(0); await expect(region(page)).toBeFocused();
expect((await nativeJob(page.request, row.id)).followUpAt).toBeNull();
expect(requests(session, 'PATCH', `/api/jobapplications/${row.id}/followup`).map(request => request.body)).toEqual([{ followUpAt: '2026-10-16' }, { followUpAt: '2026-10-20' }, { followUpAt: null }]);
expect((await history(page.request, row.id)).map(event => event.newValue?.slice(0, 10) ?? null)).toEqual(['2026-10-16', '2026-10-20', null]);
await capture(page, info, desktop, 'cleared-row-region-focus');
await info.attach('reminders-date-semantics', { body: JSON.stringify({ fixedBrowserTime: fixedTime, fixedUtcTime: new Date(fixedTime).toISOString(), expected: ['2026-10-16', '2026-10-20', null],
qualification: 'This checks the preserved Date.now+N*24h toISOString date slice. It does not replace that behavior with local-calendar arithmetic or establish DST/mobile keyboard qualification.' }), contentType: 'application/json' });
});
test('[P7-05 Reminders] held single PATCH locks every row action and failed explicit Retry preserves its captured date and moved focus', async ({ page, session }, info) => {
test.setTimeout(90_000); await page.clock.setFixedTime(new Date(fixedTime));
const source = await company(page.request, session.owner, 'Pending reminder company');
const row = await job(page.request, session.owner, source.id, 'Captured failed follow-up action');
await job(page.request, session.owner, source.id, 'Other row must be locked');
await enter(page, session.owner); await expect(article(page, row.jobTitle)).toBeVisible();
const first = hold(); const second = hold(); let attempts = 0;
await page.route(`${jobsUrl}/${row.id}/followup`, async route => {
if (route.request().method() !== 'PATCH') return route.continue(); attempts += 1;
if (attempts === 1) { await first.pending; await route.fulfill({ status: 503, json: {} }); }
else { await second.pending; await route.continue(); }
});
try {
await keyboard(page, snooze(page, row, 3)); await expect.poll(() => attempts).toBe(1); await expect(region(page)).toBeFocused();
for (const button of await region(page).getByRole('article').getByRole('button').all()) await expect(button).toBeDisabled();
expect(requests(session, 'PATCH', `/api/jobapplications/${row.id}/followup`)).toHaveLength(1);
expect(await history(page.request, row.id)).toEqual([]); await capture(page, info, desktop, 'held-single-patch'); first.release();
await expect(region(page).getByRole('alert')).toContainText(text(translations.en.remindersMutationFailedForJob, { job: row.jobTitle }));
await expect(region(page).getByRole('button', { name: translations.en.retry, exact: true })).toBeVisible();
expect(await history(page.request, row.id)).toEqual([]); await capture(page, info, desktop, 'captured-patch-failure');
await page.clock.setFixedTime(new Date('2026-10-13T23:30:00-08:00'));
await keyboard(page, region(page).getByRole('button', { name: translations.en.retry, exact: true })); await expect.poll(() => attempts).toBe(2);
await expect(region(page)).toBeFocused(); const external = page.locator('header').getByRole('button', { name: 'Norsk', exact: true });
await external.focus(); await expect(external).toBeFocused(); second.release();
await expect(snooze(page, row, 3)).toBeEnabled(); await expect(region(page).getByRole('alert')).toHaveCount(0); await expect(external).toBeFocused();
expect(requests(session, 'PATCH', `/api/jobapplications/${row.id}/followup`).map(request => request.body)).toEqual([{ followUpAt: '2026-10-16' }, { followUpAt: '2026-10-16' }]);
expect((await nativeJob(page.request, row.id)).followUpAt?.slice(0, 10)).toBe('2026-10-16'); expect(await history(page.request, row.id)).toHaveLength(1);
await capture(page, info, desktop, 'actual-retry-moved-focus-preserved');
} finally { first.release(); second.release(); }
});
test('[P7-05 Reminders] committed PATCH204 followed by a failed reload retries only GET and preserves its single history event', async ({ page, session }, info) => {
test.setTimeout(90_000); await page.clock.setFixedTime(new Date(fixedTime));
const source = await company(page.request, session.owner, 'Committed reminder company');
const row = await job(page.request, session.owner, source.id, 'Committed follow-up with failed view refresh');
await enter(page, session.owner); await expect(article(page, row.jobTitle)).toBeVisible();
const gate = hold(); let reads = 0;
await page.route(`${remindersUrl}?**`, async route => {
reads += 1; if (reads === 1) await route.fulfill({ status: 500, json: {} }); else { await gate.pending; await route.continue(); }
});
try {
const completed = response(page, 'PATCH', `${jobsUrl}/${row.id}/followup`); await keyboard(page, snooze(page, row, 3)); expect((await completed).status()).toBe(204);
await expect(region(page).getByRole('alert')).toContainText(translations.en.remindersLoadFailed);
expect((await nativeJob(page.request, row.id)).followUpAt?.slice(0, 10)).toBe('2026-10-16'); expect(await history(page.request, row.id)).toHaveLength(1);
expect(requests(session, 'PATCH', `/api/jobapplications/${row.id}/followup`)).toHaveLength(1);
await expect(region(page).getByText(text(translations.en.remindersMutationFailedForJob, { job: row.jobTitle }), { exact: true })).toHaveCount(0);
await capture(page, info, desktop, 'committed-write-read-failure');
await keyboard(page, region(page).getByRole('button', { name: translations.en.retry, exact: true })); await expect.poll(() => reads).toBe(2);
await expect(region(page)).toBeFocused(); await expect(region(page).getByRole('status')).toContainText(translations.en.loading);
expect(requests(session, 'PATCH', `/api/jobapplications/${row.id}/followup`)).toHaveLength(1); await capture(page, info, desktop, 'held-get-only-recovery'); gate.release();
await expect(article(page, row.jobTitle)).toBeVisible(); await expect(region(page).getByRole('alert')).toHaveCount(0);
expect(await history(page.request, row.id)).toHaveLength(1); expect(requests(session, 'PATCH', `/api/jobapplications/${row.id}/followup`)).toHaveLength(1);
} finally { gate.release(); }
});
test('[P7-05 Reminders] live EN/Bokmal translates status/date controls and preserves raw reasons without a page-triggered refetch', async ({ page, session }, info) => {
test.setTimeout(90_000); const { rows } = await groups(page.request, session.owner);
await enter(page, session.owner); await expect(region(page).getByRole('article')).toHaveCount(4);
await expect.poll(() => requests(session, 'GET', '/api/jobapplications/reminders').length).toBeGreaterThanOrEqual(2);
const baseline = requests(session, 'GET', '/api/jobapplications/reminders').length;
const preference = response(page, 'PUT', `${apiUrl}/auth/preferences/language`);
await keyboard(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(region(page, 'nb').getByRole('article')).toHaveCount(4);
for (const row of rows) await expect(article(page, row.jobTitle)).toContainText(row.workflowSignal!.reason);
await expect(article(page, rows[1].jobTitle).getByText(translations.nb.statusInterview, { exact: true })).toBeVisible();
const authoritative = (await read(page.request)).find(row => row.id === rows[3].id)!;
const date = await page.evaluate(value => new Date(value).toLocaleDateString('nb-NO'), authoritative.followUpAt!);
await expect(article(page, rows[3].jobTitle).getByText(text(translations.nb.remindersFollowUpDate, { date }), { exact: true })).toBeVisible();
await expect(snooze(page, rows[0], 3, 'nb')).toBeVisible(); await expect(clear(page, rows[0], 'nb')).toBeVisible();
const elapsed = await page.evaluate(() => performance.now()); expect(elapsed).toBeLessThan(60_000);
expect(requests(session, 'GET', '/api/jobapplications/reminders')).toHaveLength(baseline);
expect(session.requests.filter(request => request.method === 'PATCH')).toEqual([]);
await capture(page, info, { ...desktop, language: 'nb' }, 'live-locale-raw-reasons');
await info.attach('reminders-shell-read-boundary', { body: JSON.stringify({ boundary, initialObservedReads: baseline, afterLocaleReads: requests(session, 'GET', '/api/jobapplications/reminders').length,
documentElapsedMs: elapsed, qualification: 'The baseline includes both Shell initial read and page read. This measured locale transition completes before the Shell60s poll and creates no additional identical request. No total-GET1 or Shell reliability claim.' }), contentType: 'application/json' });
});
test('[P7-05 Reminders] eight finite bilingual theme widths preserve long rows and keyboard-accessible actions', async ({ page, session }, info) => {
test.setTimeout(240_000);
const prefix = 'Langsiktig søknadsoppfølging for samfunnskritiske tjenester med mange samarbeidspartnere Æ Ø Å';
const { rows } = await groups(page.request, session.owner, prefix);
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: 768, height: 1024 },
{ language: 'en', theme: 'dark', width: 1024, height: 768 }, { language: 'nb', theme: 'light', width: 1280, height: 320 },
{ language: 'en', theme: 'light', width: 1440, height: 900 }, { language: 'nb', theme: 'dark', width: 1920, height: 1080 },
];
for (const sample of samples) {
await enter(page, session.owner, sample); await expect(region(page, sample.language).getByRole('article')).toHaveCount(4);
for (const row of rows) {
await expect(article(page, row.jobTitle).getByRole('heading', { level: 4 })).toContainText(row.jobTitle);
await expect(article(page, row.jobTitle)).toContainText(row.workflowSignal!.reason);
const open = article(page, row.jobTitle).getByRole('button', { name: action(row, sample.language), exact: true });
await open.scrollIntoViewIfNeeded(); await open.focus(); await expect(open).toBeFocused();
for (const days of [3, 7]) { await snooze(page, row, days, sample.language).focus(); await expect(snooze(page, row, days, sample.language)).toBeFocused(); }
}
await page.evaluate(() => scrollTo(0, 0)); await capture(page, info, sample, 'long-four-group-rows');
}
});