import { test, expect, type APIRequestContext } from '../../../../../job-tracker-ui/node_modules/@playwright/test'; import { translations, type TranslationKey } from '../../../../../job-tracker-ui/src/i18n/translations'; import fs from 'node:fs/promises'; import path from 'node:path'; const api = 'http://localhost:5302/api'; const password = 'E2ePassword123!'; async function login(request: APIRequestContext, email: string) { expect((await request.post(`${api}/auth/login`, { data: { email, password } })).status()).toBe(200); const me = await (await request.get(`${api}/auth/me`)).json(); const csrf = (await request.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value; return { 'X-CSRF-TOKEN': csrf, 'X-Session-Context': me.sessionContext as string }; } const publicRoutes: [string, TranslationKey | null][] = [ ['/', null], ['/login', 'signInTitle'], ['/register', 'createAccount'], ['/forgot-password', 'forgotPasswordTitle'], ['/reset-password', 'resetPasswordTitle'], ['/verify-email', 'verifyEmailTitle'], ['/confirm-email-change', 'confirmEmailChangeTitle'], ['/microsoft-legacy-relink', 'microsoftLegacyRelinkTitle'], ['/cv/:slug', 'publicCvTitle'], ]; const privateRoutes: [string, TranslationKey, string][] = [ ['/dashboard', 'dashboard', '/jobapplications/stats'], ['/analytics', 'analytics', '/jobapplications/stats'], ['/discover', 'discoverJobs', '/job-discovery/saved-searches'], ['/jobs', 'jobApplications', '/jobapplications'], ['/jobs/:id', 'jobDetails', '/jobapplications/:id/workspace'], ['/reminders', 'reminders', '/jobapplications/reminders'], ['/operations', 'operations', '/operations'], ['/kanban', 'jobApplications', '/jobapplications/board'], ['/companies', 'companies', '/companies'], ['/offers', 'offerComparisonTitle', '/jobapplications'], ['/correspondence', 'correspondenceInboxTitle', '/correspondence/page'], ['/correspondence/review', 'correspondenceInboxTitle', '/gmail/review-candidates'], ['/profile', 'profile', '/auth/me'], ['/applications/:id', 'jobDetails', '/jobapplications/:id/workspace'], ['/career', 'careerWorkspace', '/career/profile'], ['/career/builder', 'cvBuilder', '/cv/variants'], ['/career/builder/:id', 'cvBuilder', '/cv/variants/:cv'], ['/documents', 'documentsTitle', '/documents'], ['/admin/audit', 'auditLog', '/admin/audit'], ['/admin/users', 'users', '/users'], ['/admin/system', 'systemStatus', '/admin/system'], ['/trash', 'trash', '/jobapplications'], ['/settings', 'settings', '/notification-settings'], ['/settings/connected-accounts', 'connectedAccounts', '/gmail/status'], ]; for (const width of [320, 768, 1440]) for (const language of ['en', 'nb'] as const) for (const theme of ['light', 'dark'] as const) { test(`P9 all 33 routes ${width} ${language} ${theme}`, async ({ page, context, playwright }, info) => { test.setTimeout(360000); const t = translations[language]; const census = JSON.parse(await fs.readFile(path.resolve(__dirname, '../../p9-00/inventory.json'), 'utf8')); expect([...publicRoutes.map(row => row[0]), ...privateRoutes.map(row => row[0])].sort()).toEqual(Object.keys(census.routes).sort()); await page.setViewportSize({ width, height: 900 }); await context.addInitScript(({ language, theme }) => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', theme); }, { language, theme }); 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(); }); const samples: object[] = []; const capture = async (pattern: string, index: number, status?: number) => { await page.evaluate(async () => { await document.fonts.ready; await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); }); const state = await page.evaluate(() => ({ width: innerWidth, scrollWidth: document.documentElement.scrollWidth, title: document.title, headings: [...document.querySelectorAll('h1')].map(node => node.textContent), lang: document.documentElement.lang, effectiveTheme: document.documentElement.getAttribute('data-color-scheme'), bodyText: document.body.innerText, domElements: document.querySelectorAll('*').length })); const visibleLines = new Set(state.bodyText.split('\n').map(line => line.trim())); const englishLeakCandidates = language === 'nb' ? Object.entries(translations.en) .filter(([key, value]) => value.length > 6 && translations.nb[key as TranslationKey] !== value && visibleLines.has(value)) .map(([key, value]) => ({ key, value })) : []; samples.push({ pattern, viewport: width, language, theme, status, ...state, englishLeakCandidates }); expect.soft(state.effectiveTheme, `${pattern} applied theme`).toBe(theme); expect.soft(state.lang, `${pattern} applied locale`).toBe(language === 'nb' ? 'nb-NO' : 'en'); expect.soft(state.scrollWidth, `${pattern} root overflow`).toBeLessThanOrEqual(width + 1); expect.soft(state.bodyText, `${pattern} raw translation key`).not.toMatch(/\b(?:dashboardCustomize|documentsPageSubtitle|careerWorkspaceSubtitle)\b/); const screenshot = info.outputPath(`route-${String(index).padStart(2, '0')}-${width}-${language}-${theme}.png`); await page.screenshot({ path: screenshot, fullPage: true, animations: 'disabled' }); await info.attach(`route-${index}-${pattern}`, { path: screenshot, contentType: 'image/png' }); }; const admin = await playwright.request.newContext(); let createdId: string | undefined; let adminHeaders: Record | undefined; try { for (const [index, [pattern, key]] of publicRoutes.entries()) { await page.goto(pattern.replace(':slug', 'phase9-unpublished')); if (key) await expect(page.getByRole('heading', { name: t[key], exact: true })).toBeVisible(); else await expect(page.getByRole('heading', { level: 1 }).first()).toBeVisible(); if (pattern === '/cv/:slug') await expect(page.getByText(t.publicCvUnavailableBody, { exact: true })).toBeVisible(); await capture(pattern, index); } adminHeaders = await login(admin, 'e2e@example.test'); const email = `phase9-matrix-${width}-${language}-${theme}-${Date.now()}@example.test`; const created = await admin.post(`${api}/users`, { headers: adminHeaders, data: { email, password, roles: ['Admin'] } }); expect(created.status()).toBe(200); createdId = (await created.json()).id; const headers = await login(page.request, email); expect((await page.request.put(`${api}/auth/preferences/language`, { headers, data: { language: language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204); const company = await page.request.post(`${api}/companies`, { headers, data: { name: 'Phase 9 — Nordlys dokumenterte arbeidsflyter æøå' } }); expect(company.status()).toBe(201); const job = await page.request.post(`${api}/jobapplications`, { headers, data: { companyId: (await company.json()).id, jobTitle: 'Phase 9 reliable workflow engineer — dokumentert erfaring', status: 'Saved' } }); expect(job.status()).toBe(201); const jobId = (await job.json()).id; const cv = await page.request.post(`${api}/cv/variants`, { headers, data: { name: 'Phase 9 synthetic CV' } }); expect(cv.status()).toBe(200); const cvId = (await cv.json()).id; for (const [index, [pattern, key, endpoint]] of privateRoutes.entries()) { const url = pattern.replace(':id', pattern.startsWith('/career/builder/') ? String(cvId) : String(jobId)); const apiPath = `/api${endpoint.replace(':id', String(jobId)).replace(':cv', String(cvId))}`; const response = page.waitForResponse(response => new URL(response.url()).pathname === apiPath && response.request().method() === 'GET'); await page.goto(url); const status = (await response).status(); expect.soft(status, pattern).toBe(pattern === '/correspondence/review' ? 409 : 200); await expect(page.getByRole('heading', { level: 1, name: t[key], exact: true })).toBeVisible(); if (pattern === '/career/builder/:id') { if (width < 900) await page.getByRole('button', { name: t.cvEditorPreview, exact: true }).click(); await expect(page.getByRole('region', { name: t.cvEditorLivePreview, exact: true })).toHaveAttribute('aria-busy', 'false'); await expect(page.getByTitle(t.cvEditorPreviewPage.replace('{page}', '1'), { exact: true })).toBeVisible(); } if (pattern === '/dashboard') await expect(page.locator('[data-dashboard-widget]')).toHaveCount(9); if (pattern === '/reminders') await expect(page.getByRole('region', { name: t.remindersTitle, exact: true })).toHaveAttribute('aria-busy', 'false'); if (pattern === '/profile') await expect(page.getByLabel(t.profileUsername, { exact: true })).toHaveValue(email); if (pattern === '/admin/users' && width >= 768) await expect(page.getByText(language === 'nb' ? 'Rader per side:' : 'Rows per page:', { exact: true })).toBeVisible(); await capture(pattern, index + publicRoutes.length, status); } } finally { await info.attach('phase9-route-matrix', { body: JSON.stringify({ width, language, theme, samples, limits: 'Screenshots and DOM evidence; actual visual review must be recorded separately. No provider/device/human linguistic acceptance.' }, null, 2), contentType: 'application/json' }); if (createdId) expect((await admin.put(`${api}/users/${createdId}/roles`, { headers: adminHeaders, data: { roles: [] } })).status()).toBe(204); await admin.dispose(); } }); }