144 lines
35 KiB
TypeScript
144 lines
35 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 = 'Operations-page native workflows use the 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-operations-${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('operations-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 = '/operations?source=phase7-operations#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();
|
|
}
|
|
function activity(page: Page, language: Sample['language'] = 'en') { return page.getByRole('region', { name: copy('operationsActivity', language), exact: true }); }
|
|
function taskCard(page: Page, id: string, language: Sample['language'] = 'en') { return activity(page, language).locator(`article[data-operation-id="${id}"]`); }
|
|
function noticeCard(page: Page, notice: UserNotification, language: Sample['language'] = 'en') { return activity(page, language).getByRole('article', { name: notice.title, exact: true }); }
|
|
async function focusInViewport(page: Page, control: Locator) {
|
|
await expect(control).toBeVisible(); await expect(control).toBeEnabled(); await control.focus();
|
|
await control.evaluate(element => element.scrollIntoView({ block: 'center', inline: 'nearest' }));
|
|
await page.evaluate(() => new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))));
|
|
await expect(control).toBeFocused();
|
|
const rect = await control.boundingBox(); expect(rect).not.toBeNull();
|
|
const viewport = page.viewportSize()!; expect(rect!.y).toBeGreaterThanOrEqual(0); expect(rect!.y + rect!.height).toBeLessThanOrEqual(viewport.height);
|
|
expect(await control.evaluate(element => { const r = element.getBoundingClientRect(); const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2); return hit === element || !!hit && element.contains(hit); })).toBe(true);
|
|
return rect;
|
|
}
|
|
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 region = activity(page, sample.language);
|
|
const geometry = await region.evaluate(element => { const r = element.getBoundingClientRect(); const focus = document.activeElement; const f = focus?.getBoundingClientRect(); return { viewport: { width: innerWidth, height: innerHeight }, rootOverflow: Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth), region: { left: r.left, right: r.right, top: r.top, bottom: r.bottom, scrollWidth: element.scrollWidth, clientWidth: element.clientWidth }, focused: focus ? { tag: focus.tagName, text: focus.textContent, insideRegion: element.contains(focus), rect: f ? { left: f.left, right: f.right, top: f.top, bottom: f.bottom } : null } : null }; });
|
|
const axe = await new AxeBuilder({ page }).options({ rules: { 'target-size': { enabled: true } } }).analyze(); const name = `p708-operations-${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 local Chromium screenshots/geometry only; human AT, physical touch, software keyboard, zoom and production acceptance remain pending.', 'Controlled metadata is labelled explicitly and does not claim current producer output or execute an operation handler.', 'Full-page sticky captures are not viewport-obstruction evidence; focused-control geometry is recorded before and after explicit scroll 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.region.left).toBeGreaterThanOrEqual(0); expect(geometry.region.right).toBeLessThanOrEqual(sample.width); expect(geometry.region.scrollWidth).toBeLessThanOrEqual(geometry.region.clientWidth); expect(axe.violations).toEqual([]);
|
|
}
|
|
|
|
test('[P7-08 operations] actual UI cancellation creates a notice and retry preserves the task identity before a new cancellation notice', async ({ page, session }, info) => {
|
|
test.setTimeout(150_000); const id = seedQueued(info, session.owner); const queued = await operation(page.request, id); expect(queued).toMatchObject({ taskType: 'native-offline-fixture', status: 'queued', canCancel: true, canRetry: false, subjectType: null });
|
|
await enter(page, session.owner); await expect(page.getByText('native-offline-fixture', { exact: true })).toBeVisible(); await expect(taskCard(page, id)).toHaveAccessibleName('native-offline-fixture'); await expect(taskCard(page, id).getByRole('heading', { name: 'native-offline-fixture', exact: true })).toBeVisible();
|
|
const cancelledResponse = waitResponse(page, 'POST', `/operations/${id}/cancel`); await keyboard(page, page.locator('main').getByRole('button', { name: copy('cancel'), exact: true })); const cancelled = await json<Operation>(await cancelledResponse); utcFields(cancelled); expect(cancelled).toMatchObject({ id, status: 'cancelled', canCancel: false, canRetry: true }); expect(await operation(page.request, id)).toEqual(cancelled);
|
|
const firstNotice = (await notices(page.request)).find(item => item.operationId === id)!; expect(firstNotice).toMatchObject({ kind: 'operation_cancelled', operationId: id, linkPath: null, readAtUtc: null }); expect(firstNotice.createdAtUtc).toBe(cancelled.completedAtUtc);
|
|
await expect(noticeCard(page, firstNotice)).toBeVisible(); await expect(taskCard(page, id).getByRole('button', { name: copy('retry'), exact: true })).toBeEnabled(); await expect(bell(page)).toHaveAccessibleDescription(copy('notificationsUnreadCount', 'en', { count: 1 }));
|
|
await keyboard(page, bell(page)); await expect(panel(page)).toBeVisible(); await expect(panel(page).getByText(firstNotice.title, { exact: true })).toBeVisible(); await expect(panel(page).getByRole('button', { name: copy('notificationsOpen'), exact: true })).toHaveCount(0); await page.keyboard.press('Escape'); await expect(panel(page)).toHaveCount(0);
|
|
const retryResponse = waitResponse(page, 'POST', `/operations/${id}/retry`); await keyboard(page, taskCard(page, id).getByRole('button', { name: copy('retry'), exact: true })); const retried = await json<Operation>(await retryResponse); expect(retried).toEqual(queued); expect(await operation(page.request, id)).toEqual(queued); expect(await notices(page.request)).toEqual([]); await expect(noticeCard(page, firstNotice)).toHaveCount(0); await expect(bell(page)).toHaveAccessibleDescription(copy('notificationsUnreadCount', 'en', { count: 0 }));
|
|
const secondResponse = waitResponse(page, 'POST', `/operations/${id}/cancel`); await keyboard(page, taskCard(page, id).getByRole('button', { name: copy('cancel'), exact: true })); const secondCancellation = await json<Operation>(await secondResponse); const secondNotice = (await notices(page.request)).find(item => item.operationId === id)!; expect(secondNotice.id).not.toBe(firstNotice.id); expect(secondNotice).toMatchObject({ operationId: id, linkPath: null, readAtUtc: null }); expect(await operation(page.request, id)).toEqual(secondCancellation); await expect(noticeCard(page, secondNotice)).toBeVisible();
|
|
expect(count(session, 'POST', `/operations/${id}/cancel`)).toHaveLength(2); expect(count(session, 'POST', `/operations/${id}/retry`)).toHaveLength(1); expect(session.requests.filter(request => request.method !== 'GET' && request.path.startsWith('/api/notifications/'))).toEqual([]);
|
|
await capture(page, info, desktop, 'actual-ui-cancel-retry-same-id'); await info.attach('operations-actual-ui-lifecycle', { body: JSON.stringify({ queued, cancelled, firstNotice, retried, secondCancellation, secondNotice, qualification: 'No worker/handler/result is activated. Actual terminal notice linkPath stays null, so no notification Open capability is fabricated.' }), contentType: 'application/json' });
|
|
});
|
|
|
|
test('[P7-08 operations] actual read and dismiss persist through reload while the underlying cancelled task remains unchanged', async ({ page, session }, info) => {
|
|
test.setTimeout(150_000); const seeded = await terminal(page, session.owner, info); await enter(page, session.owner); const row = noticeCard(page, seeded.notice); await expect(row).toBeVisible(); await expect(row.getByRole('button', { name: copy('notificationsOpen'), exact: true })).toHaveCount(0);
|
|
const read = waitResponse(page, 'POST', `/notifications/${seeded.notice.id}/read`); await keyboard(page, row.getByRole('button', { name: copy('notificationsMarkRead'), exact: true })); expect((await read).status()).toBe(204); await expect(row.getByRole('button', { name: copy('notificationsMarkRead'), exact: true })).toHaveCount(0); const saved = (await notices(page.request)).find(item => item.id === seeded.notice.id)!; expect(saved.readAtUtc).toMatch(/Z$/); expect(await operation(page.request, seeded.id)).toEqual(seeded.value); await expect(bell(page)).toHaveAccessibleDescription(copy('notificationsUnreadCount', 'en', { count: 0 }));
|
|
await page.reload(); await expect(noticeCard(page, seeded.notice)).toBeVisible(); await expect(noticeCard(page, seeded.notice).getByRole('button', { name: copy('notificationsMarkRead'), exact: true })).toHaveCount(0); expect((await notices(page.request)).find(item => item.id === seeded.notice.id)).toEqual(saved);
|
|
const dismissed = waitResponse(page, 'DELETE', `/notifications/${seeded.notice.id}`); await keyboard(page, noticeCard(page, seeded.notice).getByRole('button', { name: copy('notificationsDismiss'), exact: true })); expect((await dismissed).status()).toBe(204); await expect(noticeCard(page, seeded.notice)).toHaveCount(0); expect(await activity(page).evaluate(element => element.contains(document.activeElement))).toBe(true); expect(await notices(page.request)).toEqual([]); expect(await operation(page.request, seeded.id)).toEqual(seeded.value);
|
|
await page.reload(); await expect(activity(page).getByText(copy('operationsNoNotifications'), { exact: true })).toBeVisible(); await expect(taskCard(page, seeded.id)).toBeVisible(); expect(await operation(page.request, seeded.id)).toEqual(seeded.value); expect(count(session, 'POST', `/notifications/${seeded.notice.id}/read`)).toHaveLength(1); expect(count(session, 'DELETE', `/notifications/${seeded.notice.id}`)).toHaveLength(1); expect(session.requests.filter(request => request.method !== 'GET' && request.path.startsWith('/api/operations/'))).toEqual([]);
|
|
await capture(page, info, desktop, 'actual-read-dismiss-task-unchanged'); await info.attach('operations-notice-persistence', { body: JSON.stringify({ taskBefore: seeded.value, noticeBefore: seeded.notice, readBack: saved, taskAfter: await operation(page.request, seeded.id), remainingNotices: await notices(page.request) }), contentType: 'application/json' });
|
|
});
|
|
|
|
test('[P7-08 operations] controlled failed read and held old poll cannot revert a committed cancellation or replay it during GET-only recovery', async ({ page, session }, info) => {
|
|
test.setTimeout(180_000); const id = seedQueued(info, session.owner); const queued = await operation(page.request, id); const gate = hold(); const started = hold(); const delivered = hold(); let loads = 0; let capturedOld: Operation[] = [];
|
|
await page.route(`${apiUrl}/operations?limit=50`, async route => { loads += 1; if (loads === 1 || loads === 4) return route.fulfill({ status: 503, json: {} }); const response = await route.fetch(); if (loads === 3) { capturedOld = await response.json() as Operation[]; expect(capturedOld.find(item => item.id === id)).toEqual(queued); started.release(); await gate.pending; try { return await route.fulfill({ response, json: capturedOld }); } finally { delivered.release(); } } return route.fulfill({ response }); });
|
|
try {
|
|
await enter(page, session.owner); const initialFailure = activity(page).getByRole('alert').filter({ hasText: copy('operationsLoadFailed') }); await expect(initialFailure).toBeVisible(); await expect(activity(page).getByText(copy('operationsEmpty'), { exact: true })).toHaveCount(0); await expect(activity(page).getByText(copy('operationsNoNotifications'), { exact: true })).toHaveCount(0);
|
|
await keyboard(page, initialFailure.getByRole('button', { name: copy('retry'), exact: true })); await expect(taskCard(page, id).getByRole('button', { name: copy('cancel'), exact: true })).toBeEnabled(); expect(loads).toBe(2); expect(session.requests.filter(request => request.method !== 'GET')).toEqual([]);
|
|
await expect.poll(() => loads, { timeout: 25_000 }).toBe(3); await started.pending; const cancellation = waitResponse(page, 'POST', `/operations/${id}/cancel`); await keyboard(page, taskCard(page, id).getByRole('button', { name: copy('cancel'), exact: true })); const committed = await json<Operation>(await cancellation); expect(committed).toMatchObject({ id, status: 'cancelled', canRetry: true });
|
|
const refreshFailure = activity(page).getByRole('alert').filter({ hasText: copy('operationsCommittedRefreshFailed') }); await expect(refreshFailure).toBeVisible(); await expect(taskCard(page, id).getByText(copy('operationsStatusCancelled'), { exact: true })).toBeVisible(); await expect(taskCard(page, id).getByRole('button', { name: copy('retry'), exact: true })).toBeDisabled();
|
|
gate.release(); await delivered.pending; await page.evaluate(() => new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); await expect(taskCard(page, id).getByText(copy('operationsStatusCancelled'), { exact: true })).toBeVisible(); expect(await operation(page.request, id)).toEqual(committed); expect(loads).toBe(4);
|
|
const languageSave = waitResponse(page, 'PUT', '/auth/preferences/language'); await keyboard(page, page.locator('header').getByRole('button', { name: 'Norsk', exact: true })); expect((await languageSave).status()).toBe(204); await expect(page.locator('html')).toHaveAttribute('lang', 'nb-NO'); const localizedFailure = activity(page, 'nb').getByRole('alert').filter({ hasText: copy('operationsCommittedRefreshFailed', 'nb') }); await expect(localizedFailure).toBeVisible(); expect(loads).toBe(4);
|
|
await keyboard(page, localizedFailure.getByRole('button', { name: copy('retry', 'nb'), exact: true })); await expect(localizedFailure).toHaveCount(0); await expect(taskCard(page, id, 'nb').getByRole('button', { name: copy('retry', 'nb'), exact: true })).toBeEnabled(); expect(loads).toBe(5); expect(count(session, 'POST', `/operations/${id}/cancel`)).toHaveLength(1); expect(count(session, 'POST', `/operations/${id}/retry`)).toHaveLength(0); expect(await activity(page, 'nb').evaluate(element => element.contains(document.activeElement))).toBe(true); expect(await operation(page.request, id)).toEqual(committed);
|
|
await capture(page, info, { ...desktop, language: 'nb' }, 'controlled-postcommit-get-only-recovery', 'Initial operations GET503; actual15s poll held with an actual queued snapshot; actual cancel POST200; follow-up operations GET503. Old queued response is released after cancellation. Recovery is GET-only and actual persisted task remains cancelled. No provider, worker, fabricated result or uncertain delivery claim.');
|
|
await info.attach('operations-controlled-race-receipt', { body: JSON.stringify({ queued, capturedOld, committed, operationLoads: loads, actualTaskAfter: await operation(page.request, id), operationWrites: session.requests.filter(request => request.method !== 'GET' && request.path.startsWith('/api/operations/')) }), contentType: 'application/json' });
|
|
} finally { gate.release(); }
|
|
});
|
|
|
|
test('[P7-08 operations] actual owner and anonymous boundaries plus sign-out prevent earlier task and notice exposure', 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 json<Operation[]>(await otherApi.get(`${apiUrl}/operations?limit=50&ownerUserId=${session.owner.id}`))).toEqual([]); expect(await notices(otherApi)).toEqual([]);
|
|
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?limit=50', `/operations/${seeded.id}`, '/notifications?limit=50', '/notifications/unread-count']) expect((await anonymous.get(`${apiUrl}${endpoint}`)).status()).toBe(401);
|
|
for (const action of ['cancel', 'retry']) expect((await anonymous.post(`${apiUrl}/operations/${seeded.id}/${action}`)).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);
|
|
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);
|
|
await enter(page, session.owner); await expect(taskCard(page, seeded.id)).toBeVisible(); await expect(noticeCard(page, seeded.notice)).toBeVisible(); 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); await expect(page.getByText('native-offline-fixture', { exact: true })).toHaveCount(0); expect((await page.request.get(`${apiUrl}/operations?limit=50`)).status()).toBe(401);
|
|
const signedInOther = await login(page.request, otherEmail); await enter(page, signedInOther); await expect(activity(page).getByText(copy('operationsEmpty'), { exact: true })).toBeVisible(); await expect(activity(page).getByText(copy('operationsNoNotifications'), { exact: true })).toBeVisible(); await expect(page.getByText(seeded.notice.title, { exact: true })).toHaveCount(0); await expect(taskCard(page, seeded.id)).toHaveCount(0); await expect(bell(page)).toHaveAccessibleDescription(copy('notificationsUnreadCount', 'en', { count: 0 })); expect(await json<Operation[]>(await page.request.get(`${apiUrl}/operations?limit=50`))).toEqual([]);
|
|
await capture(page, info, desktop, 'actual-other-owner-empty'); await info.attach('operations-auth-boundary-receipt', { body: JSON.stringify({ earlierTaskId: seeded.id, earlierNoticeId: seeded.notice.id, otherOwnerEmpty: true, actualAnonymousDenials: 'All four GET, both operation POST, notice read POST and dismiss DELETE require401', qualification: 'Real local Identity sessions/CSRF and isolated owner metadata; no authentication tokens or cookies are attached.' }), contentType: 'application/json' });
|
|
} finally { await otherApi.dispose(); await anonymous.dispose(); }
|
|
});
|
|
|
|
test('[P7-08 operations] eight finite bilingual theme widths retain long controlled metadata and reachable focused actions with complete axe vectors', async ({ page, session }, info) => {
|
|
test.setTimeout(300_000); const seeded = await terminal(page, session.owner, info); const longTitle = 'Kontrollert varsel om samfunnskritisk plattformutvikling og internasjonale samarbeidspartnere Æ Ø Å'; const longMessage = ('Kontrollert varseltekst for lesbarhet og linjebryting på små skjermer. LangUbruttReferanseForGrensekontroll0123456789ÆØÅ. ').repeat(6); const longStage = ('Kontrollert lang fremdriftstekst — ikke faktisk produsert oppgavestatus. LangUbruttOperasjonsReferanse0123456789ÆØÅ. ').repeat(6);
|
|
const projection = 'Controlled GET metadata only: one actual cancelled native-offline-fixture task keeps its unknown taskType/id/status/capability/timestamp fields, with progressStage and progressPercent=42 projected solely for layout. Its actual notice keeps id/operationId/timestamps/read state/absent link, with only title/message replaced by controlled long text. No operation handler or long-message producer is claimed.';
|
|
await page.route(`${apiUrl}/operations?limit=50`, async route => { const response = await route.fetch(); const values = await response.json() as Operation[]; expect(values.find(item => item.id === seeded.id)).toEqual(seeded.value); return route.fulfill({ response, json: values.map(item => item.id === seeded.id ? { ...item, progressStage: longStage, progressPercent: 42 } : item) }); });
|
|
await page.route(`${apiUrl}/notifications?limit=50`, async route => { const response = await route.fetch(); const values = await response.json() as UserNotification[]; expect(values.find(item => item.id === seeded.notice.id)).toEqual(seeded.notice); 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(activity(page, sample.language).getByRole('heading', { name: copy('operationsActivity', sample.language), exact: true })).toBeVisible(); const task = taskCard(page, seeded.id, sample.language); await expect(task.getByText('native-offline-fixture', { exact: true })).toBeVisible(); await expect(task.getByText(copy('operationsStatusCancelled', sample.language), { exact: true })).toBeVisible(); await expect(task.getByText(longStage, { exact: true })).toBeVisible(); await expect(task.getByRole('progressbar', { name: copy('operationsProgress', sample.language, { task: 'native-offline-fixture' }), exact: true })).toHaveAttribute('aria-valuenow', '42'); await expect(activity(page, sample.language).getByRole('article', { name: longTitle, exact: true })).toBeVisible(); await expect(activity(page, sample.language).getByRole('button', { name: copy('notificationsOpen', sample.language), exact: true })).toHaveCount(0);
|
|
const retry = task.getByRole('button', { name: copy('retry', sample.language), exact: true }); const before = await focusInViewport(page, retry); await capture(page, info, sample, 'controlled-long-metadata-keyboard', projection); const after = await focusInViewport(page, retry); await info.attach(`p708-operations-${sample.width}-focused-action-bounds`, { body: JSON.stringify({ sample, beforeCapture: before, afterScrollRestoration: after, controlledProjection: projection }), contentType: 'application/json' }); if ([320, 1280].includes(sample.width)) await info.attach(`p708-operations-${sample.width}-focused-viewport`, { body: await page.screenshot({ fullPage: false, animations: 'disabled' }), contentType: 'image/png' });
|
|
}
|
|
expect(session.requests.filter(request => request.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);
|
|
});
|