test(qa): qualify route matrix and document stale-write failure
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
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<string, string> | 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user