import { expect, test, type APIRequestContext, type Page } from '@playwright/test'; import fs from 'node:fs/promises'; import { translations } from '../src/i18n/translations'; const apiUrl = 'http://localhost:5302/api'; const password = 'E2ePassword123!'; async function login(api: APIRequestContext, email: string) { expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200); const meResponse = await api.get(`${apiUrl}/auth/me`); expect(meResponse.status()).toBe(200); const me = await meResponse.json(); const csrf = (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value; return { 'X-CSRF-TOKEN': csrf, 'X-Session-Context': me.sessionContext as string }; } async function geometry(page: Page) { await page.evaluate(() => document.fonts.ready); await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => resolve()))); return page.evaluate(() => { const main = document.querySelector('main'); const controls = Array.from((main ?? document.body).querySelectorAll('button,input,select,textarea,a[href],[role="button"],[role="tab"]')); const shown = controls.filter(element => { const rect = element.getBoundingClientRect(); return rect.width > 0 && rect.height > 0 && getComputedStyle(element).visibility !== 'hidden'; }); const bounds = shown.map(element => { const rect = element.getBoundingClientRect(); return { name: element.getAttribute('aria-label') ?? element.textContent?.trim().slice(0, 100) ?? '', tag: element.tagName, role: element.getAttribute('role'), x: rect.x, y: rect.y, width: rect.width, height: rect.height, outsideHorizontalViewport: rect.left < -1 || rect.right > innerWidth + 1, intentionalScroller: !!element.closest('[data-cv-page],[role="table"],table,[data-kanban-card-id]'), }; }); return { innerWidth, innerHeight, outerWidth, outerHeight, devicePixelRatio, visualScale: visualViewport?.scale, pageHorizontalOverflow: document.documentElement.scrollWidth - innerWidth, mainHorizontalOverflow: main ? main.scrollWidth - main.clientWidth : null, headings: Array.from(document.querySelectorAll('h1,h2,h3')).map(element => ({ level: element.tagName, text: element.textContent?.trim() })), controls: bounds, }; }); } for (const [language, mode] of [['en', 'light'], ['nb', 'dark']] as const) { test(`[P5-15-02 E08 C06] actual browser zoom ${language}/${mode}: collect 100/200/400 percent reflow without changing document geometry`, async ({ playwright }, info) => { test.setTimeout(240_000); const extension = info.outputPath('local-zoom-extension'); await fs.mkdir(extension, { recursive: true }); await fs.writeFile(`${extension}/manifest.json`, JSON.stringify({ manifest_version: 3, name: 'Local audit browser zoom', version: '1.0', permissions: ['tabs'], host_permissions: ['http://localhost:3300/*'], background: { service_worker: 'background.js' }, })); await fs.writeFile(`${extension}/background.js`, 'globalThis.auditZoomReady = true;'); const context = await playwright.chromium.launchPersistentContext(info.outputPath('local-browser-profile'), { channel: 'chromium', headless: true, viewport: null, deviceScaleFactor: undefined, args: ['--window-size=1280,900', `--disable-extensions-except=${extension}`, `--load-extension=${extension}`], }); const admin = await playwright.request.newContext(); const records: unknown[] = []; try { await context.route('**/*', route => { const url = new URL(route.request().url()); return ['localhost', '127.0.0.1'].includes(url.hostname) || ['data:', 'blob:', 'about:'].includes(url.protocol) ? route.continue() : route.abort(); }); await context.addInitScript(({ language, mode }) => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', mode); }, { language, mode }); const page = await context.newPage(); await page.emulateMedia({ reducedMotion: 'reduce' }); await page.goto('http://localhost:3300/login'); await expect(page.getByRole('heading', { name: translations[language].signInTitle, exact: true })).toBeVisible(); const worker = context.serviceWorkers()[0] || await context.waitForEvent('serviceworker'); const zoom = async (factor: number) => { const result = await worker.evaluate(async factor => { // The extension changes real tab zoom, rather than emulating a narrow viewport or pinch. const api = (globalThis as unknown as { chrome: { tabs: { query: (query: object) => Promise>; setZoom: (id: number, factor: number) => Promise; getZoom: (id: number) => Promise; } } }).chrome.tabs; const tab = (await api.query({})).find(tab => tab.url?.startsWith('http://localhost:3300/')); if (!tab) throw new Error('Local application tab unavailable'); await api.setZoom(tab.id, factor); return api.getZoom(tab.id); }, factor); expect(result).toBeCloseTo(factor, 5); await expect.poll(() => page.evaluate(() => devicePixelRatio)).toBeCloseTo(factor, 2); expect(await page.evaluate(() => visualViewport?.scale)).toBe(1); return result; }; for (const factor of [1, 2, 4]) { await zoom(factor); records.push({ area: 'Authentication', route: '/login', language, mode, zoom: factor, geometry: await geometry(page) }); } const adminHeaders = await login(admin, 'e2e@example.test'); const email = `zoom-${language}-${Date.now()}@example.test`; expect((await admin.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email, password, roles: ['Admin'] } })).status()).toBe(200); const headers = await login(page.request, email); expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers, data: { language: language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204); const profile = { version: '1', contact: { fullName: 'Åse Zoom Fixture', headline: 'Documented application workflows', location: 'Oslo' }, summary: ['Supported source summary.'], skills: ['C#', 'React'], jobs: [{ id: 'zoom-role', title: 'Engineer', company: 'Synthetic company', bullets: ['Built and maintained internal web applications.'] }] }; expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { profile } })).status()).toBe(200); const source = await (await page.request.get(`${apiUrl}/career/profile`)).json(); const company = await page.request.post(`${apiUrl}/companies`, { headers, data: { name: 'Representative Norwegian company — æøå and long title' } }); expect(company.status()).toBe(201); const companyId = (await company.json()).id; let jobId = 0; for (const status of ['Saved', 'Applied', 'Interview', 'Faglig vurdering']) { const job = await page.request.post(`${apiUrl}/jobapplications`, { headers, data: { companyId, jobTitle: `Long application title ${status}`, status } }); expect(job.status()).toBe(201); jobId = (await job.json()).id; } const cvResponse = await page.request.post(`${apiUrl}/cv/variants`, { headers, data: { name: 'Owned zoom CV', settings: { themeId: 'minimal', language } } }); expect(cvResponse.status()).toBe(200); const cv = await cvResponse.json(); const routes = [ ['Shell', '/dashboard', '/jobapplications/stats'], ['Jobs', '/jobs', '/jobapplications'], ['Board', '/jobs?view=board', '/jobapplications/board'], ['Dashboard', '/dashboard', '/jobapplications/stats'], ['Analytics', '/analytics?months=6', '/jobapplications/analytics'], ['Job workspace', `/jobs/${jobId}`, `/jobapplications/${jobId}/workspace`], ['Documents', `/jobs/${jobId}?section=documents`, `/jobapplications/${jobId}/workspace`], ['Career Profile', '/career?section=profile', '/career/profile'], ['CV Builder', `/career/builder/${cv.id}`, `/cv/variants/${cv.id}`], ['Settings', '/settings', '/notification-settings'], ['Administration', '/admin/users', '/users'], ['Correspondence', '/correspondence', '/correspondence/page'], ]; for (const [area, route, endpoint] of routes) { const loaded = page.waitForResponse(response => new URL(response.url()).pathname === `/api${endpoint}` && response.request().method() === 'GET'); await page.goto(`http://localhost:3300${route}`); expect((await loaded).status()).toBe(200); await expect(page.getByRole('main')).toBeVisible(); const heading = area === 'Correspondence' ? page.getByRole('heading', { level: 1, name: translations[language].correspondenceInboxTitle, exact: true }) : page.getByRole('heading', { level: 1 }); await expect(heading).toBeVisible(); if (area === 'CV Builder') await expect(page.getByLabel(translations[language].cvEditorName, { exact: true })).toHaveValue(cv.name); for (const factor of [1, 2, 4]) { await zoom(factor); const metrics = await geometry(page); records.push({ area, route, language, mode, zoom: factor, geometry: metrics }); if (factor === 4 && ['Jobs', 'CV Builder', 'Settings'].includes(area)) { const path = info.outputPath(`zoom-${language}-${mode}-${area.replaceAll(' ', '-').toLowerCase()}-400.png`); await page.screenshot({ path, animations: 'disabled' }); await info.attach(`zoom-${area}-400`, { path, contentType: 'image/png' }); } } await zoom(1); // User stylesheet simulation for WCAG 1.4.12, explicitly separate from browser text-size preferences. await page.addStyleTag({ content: '* { line-height: 1.5 !important; letter-spacing: .12em !important; word-spacing: .16em !important; } p { margin-bottom: 2em !important; }' }); records.push({ area, route, language, mode, zoom: 1, textSpacing: 'WCAG 1.4.12 user stylesheet simulation, iframe document unchanged', geometry: await geometry(page) }); } expect(await (await page.request.get(`${apiUrl}/career/profile`)).json()).toEqual(source); expect(await (await page.request.get(`${apiUrl}/cv/variants/${cv.id}`)).json()).toEqual(cv); } finally { await info.attach('actual-browser-zoom-observations', { body: JSON.stringify({ method: 'chrome.tabs.setZoom; viewport:null; dpr and actual zoom asserted; not human acceptance', browser: context.browser()?.version(), records }, null, 2), contentType: 'application/json' }); await admin.dispose(); await context.close(); } }); }