Files
jobtrackingapp/job-tracker-ui/e2e/phase7-settings-preferences.spec.ts
cesnimda e7dba9a2f7
CI and Deploy / test (push) Failing after 4m27s
CI and Deploy / deploy (push) Has been skipped
feat(settings): preserve drafts and connection recovery
2026-10-17 10:50:40 +02:00

150 lines
28 KiB
TypeScript

import AxeBuilder from '@axe-core/playwright';
import { expect, test as base, type APIRequestContext, type APIResponse, type Locator, type Page, type TestInfo } from '@playwright/test';
import { translations } from '../src/i18n/translations';
const apiUrl = 'http://localhost:5302/api';
const password = 'E2ePassword123!';
type Language = 'en' | 'nb';
type Headers = Record<string, string>;
type Owner = { id: string; email: string; headers: Headers };
type RequestRecord = { channel: 'browser' | 'api'; method: string; path: string; query: string; status?: number };
type Session = { owner: Owner; admin: APIRequestContext; adminHeaders: Headers; requests: RequestRecord[] };
const boundary = 'Actual isolated local API/Identity/CSRF/Free owners and authenticated notification/Rules DTO persistence are exercised. Only explicitly named GET503, PUT503 and long-address responses are controlled browser projections. Read retries never replay writes. Mailbox status remains actually disconnected; no provider connect/disconnect, IMAP credential submission, mail/model call, billing/deletion/restore, Docker, main or Phase8 action occurs. Google Fonts css2 stylesheet requests are recorded and aborted. All other nonlocal requests are rejected. Viewports and keyboard are Chromium automation; actual browser zoom, physical touch/software keyboard and human screen reader acceptance remain unverified.';
const rules = { id: 1, appliedFollowUpDays: 14, appliedGhostDays: 30, offerFollowUpDays: 7, offerGhostDays: 14, feedbackFollowUpDays: 7, feedbackGhostDays: 14 };
function copy(key: string, language: Language = 'en') { return (translations[language] as Record<string, string>)[key] ?? key; }
function hold() { let release!: () => void; const pending = new Promise<void>(resolve => { release = resolve; }); return { pending, release }; }
function browserRequests(session: Session, path: string, method?: string) { return session.requests.filter(value => value.channel === 'browser' && value.path === `/api${path}` && (!method || value.method === method)); }
async function call(records: RequestRecord[], api: APIRequestContext, method: 'GET' | 'POST' | 'PUT', path: string, headers?: Headers, data?: unknown) {
const result = await api.fetch(`${apiUrl}${path}`, { method, headers, data });
const url = new URL(result.url()); records.push({ channel: 'api', method, path: url.pathname, query: url.search, status: result.status() }); return result;
}
async function json<T>(response: APIResponse, status = 200): Promise<T> { expect(response.status(), response.url()).toBe(status); return response.json(); }
async function login(records: RequestRecord[], api: APIRequestContext, email: string): Promise<Owner> {
expect((await call(records, api, 'POST', '/auth/login', undefined, { email, password })).status()).toBe(200);
const me = await json<{ id: string; sessionContext: string }>(await call(records, api, 'GET', '/auth/me'));
const token = (await api.storageState()).cookies.find(value => value.name === 'XSRF-TOKEN'); expect(token).toBeDefined();
return { id: me.id, email, headers: { 'X-CSRF-TOKEN': token!.value, 'X-Session-Context': me.sessionContext } };
}
async function createOwner(records: RequestRecord[], admin: APIRequestContext, headers: Headers) {
const email = `p709-preferences-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`;
expect((await call(records, admin, 'POST', '/users', headers, { 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 records: RequestRecord[] = []; const forbidden: string[] = []; const fonts: string[] = []; const admin = await playwright.request.newContext();
context.on('request', request => { const url = new URL(request.url()); records.push({ channel: 'browser', method: request.method(), path: url.pathname, query: url.search }); });
context.on('response', result => { const url = new URL(result.url()); const record = [...records].reverse().find(value => value.channel === 'browser' && value.status === undefined && value.method === result.request().method() && value.path === url.pathname && value.query === url.search); if (record) record.status = result.status(); });
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') { fonts.push(url.href); return route.abort(); }
const mailbox = /^\/api\/(gmail|microsoft-graph|imap)(?:\/|$)/.test(url.pathname);
const statusRead = request.method() === 'GET' && /^\/api\/(gmail|microsoft-graph|imap)\/status$/.test(url.pathname);
const ai = /^\/api\/ai(?:\/|$)/.test(url.pathname); const accountAiRead = request.method() === 'GET' && ['/api/ai/settings', '/api/ai/usage'].includes(url.pathname);
if (mailbox && !statusRead || ai && !accountAiRead || /\/api\/(?:email\/send|billing\/(?:checkout|portal)|account-lifecycle\/delete|backup\/restore)(?:\/|$)/.test(url.pathname) || !['blob:', 'data:', 'about:'].includes(url.protocol) && (!['localhost', '127.0.0.1'].includes(url.hostname) || !['3300', '5302'].includes(url.port))) { forbidden.push(`${request.method()} ${url.origin}${url.pathname}`); return route.abort(); }
return route.continue();
});
await context.addInitScript(() => { if (!localStorage.getItem('uiLanguage')) localStorage.setItem('uiLanguage', 'en'); if (!localStorage.getItem('jobtracker.themeMode')) localStorage.setItem('jobtracker.themeMode', 'light'); });
try {
const administrator = await login(records, admin, 'e2e@example.test'); const email = await createOwner(records, admin, administrator.headers); const owner = await login(records, page.request, email);
expect((await json<{ entitlements: { ai: boolean } }>(await call(records, page.request, 'GET', '/auth/me'))).entitlements.ai).toBe(false);
await provide({ owner, admin, adminHeaders: administrator.headers, requests: records }); expect(forbidden).toEqual([]);
} finally { await info.attach('settings-preferences-boundary-and-requests', { body: JSON.stringify({ boundary, requests: records, forbiddenRequests: forbidden, abortedFontStylesheets: fonts }), contentType: 'application/json' }); await admin.dispose(); }
},
});
async function start(page: Page, path = '/settings', width = 1440) { await page.setViewportSize({ width, height: width < 768 ? 844 : 900 }); await page.emulateMedia({ reducedMotion: 'reduce' }); await page.goto(path); await expect(page.getByRole('main').getByRole('heading', { level: 1 })).toBeVisible(); }
async function keyboard(control: Locator) { await expect(control).toBeVisible(); await expect(control).toBeEnabled(); await control.focus(); await expect(control).toBeFocused(); await control.press('Enter'); }
async function choose(page: Page, label: string, language: Language = 'en') {
const main = page.getByRole('main'); const selector = main.getByRole('combobox', { name: copy('settingsNavigation', language), exact: true });
const tab = main.getByRole('tab', { name: label, exact: true });
await expect.poll(async () => await selector.isVisible() || await tab.isVisible()).toBe(true);
if (await selector.isVisible()) { await keyboard(selector); const option = page.getByRole('option', { name: label, exact: true }); await option.focus(); await option.press('Enter'); await expect(selector).toBeFocused(); }
else { await keyboard(tab); await expect(tab).toHaveAttribute('aria-selected', 'true'); }
}
function notificationGroup(page: Page, language: Language = 'en') { return page.getByRole('main').getByRole('group', { name: copy('settingsNotificationsTitle', language), exact: true }); }
function notificationCheckbox(page: Page, language: Language = 'en') { return page.getByRole('main').getByRole('checkbox', { name: copy('settingsNotificationsFollowUpReminders', language), exact: true }); }
async function toggle(control: Locator) { await expect(control).toBeEnabled(); await control.focus(); await control.press('Space'); }
async function persistedNotifications(session: Session, api: APIRequestContext) { return json<{ emailFollowUpRemindersEnabled: boolean }>(await call(session.requests, api, 'GET', '/notification-settings')); }
function response(page: Page, method: string, path: string) { return page.waitForResponse(value => value.request().method() === method && new URL(value.url()).pathname === `/api${path}`); }
async function disconnected(session: Session, api: APIRequestContext) { for (const provider of ['gmail', 'microsoft-graph', 'imap']) expect(await json<{ connected: boolean }>(await call(session.requests, api, 'GET', `/${provider}/status`))).toMatchObject({ connected: false }); }
async function capture(page: Page, info: TestInfo, name: string) {
const geometry = await page.getByRole('main').evaluate(main => {
const bounds = main.getBoundingClientRect(); const targets = Array.from(main.querySelectorAll('h1,h2,h3,p,button,input,[role="combobox"],[role="status"],.MuiFormControlLabel-root'));
return { width: innerWidth, rootOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, outside: targets.filter(node => node.getClientRects().length && getComputedStyle(node).visibility !== 'hidden').map(node => ({ text: node.textContent?.slice(0, 90), left: node.getBoundingClientRect().left, right: node.getBoundingClientRect().right })).filter(rect => rect.left < bounds.left - 1 || rect.right > Math.min(bounds.right, innerWidth) + 1) };
});
await info.attach(`${name}-reflow`, { body: JSON.stringify(geometry), contentType: 'application/json' }); expect(geometry.rootOverflow).toBeLessThanOrEqual(1); expect(geometry.outside).toEqual([]);
await info.attach(name, { body: await page.screenshot({ fullPage: true, animations: 'disabled' }), contentType: 'image/png' });
const axe = await new AxeBuilder({ page }).analyze(); await info.attach(`${name}-axe`, { body: JSON.stringify({ violations: axe.violations, incomplete: axe.incomplete, qualification: 'All enabled axe rules; raw incomplete contexts retained for root triage, not human screen-reader acceptance.' }), contentType: 'application/json' }); expect(axe.violations).toEqual([]);
}
test('[P7-09 S03 E09] notification and Rules saves stay independent, persist after reload and ignore forged owners across actual account disposal', async ({ page, playwright, session }, info) => {
test.setTimeout(150_000); expect(await persistedNotifications(session, page.request)).toEqual({ emailFollowUpRemindersEnabled: true }); await start(page);
const initialRules = response(page, 'GET', '/rules'); await choose(page, copy('settingsTabFollowUps')); expect((await initialRules).status()).toBe(200); const rule = page.getByRole('spinbutton', { name: copy('rulesAppliedFollowUpDays'), exact: true }); await expect(rule).toHaveValue('14'); await rule.fill('6');
await choose(page, copy('settingsTabNotifications')); const checkbox = notificationCheckbox(page); await expect(checkbox).toBeChecked(); await toggle(checkbox);
const notificationSave = response(page, 'PUT', '/notification-settings'); await keyboard(page.getByRole('main').getByRole('button', { name: copy('settingsNotificationsSave'), exact: true })); const saved = await notificationSave; expect(saved.request().postDataJSON()).toEqual({ emailFollowUpRemindersEnabled: false }); expect(saved.status()).toBe(200);
expect(await persistedNotifications(session, page.request)).toEqual({ emailFollowUpRemindersEnabled: false }); expect(browserRequests(session, '/rules', 'PUT')).toHaveLength(0);
await choose(page, copy('settingsTabFollowUps')); await expect(rule).toHaveValue('6'); const ruleSave = response(page, 'PUT', '/rules'); await keyboard(page.getByRole('main').getByRole('button', { name: copy('rulesSave'), exact: true })); expect((await ruleSave).status()).toBe(204);
expect(await json(await call(session.requests, page.request, 'GET', '/rules'))).toEqual({ ...rules, appliedFollowUpDays: 6 }); expect(await persistedNotifications(session, page.request)).toEqual({ emailFollowUpRemindersEnabled: false });
await page.reload(); await choose(page, copy('settingsTabNotifications')); await expect(checkbox).not.toBeChecked(); await choose(page, copy('settingsTabFollowUps')); await expect(rule).toHaveValue('6'); await rule.fill('9');
const retainedA = await playwright.request.newContext({ storageState: await page.context().storageState() }); const anonymous = await playwright.request.newContext();
try {
const emailB = await createOwner(session.requests, session.admin, session.adminHeaders); const second = await login(session.requests, page.request, emailB);
expect((await call(session.requests, page.request, 'PUT', '/notification-settings', second.headers, { emailFollowUpRemindersEnabled: false })).status()).toBe(200);
expect((await call(session.requests, page.request, 'PUT', `/notification-settings?ownerUserId=${session.owner.id}`, second.headers, { emailFollowUpRemindersEnabled: true, ownerUserId: session.owner.id })).status()).toBe(200);
expect(await persistedNotifications(session, retainedA)).toEqual({ emailFollowUpRemindersEnabled: false }); expect(await persistedNotifications(session, page.request)).toEqual({ emailFollowUpRemindersEnabled: true });
await expect(page).toHaveURL(/\/login\?sessionChanged=1$/); await expect(rule).toHaveCount(0); await page.goto('/settings'); await choose(page, copy('settingsTabNotifications')); await expect(checkbox).toBeChecked(); await expect(notificationGroup(page).getByRole('status')).toHaveCount(0);
await choose(page, copy('settingsTabFollowUps')); await expect(rule).toHaveValue('14'); expect((await call(session.requests, anonymous, 'GET', '/notification-settings')).status()).toBe(401); expect((await call(session.requests, anonymous, 'PUT', '/notification-settings', undefined, { emailFollowUpRemindersEnabled: false })).status()).toBe(401);
await info.attach('native-independent-settings-owner-readback', { body: JSON.stringify({ notificationA: false, savedRuleA: 6, disposedRuleDraftA: 9, notificationB: true, ruleB: 14, forgedOwnerIgnored: true, anonymous: [401, 401], qualification: 'All saves/readbacks above are actual local HTTP; account B retains its own session and only B changed from the forged-owner request.' }), contentType: 'application/json' });
} finally { await retainedA.dispose(); await anonymous.dispose(); }
});
test('[P7-09 S03] a held actual notification acknowledgement preserves newer edits, rejects duplicate activation and retains separate Rules and Discard baselines', async ({ page, session }, info) => {
test.setTimeout(120_000); await start(page, '/settings', 390); const initialRules = response(page, 'GET', '/rules'); await choose(page, copy('settingsTabFollowUps')); expect((await initialRules).status()).toBe(200); const rule = page.getByRole('spinbutton', { name: copy('rulesAppliedFollowUpDays'), exact: true }); await expect(rule).toHaveValue('14'); await rule.fill('5'); await choose(page, copy('settingsTabNotifications'));
const gate = hold(); let writes = 0; let acknowledged = false;
await page.route(`${apiUrl}/notification-settings`, async route => { if (route.request().method() !== 'PUT') return route.continue(); writes++; expect(route.request().postDataJSON()).toEqual({ emailFollowUpRemindersEnabled: false }); const actual = await route.fetch(); expect(actual.status()).toBe(200); acknowledged = true; await gate.pending; return route.fulfill({ response: actual }); });
try {
const checkbox = notificationCheckbox(page); await expect(checkbox).toBeChecked(); await toggle(checkbox); const saved = response(page, 'PUT', '/notification-settings'); await keyboard(page.getByRole('main').getByRole('button', { name: copy('settingsNotificationsSave'), exact: true })); await page.keyboard.press('Enter'); await expect.poll(() => acknowledged).toBe(true); expect(writes).toBe(1); await expect(page.getByRole('main').getByRole('button', { name: copy('settingsSaving'), exact: true })).toBeDisabled();
await toggle(checkbox); await choose(page, copy('settingsTabFollowUps')); await expect(rule).toHaveValue('5'); await choose(page, copy('settingsTabNotifications')); await expect(checkbox).toBeChecked();
const language = response(page, 'PUT', '/auth/preferences/language'); await keyboard(page.locator('header').getByRole('button', { name: 'Norsk', exact: true })); expect((await language).status()).toBe(204); await expect(notificationCheckbox(page, 'nb')).toBeChecked(); expect(writes).toBe(1);
gate.release(); expect((await saved).status()).toBe(200); await expect(notificationGroup(page, 'nb').getByRole('status')).toContainText(copy('assetsUnsavedChanges', 'nb')); await expect(notificationCheckbox(page, 'nb')).toBeChecked(); expect(await persistedNotifications(session, page.request)).toEqual({ emailFollowUpRemindersEnabled: false }); expect(browserRequests(session, '/rules', 'PUT')).toHaveLength(0);
await capture(page, info, 'held-notification-newer-draft-nb-390'); await keyboard(notificationGroup(page, 'nb').getByRole('button', { name: copy('assetsDiscardChanges', 'nb'), exact: true })); await expect(notificationCheckbox(page, 'nb')).not.toBeChecked(); await expect(notificationGroup(page, 'nb').getByRole('status')).toHaveCount(0);
await choose(page, copy('settingsTabFollowUps', 'nb'), 'nb'); const translatedRule = page.getByRole('spinbutton', { name: copy('rulesAppliedFollowUpDays', 'nb'), exact: true }); await expect(translatedRule).toHaveValue('5'); await keyboard(page.getByRole('main').getByRole('button', { name: copy('assetsDiscardChanges', 'nb'), exact: true })); await expect(translatedRule).toHaveValue('14'); expect(await json(await call(session.requests, page.request, 'GET', '/rules'))).toEqual(rules); expect(writes).toBe(1);
await info.attach('actual-notification-snapshot-acknowledgement', { body: JSON.stringify({ submitted: false, actualPersistedBeforeClientAck: false, newerDraft: true, notificationDiscardBaseline: false, independentRulesDraft: 5, rulesDiscardBaseline: 14, nativeWrites: writes, qualification: 'The actual successful local PUT response was held after route.fetch; no invented success DTO or backend write projection.' }), contentType: 'application/json' });
} finally { gate.release(); }
});
test('[P7-09 S03] controlled initial GET503 recovers by focused GET-only Retry and controlled save503 retains its draft for one explicit real save', async ({ page, session }, info) => {
test.setTimeout(120_000); let failRead = true; let failWrite = true;
await page.route(`${apiUrl}/notification-settings`, route => { const method = route.request().method(); if (method === 'GET' && failRead) { failRead = false; return route.fulfill({ status: 503, json: {} }); } if (method === 'PUT' && failWrite) { failWrite = false; return route.fulfill({ status: 503, json: {} }); } return route.continue(); });
await start(page, '/settings', 320); await choose(page, copy('settingsTabNotifications')); const group = notificationGroup(page); await expect(group.getByRole('alert')).toContainText(copy('settingsNotificationsLoadFailed')); await expect(notificationCheckbox(page)).toHaveCount(0); await capture(page, info, 'controlled-notification-initial-get503-en-320');
const read = response(page, 'GET', '/notification-settings'); await keyboard(group.getByRole('button', { name: copy('retry'), exact: true })); expect((await read).status()).toBe(200); await expect(notificationCheckbox(page)).toBeChecked(); await expect(group).toBeFocused(); expect(browserRequests(session, '/notification-settings', 'GET')).toHaveLength(2); expect(browserRequests(session, '/notification-settings', 'PUT')).toHaveLength(0);
await toggle(notificationCheckbox(page)); const failed = response(page, 'PUT', '/notification-settings'); await keyboard(group.getByRole('button', { name: copy('settingsNotificationsSave'), exact: true })); expect((await failed).status()).toBe(503); await expect(group.getByRole('alert')).toContainText(copy('settingsNotificationsSaveFailed')); await expect(notificationCheckbox(page)).not.toBeChecked(); expect(await persistedNotifications(session, page.request)).toEqual({ emailFollowUpRemindersEnabled: true });
await choose(page, copy('settingsTabGeneral')); await choose(page, copy('settingsTabNotifications')); await expect(group.getByRole('alert')).toContainText(copy('settingsNotificationsSaveFailed')); await expect(notificationCheckbox(page)).not.toBeChecked(); expect(browserRequests(session, '/notification-settings', 'GET')).toHaveLength(2); expect(browserRequests(session, '/notification-settings', 'PUT')).toHaveLength(1); await capture(page, info, 'controlled-notification-save503-draft-en-320');
const retried = response(page, 'PUT', '/notification-settings'); await keyboard(group.getByRole('button', { name: copy('settingsNotificationsSave'), exact: true })); expect((await retried).status()).toBe(200); await expect(group.getByRole('alert')).toHaveCount(0); expect(await persistedNotifications(session, page.request)).toEqual({ emailFollowUpRemindersEnabled: false }); expect(browserRequests(session, '/notification-settings', 'GET')).toHaveLength(2); expect(browserRequests(session, '/notification-settings', 'PUT')).toHaveLength(2); expect(browserRequests(session, '/rules', 'PUT')).toHaveLength(0); await disconnected(session, page.request);
await info.attach('controlled-notification-recovery-boundary', { body: JSON.stringify({ boundary, controlled: ['first browser GET503', 'first browser PUT503 without backend write'], actual: ['Retry GET200', 'explicit retry PUT200', 'owner readback false'], autoWriteRetries: 0, locale500OrSqliteQualification: 'RD11/RD12/RD13 are not reproduced or closed by controlled503 recovery. Every unprojected response remains a strict status assertion.' }), contentType: 'application/json' });
});
test('[P7-09 M06] a controlled Gmail status failure stays unknown while independent real providers remain disconnected and Retry only reads status', async ({ page, session }, info) => {
test.setTimeout(90_000); await disconnected(session, page.request); let failRead = true;
await page.route(`${apiUrl}/gmail/status`, route => { expect(route.request().method()).toBe('GET'); if (failRead) { failRead = false; return route.fulfill({ status: 503, json: {} }); } return route.continue(); });
await start(page, '/settings/connected-accounts', 390); const gmail = page.getByRole('region', { name: 'Gmail', exact: true }); const microsoft = page.getByRole('region', { name: 'Outlook / Microsoft 365', exact: true }); const imap = page.getByRole('region', { name: copy('emailConnectionsOtherImap'), exact: true });
await expect(gmail.getByRole('alert')).toContainText(copy('emailConnectionsStatusFailed')); await expect(gmail.getByText(copy('emailConnectionsNotConnected'), { exact: true })).toHaveCount(0); await expect(gmail.getByRole('button', { name: copy('emailConnectionsConnect'), exact: true })).toHaveCount(0); await expect(microsoft.getByText(copy('emailConnectionsNotConnected'), { exact: true })).toBeVisible(); await expect(microsoft.getByRole('button', { name: copy('emailConnectionsConnect'), exact: true })).toBeEnabled(); await expect(imap.getByRole('textbox', { name: copy('emailConnectionsImapHost'), exact: true })).toBeVisible();
await capture(page, info, 'controlled-gmail-status503-independent-providers-en-390'); const before = session.requests.filter(value => value.channel === 'browser' && value.method !== 'GET'); const read = response(page, 'GET', '/gmail/status'); await keyboard(gmail.getByRole('button', { name: copy('retry'), exact: true })); expect((await read).status()).toBe(200); await expect(gmail.getByText(copy('emailConnectionsNotConnected'), { exact: true })).toBeVisible(); await expect(gmail.getByRole('heading', { level: 3, name: 'Gmail', exact: true })).toBeFocused(); await expect(gmail.getByRole('button', { name: copy('emailConnectionsConnect'), exact: true })).toBeEnabled(); expect(browserRequests(session, '/gmail/status', 'GET')).toHaveLength(2); expect(browserRequests(session, '/microsoft-graph/status', 'GET')).toHaveLength(1); expect(browserRequests(session, '/imap/status', 'GET')).toHaveLength(1); expect(session.requests.filter(value => value.channel === 'browser' && value.method !== 'GET')).toEqual(before); await disconnected(session, page.request);
await info.attach('controlled-connections-get-only-recovery', { body: JSON.stringify({ boundary, controlled: 'First browser Gmail status503 only', recovered: 'Actual Gmail GET200 connected=false', otherProviders: 'Actual Graph/IMAP disconnected before and after', providerMutationRequests: 0, realMailboxGrants: 0 }), contentType: 'application/json' });
});
test('[P7-09 S03 M06 E08] representative English/Bokmål light/dark Settings and controlled long-address Connections reflow across seven widths', async ({ page, session }, info) => {
test.setTimeout(240_000); await start(page); await disconnected(session, page.request);
const address = `controlled-${'long-mailbox-segment-'.repeat(9)}@example.test`; const samples = [
{ width: 320, language: 'nb', mode: 'dark' }, { width: 390, language: 'en', mode: 'light' }, { width: 768, language: 'nb', mode: 'light' }, { width: 1024, language: 'en', mode: 'dark' }, { width: 1280, language: 'nb', mode: 'dark' }, { width: 1440, language: 'en', mode: 'light' }, { width: 1920, language: 'en', mode: 'dark' },
] as const;
await page.route(`${apiUrl}/gmail/status`, route => { expect(route.request().method()).toBe('GET'); return route.fulfill({ status: 200, json: { connected: true, gmailAddress: address, lastSyncedAt: null, lastSyncAttemptedAt: null, lastSyncMode: null, lastSyncSource: null, lastSyncStatus: null, lastSyncError: null } }); });
for (const sample of samples) {
expect((await call(session.requests, page.request, 'PUT', '/auth/preferences/language', session.owner.headers, { language: sample.language === 'nb' ? 'nb-NO' : 'en' })).status()).toBe(204);
await page.evaluate(value => { localStorage.setItem('uiLanguage', value.language); localStorage.setItem('jobtracker.themeMode', value.mode); }, sample); await page.setViewportSize({ width: sample.width, height: sample.width < 768 ? 844 : 900 }); await page.goto('/settings'); await expect(page.locator('html')).toHaveAttribute('data-color-scheme', sample.mode); await choose(page, copy('settingsTabNotifications', sample.language), sample.language); await expect(notificationCheckbox(page, sample.language)).toBeChecked(); await toggle(notificationCheckbox(page, sample.language)); const save = notificationGroup(page, sample.language).getByRole('button', { name: copy('settingsNotificationsSave', sample.language), exact: true }); await expect(save).toBeEnabled(); await save.focus(); await expect(save).toBeFocused(); expect(await save.evaluate(node => getComputedStyle(node).outlineStyle)).toBe('solid'); await capture(page, info, `notifications-${sample.width}-${sample.language}-${sample.mode}`);
await choose(page, copy('settingsTabGeneral', sample.language), sample.language); await keyboard(page.getByRole('main').getByRole('button', { name: copy('settingsManageConnectedAccounts', sample.language), exact: true })); await expect(page).toHaveURL(/\/settings\/connected-accounts$/); const gmail = page.getByRole('region', { name: 'Gmail', exact: true }); await expect(gmail.getByText(address, { exact: true })).toBeVisible(); await expect(gmail.getByRole('button', { name: copy('emailConnectionsDisconnect', sample.language), exact: true })).toBeEnabled(); await capture(page, info, `controlled-long-address-connections-${sample.width}-${sample.language}-${sample.mode}`);
}
await page.unroute(`${apiUrl}/gmail/status`); await disconnected(session, page.request); expect(session.requests.filter(value => value.channel === 'browser' && value.method !== 'GET')).toEqual([]);
await info.attach('finite-settings-connections-matrix', { body: JSON.stringify({ boundary, samples, controlledProjection: { endpoint: 'GET /gmail/status', connected: true, gmailAddress: address }, actualServerStatus: 'Gmail/Graph/IMAP remain disconnected; the long address is a UI-only projection and no Disconnect/Connect action is activated.', scope: 'Seven paired viewport/theme/locale samples, not the full Cartesian product. Desktop and mobile keyboard section navigation, visible focused Save, raw reflow/axe and PNG evidence only; physical/reader/zoom remain pending.' }), contentType: 'application/json' });
});