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

110 lines
33 KiB
TypeScript

import AxeBuilder from '@axe-core/playwright';
import { spawnSync } from 'node:child_process';
import { realpathSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
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 { UserNotification } from '../src/notifications';
const apiUrl = 'http://localhost:5302/api';
const password = 'E2ePassword123!';
type Owner = { id: string; email: string; headers: Record<string, string> };
type Operation = { id: string; taskType: string; status: string; subjectType: string | null; createdAtUtc: string; startedAtUtc: string | null; completedAtUtc: string | null; deadlineAtUtc: string | null; cancellationRequestedAtUtc: string | null; progressStage: string | null; progressPercent: number | null; failureCategory: string | null; canCancel: boolean; canRetry: boolean };
type Sample = { language: 'en' | 'nb'; theme: 'light' | 'dark'; width: number; height: number };
type RequestRecord = { method: string; path: string; query: string };
type Session = { owner: Owner; administrator: Owner; admin: APIRequestContext; requests: RequestRecord[] };
const desktop: Sample = { language: 'en', theme: 'light', width: 1440, height: 900 };
const boundary = 'Actual Program, local Identity cookies/CSRF, free owner accounts and isolated temporary SQLite. A guarded test-only helper inserts exactly one inert queued native-offline-fixture task; no registered producer/handler, AI entitlement, subject, provider/model, lease, usage or result is created. Actual cancel/retry/read/dismiss endpoints own every recorded persistent transition. Actual terminal notices have operationId and linkPath=null: no result/Open capability is inferred. Explicitly labelled controlled GET projections qualify only long-text and internal-link presentation. Controlled precommit503/held GET responses qualify UI recovery/race handling, not uncertain delivery. Every external request is aborted. Only existing HTTPS GET stylesheet requests to fonts.googleapis.com/css2 are classified separately as blocked font resources; every occurrence is retained, no font is fetched, and other external/provider/model requests remain an ordinary failing assertion. Font-resource counts are separate from forbidden provider/model counts; these captures do not qualify production fonts. No production/main/deployment, worker activation, Docker/model qualification, provider grant/send/sync, renderer or Phase8 action. Finite local Chromium/axe evidence does not establish human screen-reader, physical touch/software keyboard, actual zoom, live MariaDB, real worker execution, production font/photo or production readiness.';
function copy(key: string, language: Sample['language'] = 'en', params: Record<string, string | number> = {}) { return ((translations[language] as Record<string, string>)[key] ?? key).replace(/\{(\w+)\}/g, (_, name: string) => String(params[name] ?? `{${name}}`)); }
function hold() { let release!: () => void; const pending = new Promise<void>(resolve => { release = resolve; }); return { pending, release }; }
async function json<T>(response: APIResponse | Response, status = 200): Promise<T> { expect(response.status(), response.url()).toBe(status); return response.json(); }
function waitResponse(page: Page, method: string, suffix: string) { return page.waitForResponse(response => response.request().method() === method && new URL(response.url()).pathname === `/api${suffix}`); }
function count(session: Session, method: string, suffix: string) { return session.requests.filter(request => request.method === method && request.path === `/api${suffix}`); }
function panel(page: Page, language: Sample['language'] = 'en') { return page.getByRole('dialog', { name: copy('notificationsPanel', language), exact: true }); }
function bell(page: Page, language: Sample['language'] = 'en') { return page.locator('header').getByRole('button', { name: copy('notifications', language), exact: true }); }
async function keyboard(page: Page, control: Locator) { await expect(control).toBeVisible(); await expect(control).toBeEnabled(); await control.focus(); await expect(control).toBeFocused(); await page.keyboard.press('Enter'); }
function utcFields(value: Record<string, unknown>) { for (const [key, date] of Object.entries(value)) if (key.endsWith('AtUtc') && date !== null && date !== undefined) expect(date, key).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/); }
async function operation(api: APIRequestContext, id: string) { const value = await json<Operation>(await api.get(`${apiUrl}/operations/${id}`)); utcFields(value); expect(value.id).toBe(id); expect(Object.keys(value).sort()).toEqual(['id', 'taskType', 'status', 'subjectType', 'createdAtUtc', 'startedAtUtc', 'completedAtUtc', 'deadlineAtUtc', 'cancellationRequestedAtUtc', 'progressStage', 'progressPercent', 'failureCategory', 'canCancel', 'canRetry'].sort()); return value; }
async function notices(api: APIRequestContext) { const values = await json<UserNotification[]>(await api.get(`${apiUrl}/notifications?limit=100`)); for (const value of values) utcFields(value); return values; }
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 token = (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN'); expect(token).toBeDefined(); return { id: me.id, email, headers: { 'X-CSRF-TOKEN': token!.value, 'X-Session-Context': me.sessionContext } }; }
async function account(admin: APIRequestContext, owner: Owner) { const email = `p708-native-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`; expect((await admin.post(`${apiUrl}/users`, { headers: owner.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 blocked: string[] = []; const blockedKnownFontStylesheets: { url: string; resourceType: string }[] = [];
page.on('request', request => { const url = new URL(request.url()); if (/^\/api\/(?:operations|notifications|auth\/logout)(?:\/|$)/.test(url.pathname)) requests.push({ method: request.method(), path: url.pathname, query: url.search }); });
await context.route('**/*', route => { const request = route.request(); const url = new URL(request.url()); if (url.protocol === 'https:' && url.hostname === 'fonts.googleapis.com' && url.port === '' && url.pathname === '/css2' && request.method() === 'GET' && request.resourceType() === 'stylesheet') { blockedKnownFontStylesheets.push({ url: url.href, resourceType: request.resourceType() }); return route.abort(); } if (/^\/api\/(?:ai|email\/send|gmail)(?:\/|$)/.test(url.pathname) || !['blob:', 'data:', 'about:'].includes(url.protocol) && (!['localhost', '127.0.0.1'].includes(url.hostname) || !['3300', '5302'].includes(url.port))) { blocked.push(url.origin + url.pathname); return route.abort(); } return route.continue(); });
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 provide({ owner, administrator, admin, requests }); expect(blocked).toEqual([]); }
finally { await info.attach('notifications-native-boundary', { body: JSON.stringify({ boundary, requests, blockedExternalOrModelDeliveryRequests: blocked, blockedKnownFontStylesheets, fontQualification: 'All recorded font stylesheet requests were aborted without external access. Production font acceptance remains pending; no font-resource count is included in provider/model request counts.' }), contentType: 'application/json' }); await admin.dispose(); }
},
});
function seedQueued(info: TestInfo, owner: Owner) {
const metadataRoot = info.config.metadata.isolatedDataRoot;
if (typeof metadataRoot !== 'string' || process.env.PLAYWRIGHT_REUSE_SERVERS === 'true') throw new Error('Operation seeding requires explicit isolated non-reused dataRoot');
expect(path.isAbsolute(metadataRoot)).toBe(true); const dataRoot = realpathSync(metadataRoot); expect(path.dirname(dataRoot)).toBe(realpathSync(os.tmpdir())); expect(path.basename(dataRoot)).toMatch(/^jobtracker-e2e-[A-Za-z0-9][A-Za-z0-9_-]*$/);
const result = spawnSync('rtk', ['proxy', process.env.PLAYWRIGHT_PYTHON_PATH || 'python', '-X', 'utf8', path.resolve('e2e/helpers/seed-operation.py')], { input: JSON.stringify({ dataRoot, ownerId: owner.id, ownerEmail: owner.email }), encoding: 'utf8', timeout: 15_000, maxBuffer: 1024 * 1024 });
expect(result.error, 'guarded helper must start').toBeUndefined(); expect(result.status, result.stderr).toBe(0); const value = JSON.parse(result.stdout) as { operationId: string }; expect(Object.keys(value)).toEqual(['operationId']); expect(value.operationId).toMatch(/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/); return value.operationId;
}
async function cancel(api: APIRequestContext, owner: Owner, id: string) { const value = await json<Operation>(await api.post(`${apiUrl}/operations/${id}/cancel`, { headers: owner.headers })); utcFields(value); expect(value).toMatchObject({ id, status: 'cancelled', canCancel: false, canRetry: true }); const notice = (await notices(api)).find(item => item.operationId === id)!; expect(notice).toBeDefined(); expect(notice).toMatchObject({ operationId: id, kind: 'operation_cancelled', linkPath: null, readAtUtc: null }); expect(notice.createdAtUtc).toBe(value.completedAtUtc); return { value, notice }; }
async function terminal(page: Page, owner: Owner, info: TestInfo) { const id = seedQueued(info, owner); const queued = await operation(page.request, id); expect(queued).toMatchObject({ taskType: 'native-offline-fixture', status: 'queued', subjectType: null, canCancel: true, canRetry: false }); const listed = await json<Operation[]>(await page.request.get(`${apiUrl}/operations?limit=100`)); expect(listed.find(item => item.id === id)).toEqual(queued); return { id, queued, ...await cancel(page.request, owner, id) }; }
async function enter(page: Page, owner: Owner, sample: Sample = desktop, target = '/documents?source=phase7-notifications#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(target);
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(bell(page, sample.language)).toBeVisible();
}
async function open(page: Page, language: Sample['language'] = 'en') { await keyboard(page, bell(page, language)); await expect(panel(page, language)).toBeVisible(); await expect(panel(page, language).getByRole('heading', { level: 2, name: copy('notifications', language), exact: true })).toBeVisible(); }
async function close(page: Page, language: Sample['language'] = 'en') { await page.keyboard.press('Escape'); await expect(panel(page, language)).toHaveCount(0); await expect(bell(page, language)).toBeFocused(); }
async function capture(page: Page, info: TestInfo, sample: Sample, state: string, projection: string | null = null) {
await page.evaluate(async () => { await document.fonts.ready; await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); }); const dialog = panel(page, sample.language);
const geometry = await dialog.evaluate(element => { const rect = element.getBoundingClientRect(); const region = element.querySelector('[role="region"]'); return { viewport: { width: innerWidth, height: innerHeight }, rootOverflow: Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth), dialog: { left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom, width: rect.width, height: rect.height, scrollWidth: element.scrollWidth, clientWidth: element.clientWidth }, content: region ? { scrollHeight: region.scrollHeight, clientHeight: region.clientHeight, scrollTop: region.scrollTop } : null, focusInside: element.contains(document.activeElement) }; });
const axe = await new AxeBuilder({ page }).options({ rules: { 'target-size': { enabled: true } } }).analyze(); const name = `p708-notifications-${sample.language}-${sample.theme}-${sample.width}-${sample.height}-${state}`;
await info.attach(`${name}-audit`, { body: JSON.stringify({ sample, state, boundary, controlledProjection: projection, geometry, ariaSnapshot: await page.locator('body').ariaSnapshot(), axe: { scope: 'All default-enabled rules plus target-size; no exclusions', testEngine: axe.testEngine, testEnvironment: axe.testEnvironment, toolOptions: axe.toolOptions, violations: axe.violations, incomplete: axe.incomplete, passes: axe.passes.map(rule => ({ id: rule.id, nodes: rule.nodes.length })) }, qualifications: ['Finite Chromium states only; human AT, physical devices, software keyboard and real zoom remain pending.', 'Controlled projections are UI contract fixtures, not current producer output.', 'Full-page captures may move scroll; strict action geometry is measured before capture and after explicit restoration.'] }), contentType: 'application/json' });
await info.attach(name, { body: await page.screenshot({ fullPage: true, animations: 'disabled' }), contentType: 'image/png' }); expect(geometry.rootOverflow).toBe(0); expect(geometry.dialog.left).toBeGreaterThanOrEqual(0); expect(geometry.dialog.right).toBeLessThanOrEqual(sample.width); expect(geometry.dialog.top).toBeGreaterThanOrEqual(0); expect(geometry.dialog.bottom).toBeLessThanOrEqual(sample.height); expect(geometry.dialog.scrollWidth).toBeLessThanOrEqual(geometry.dialog.clientWidth); expect(axe.violations).toEqual([]);
}
test('[P7-08 notifications] actual cancelled notice persists read/dismiss independently of task and retry keeps the same task identity', async ({ page, session }, info) => {
test.setTimeout(150_000); const seeded = await terminal(page, session.owner, info); await enter(page, session.owner); await expect(bell(page)).toHaveAccessibleName(copy('notifications')); await expect(bell(page)).toHaveAccessibleDescription(copy('notificationsUnreadCount', 'en', { count: 1 })); await open(page); await expect(panel(page).getByText(seeded.notice.title, { exact: true })).toBeVisible(); await expect(panel(page).getByRole('button', { name: copy('notificationsOpen'), exact: true })).toHaveCount(0);
const read = waitResponse(page, 'POST', `/notifications/${seeded.notice.id}/read`); await keyboard(page, panel(page).getByRole('button', { name: copy('notificationsMarkRead'), exact: true })); expect((await read).status()).toBe(204); await expect(panel(page).getByRole('button', { name: copy('notificationsMarkRead'), exact: true })).toHaveCount(0); const readBack = (await notices(page.request)).find(item => item.id === seeded.notice.id)!; expect(readBack.readAtUtc).toMatch(/Z$/); expect((await page.request.post(`${apiUrl}/notifications/${seeded.notice.id}/read`, { headers: session.owner.headers })).status()).toBe(204); expect((await notices(page.request)).find(item => item.id === seeded.notice.id)).toEqual(readBack); expect(await operation(page.request, seeded.id)).toEqual(seeded.value); await capture(page, info, desktop, 'actual-read-task-unchanged');
await close(page); await page.reload(); await expect(bell(page)).toHaveAccessibleDescription(copy('notificationsUnreadCount', 'en', { count: 0 })); await open(page); await expect(panel(page).getByText(seeded.notice.title, { exact: true })).toBeVisible(); const dismissed = waitResponse(page, 'DELETE', `/notifications/${seeded.notice.id}`); await keyboard(page, panel(page).getByRole('button', { name: copy('notificationsDismiss'), exact: true })); expect((await dismissed).status()).toBe(204); await expect(panel(page).getByText(copy('notificationsEmptyTitle'), { exact: true })).toBeVisible(); expect(await notices(page.request)).toEqual([]); expect((await page.request.delete(`${apiUrl}/notifications/${seeded.notice.id}`, { headers: session.owner.headers })).status()).toBe(204); expect(await operation(page.request, seeded.id)).toEqual(seeded.value);
const retried = await json<Operation>(await page.request.post(`${apiUrl}/operations/${seeded.id}/retry`, { headers: session.owner.headers })); utcFields(retried); expect(retried).toEqual(seeded.queued); expect((await page.request.post(`${apiUrl}/notifications/${seeded.notice.id}/read`, { headers: session.owner.headers })).status()).toBe(404); const again = await cancel(page.request, session.owner, seeded.id); expect(again.notice.id).not.toBe(seeded.notice.id); expect(again.notice.operationId).toBe(seeded.id); expect(await operation(page.request, seeded.id)).toEqual(again.value); expect(session.requests.filter(value => value.path.startsWith('/api/operations/'))).toEqual([]);
await info.attach('actual-operation-notice-lifecycle', { body: JSON.stringify({ queued: seeded.queued, cancelled: seeded.value, notice: seeded.notice, readBack, retry: retried, secondNotice: again.notice, qualification: 'Only timestamps/status of actual API snapshots are claimed; dismissedAtUtc is not exposed in NotificationDto. Retry deletes the old notice, and no worker executes this fixture.' }), contentType: 'application/json' });
});
test('[P7-08 notifications] actual owner/anonymous denials and sign-out prevent cached notice exposure across accounts', async ({ page, session, playwright }, info) => {
test.setTimeout(150_000); const seeded = await terminal(page, session.owner, info); const otherEmail = await account(session.admin, session.administrator); const otherApi = await playwright.request.newContext(); const anonymous = await playwright.request.newContext();
try { const other = await login(otherApi, otherEmail); expect(other.id).not.toBe(session.owner.id); expect(await notices(otherApi)).toEqual([]); expect(await json<{ count: number }>(await otherApi.get(`${apiUrl}/notifications/unread-count`))).toEqual({ count: 0 }); expect((await otherApi.get(`${apiUrl}/operations/${seeded.id}`)).status()).toBe(404); for (const action of ['cancel', 'retry']) expect((await otherApi.post(`${apiUrl}/operations/${seeded.id}/${action}`, { headers: other.headers })).status()).toBe(404); expect((await otherApi.post(`${apiUrl}/notifications/${seeded.notice.id}/read`, { headers: other.headers })).status()).toBe(404); expect((await otherApi.delete(`${apiUrl}/notifications/${seeded.notice.id}`, { headers: other.headers })).status()).toBe(404); for (const endpoint of ['/operations', `/operations/${seeded.id}`, '/notifications', '/notifications/unread-count']) expect((await anonymous.get(`${apiUrl}${endpoint}`)).status()).toBe(401); expect((await anonymous.post(`${apiUrl}/notifications/${seeded.notice.id}/read`)).status()).toBe(401); expect((await anonymous.delete(`${apiUrl}/notifications/${seeded.notice.id}`)).status()).toBe(401);
await enter(page, session.owner); await open(page); await expect(panel(page).getByText(seeded.notice.title, { exact: true })).toBeVisible(); await close(page); await keyboard(page, page.locator('header').getByRole('button', { name: copy('user'), exact: true })); const logout = waitResponse(page, 'POST', '/auth/logout'); await keyboard(page, page.getByRole('menuitem', { name: copy('signOut'), exact: true })); expect((await logout).status()).toBe(204); await expect(page).toHaveURL(/\/login(?:\?|$)/); await expect(page.getByText(seeded.notice.title, { exact: true })).toHaveCount(0); expect((await page.request.get(`${apiUrl}/notifications`)).status()).toBe(401);
const signedInOther = await login(page.request, otherEmail); await enter(page, signedInOther); await expect(bell(page)).toHaveAccessibleDescription(copy('notificationsUnreadCount', 'en', { count: 0 })); await open(page); await expect(panel(page).getByText(copy('notificationsEmptyTitle'), { exact: true })).toBeVisible(); await expect(panel(page).getByText(seeded.notice.title, { exact: true })).toHaveCount(0); expect(await notices(otherApi)).toEqual([]); expect(await operation(otherApi, seedQueued(info, other))).toMatchObject({ status: 'queued', taskType: 'native-offline-fixture' }); await capture(page, info, desktop, 'actual-second-owner-empty');
} finally { await otherApi.dispose(); await anonymous.dispose(); }
});
test('[P7-08 notifications] failed count stays unknown and GET Retry/close/reopen rejects a held stale response without false empty state', async ({ page, session }, info) => {
test.setTimeout(150_000); const seeded = await terminal(page, session.owner, info); let counts = 0; let loads = 0; const gate = hold(); const started = hold(); await page.route(`${apiUrl}/notifications/unread-count`, async route => { counts += 1; if (counts === 1) return route.fulfill({ status: 503, json: {} }); return route.fulfill({ response: await route.fetch() }); });
await page.route(`${apiUrl}/notifications?limit=10`, async route => { loads += 1; if (loads === 1) return route.fulfill({ status: 503, json: {} }); if (loads === 2) { started.release(); await gate.pending; return route.fulfill({ status: 200, json: [] }); } return route.fulfill({ response: await route.fetch() }); });
try { await enter(page, session.owner); await expect(bell(page)).toHaveAccessibleDescription(copy('notificationsUnreadCountUnavailable')); await open(page); const failed = panel(page).getByRole('alert').filter({ hasText: copy('notificationsLoadFailed') }); await expect(failed).toBeVisible(); await expect(panel(page).getByText(copy('notificationsEmptyTitle'), { exact: true })).toHaveCount(0); await capture(page, info, desktop, 'controlled-get503-unknown-count'); await keyboard(page, failed.getByRole('button', { name: copy('retry'), exact: true })); await started.pending; await expect(panel(page).getByRole('region', { name: copy('notificationsRecent'), exact: true })).toHaveAttribute('aria-busy', 'true'); await close(page); await open(page); await expect(panel(page).getByText(seeded.notice.title, { exact: true })).toBeVisible(); gate.release(); await expect(panel(page).getByText(seeded.notice.title, { exact: true })).toBeVisible(); await expect(panel(page).getByText(copy('notificationsEmptyTitle'), { exact: true })).toHaveCount(0); await expect(panel(page).getByRole('alert')).toHaveCount(0); expect(loads).toBe(3); expect(session.requests.filter(value => value.method !== 'GET')).toEqual([]); await close(page); await page.reload(); await expect(bell(page)).toHaveAccessibleDescription(copy('notificationsUnreadCount', 'en', { count: 1 })); expect(await operation(page.request, seeded.id)).toEqual(seeded.value);
} finally { gate.release(); }
});
test('[P7-08 notifications] failed mutation serializes activation and read-only Retry recovers in each locale without replay or task mutation', async ({ page, session }, info) => {
test.setTimeout(180_000); const seeded = await terminal(page, session.owner, info); let attempts = 0; let gate = hold(); let started = hold(); await page.route(`${apiUrl}/notifications/${seeded.notice.id}/read`, async route => { attempts += 1; started.release(); await gate.pending; return route.fulfill({ status: 503, json: {} }); });
try { for (const language of ['en', 'nb'] as const) { gate = hold(); started = hold(); const sample = { ...desktop, language, theme: language === 'nb' ? 'dark' as const : 'light' as const }; await enter(page, session.owner, sample); await open(page, language); const mark = panel(page, language).getByRole('button', { name: copy('notificationsMarkRead', language), exact: true }); await mark.focus(); await mark.evaluate(node => { (node as HTMLButtonElement).click(); (node as HTMLButtonElement).click(); }); await started.pending; await expect(mark).toBeDisabled(); await expect(panel(page, language).getByRole('button', { name: copy('notificationsDismiss', language), exact: true })).toBeDisabled(); expect(attempts).toBe(language === 'en' ? 1 : 2); gate.release(); const failed = panel(page, language).getByRole('alert').filter({ hasText: copy('notificationsActionFailed', language) }); await expect(failed).toBeVisible(); await expect(mark).toBeFocused(); const beforeReads = count(session, 'GET', '/notifications').length; const retryRead = waitResponse(page, 'GET', '/notifications'); await keyboard(page, failed.getByRole('button', { name: copy('retry', language), exact: true })); expect((await retryRead).status()).toBe(200); await expect(failed).toHaveCount(0); await expect(panel(page, language).getByRole('button', { name: copy('notificationsMarkRead', language), exact: true })).toBeVisible(); expect(count(session, 'GET', '/notifications')).toHaveLength(beforeReads + 1); expect(attempts).toBe(language === 'en' ? 1 : 2); expect(await panel(page, language).evaluate(element => element.contains(document.activeElement))).toBe(true); expect((await notices(page.request)).find(item => item.id === seeded.notice.id)).toEqual(seeded.notice); expect(await operation(page.request, seeded.id)).toEqual(seeded.value); await capture(page, info, sample, 'controlled-action503-get-only-recovery'); await close(page, language); }
expect(count(session, 'POST', `/notifications/${seeded.notice.id}/read`)).toHaveLength(2); expect(session.requests.filter(value => value.method === 'DELETE' || value.path.startsWith('/api/operations/'))).toEqual([]);
} finally { gate.release(); }
});
test('[P7-08 notifications] labelled internal-link GET projection respects existing dirty-workspace Cancel before explicit navigation', async ({ page, session }, info) => {
test.setTimeout(150_000); const seeded = await terminal(page, session.owner, info); const company = await json<{ id: number }>(await page.request.post(`${apiUrl}/companies`, { headers: session.owner.headers, data: { name: 'Notification context company — æøå' } }), 201); const job = await json<{ id: number }>(await page.request.post(`${apiUrl}/jobapplications`, { headers: session.owner.headers, data: { companyId: company.id, jobTitle: 'Notification dirty-context role', status: 'Saved' } }), 201); const original = { text: 'Persisted letter before notification navigation — æøå', source: 'manual' }; await json(await page.request.put(`${apiUrl}/jobapplications/${job.id}/cover-letter`, { headers: session.owner.headers, data: original }));
await page.route(`${apiUrl}/notifications?limit=10`, async route => { const response = await route.fetch(); const values = await response.json() as UserNotification[]; expect(values.find(item => item.id === seeded.notice.id)?.linkPath).toBeNull(); return route.fulfill({ response, json: values.map(item => item.id === seeded.notice.id ? { ...item, linkPath: '/documents' } : item) }); });
const source = `/jobs/${job.id}?section=application&sub=letter`; await enter(page, session.owner, desktop, source); const field = page.getByRole('textbox', { name: copy('jobDetailsCoverLetter'), exact: true }); await expect(field).toHaveValue(original.text); const dirty = 'Unsaved reviewed letter survives notification navigation Cancel — æøå'; await field.fill(dirty); await open(page); const read = waitResponse(page, 'POST', `/notifications/${seeded.notice.id}/read`); await keyboard(page, panel(page).getByRole('button', { name: copy('notificationsOpen'), exact: true })); expect((await read).status()).toBe(204); const confirmation = page.getByRole('dialog', { name: copy('workspaceUnsavedTitle'), exact: true }); await expect(confirmation).toBeVisible(); await keyboard(page, confirmation.getByRole('button', { name: copy('workspaceKeepEditing'), exact: true })); await expect(confirmation).toHaveCount(0); await expect(field).toHaveValue(dirty); expect(new URL(page.url()).pathname + new URL(page.url()).search).toBe(source); const stored = await json<{ text: string }>(await page.request.get(`${apiUrl}/jobapplications/${job.id}/cover-letter`)); expect(stored.text).toBe(original.text); expect(await operation(page.request, seeded.id)).toEqual(seeded.value); expect((await notices(page.request)).find(item => item.id === seeded.notice.id)?.linkPath).toBeNull();
await open(page); await capture(page, info, desktop, 'controlled-internal-link-dirty-cancel', 'Only linkPath=/documents is projected on an actual owned notice; actual producer API is asserted linkPath=null before and after. Existing unsaved-workspace guard is exercised.'); await close(page); expect(count(session, 'POST', `/notifications/${seeded.notice.id}/read`)).toHaveLength(1); expect(session.requests.filter(value => value.method !== 'GET' && !value.path.endsWith(`/${seeded.notice.id}/read`))).toEqual([]);
});
test('[P7-08 notifications] eight finite bilingual theme widths retain dialog focus scroll/footer and complete axe vectors with labelled long metadata', async ({ page, session }, info) => {
test.setTimeout(300_000); const seeded = await terminal(page, session.owner, info); const longTitle = 'Langt varsel om samfunnskritisk plattformutvikling og internasjonale samarbeidspartnere Æ Ø Å'; const longMessage = ('Kontrollert lang tekst for å prøve linjebryting, lesbarhet og vertikal rulling på små skjermer. LangUbruttReferanseForGrensekontroll0123456789ÆØÅ. ').repeat(24); const projection = 'Only title/message of one actual owned terminal notice are replaced by controlled long UI contract text. Its identity, ownership, UTC timestamps, operationId and absent link stay actual; this does not claim a long-message producer.';
await page.route(`${apiUrl}/notifications?limit=10`, async route => { const response = await route.fetch(); const values = await response.json() as UserNotification[]; expect(values.find(item => item.id === seeded.notice.id)?.linkPath).toBeNull(); return route.fulfill({ response, json: values.map(item => item.id === seeded.notice.id ? { ...item, title: longTitle, message: longMessage } : item) }); });
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(bell(page, sample.language)).toHaveAccessibleDescription(copy('notificationsUnreadCount', sample.language, { count: 1 })); await open(page, sample.language); const dialog = panel(page, sample.language); await expect(dialog.getByText(longTitle, { exact: true })).toBeVisible(); const region = dialog.getByRole('region', { name: copy('notificationsRecent', sample.language), exact: true }); expect(await region.evaluate(element => element.scrollHeight > element.clientHeight)).toBe(true); const closeControl = dialog.getByRole('button', { name: copy('close', sample.language), exact: true }); await closeControl.focus(); await page.keyboard.press('Shift+Tab'); expect(await dialog.evaluate(element => element.contains(document.activeElement))).toBe(true); const footer = dialog.getByRole('button', { name: copy('notificationsViewAll', sample.language), exact: true }); await footer.focus(); await footer.scrollIntoViewIfNeeded(); await expect(footer).toBeFocused(); const before = await footer.boundingBox(); expect(before).not.toBeNull(); expect(before!.y).toBeGreaterThanOrEqual(0); expect(before!.y + before!.height).toBeLessThanOrEqual(sample.height); await capture(page, info, sample, 'controlled-long-text-keyboard', projection); await footer.scrollIntoViewIfNeeded(); await expect(footer).toBeFocused(); const after = await footer.boundingBox(); expect(after).not.toBeNull(); expect(after!.y).toBeGreaterThanOrEqual(0); expect(after!.y + after!.height).toBeLessThanOrEqual(sample.height); await info.attach(`p708-notifications-${sample.width}-footer-bounds`, { body: JSON.stringify({ sample, beforeCapture: before, afterScrollRestoration: after, controlledProjection: projection }), contentType: 'application/json' }); if ([320, 1280].includes(sample.width)) await info.attach(`p708-notifications-${sample.width}-focused-viewport`, { body: await page.screenshot({ fullPage: false, animations: 'disabled' }), contentType: 'image/png' }); await close(page, sample.language); }
expect(session.requests.filter(value => value.method !== 'GET')).toEqual([]); expect(await operation(page.request, seeded.id)).toEqual(seeded.value); expect((await notices(page.request)).find(item => item.id === seeded.notice.id)).toEqual(seeded.notice);
});