Files
jobtrackingapp/job-tracker-ui/e2e/browser-zoom-audit.spec.ts

162 lines
11 KiB
TypeScript

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<void>(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<Array<{ id: number; url?: string }>>;
setZoom: (id: number, factor: number) => Promise<void>;
getZoom: (id: number) => Promise<number>;
} } }).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();
}
});
}