fix(ux): preserve localized save errors and current language retries
This commit is contained in:
@@ -62,6 +62,37 @@ const test = base.extend<{ owner: Owner }>({
|
||||
},
|
||||
});
|
||||
|
||||
for (const language of ['en', 'nb'] as const) {
|
||||
test(`[P6-02] ${language} company save failure stays in the dialog and retry persists the retained draft`, async ({ page, owner }, info) => {
|
||||
const t = translations[language];
|
||||
await enter(page, owner, { area: 'jobs', path: '/jobs', width: language === 'en' ? 1440 : 390, language, theme: language === 'en' ? 'light' : 'dark' });
|
||||
await page.goto('/companies');
|
||||
const edit = page.getByRole('button', { name: new RegExp(`^${t.jobTableEdit}:`) });
|
||||
await expect(edit).toHaveCount(1);
|
||||
await edit.press('Enter');
|
||||
const dialog = page.getByRole('dialog');
|
||||
const name = dialog.getByLabel(t.companiesName, { exact: true });
|
||||
const changed = `Reviewed company draft ${language} æøå`;
|
||||
await name.fill(changed);
|
||||
const save = dialog.getByRole('button', { name: t.save, exact: true });
|
||||
const fail = async (route: import('@playwright/test').Route) => route.fulfill({ status: 500, contentType: 'application/json', body: '{}' });
|
||||
await page.route('**/api/companies/*', fail);
|
||||
await save.press('Enter');
|
||||
await expect(dialog.getByRole('alert')).toHaveText(t.companiesUpdateFailed);
|
||||
await expect(name).toHaveValue(changed);
|
||||
expect((await (await page.request.get(`${apiUrl}/companies`)).json()).some((company: { name: string }) => company.name === changed)).toBe(false);
|
||||
await info.attach(`company-${language}-failed-save`, { body: await dialog.ariaSnapshot(), contentType: 'text/plain' });
|
||||
await info.attach(`company-${language}-failed-save`, { body: await page.screenshot({ animations: 'disabled' }), contentType: 'image/png' });
|
||||
await page.unroute('**/api/companies/*', fail);
|
||||
const persisted = page.waitForResponse(response => /\/api\/companies\/\d+$/.test(new URL(response.url()).pathname) && response.request().method() === 'PUT');
|
||||
await save.press('Enter');
|
||||
expect((await persisted).status()).toBe(200);
|
||||
await expect(dialog).toBeHidden();
|
||||
await page.reload();
|
||||
await expect(page.getByRole('button', { name: `${t.jobTableEdit}: ${changed}`, exact: true })).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
async function enter(page: Page, owner: Owner, sample: Sample) {
|
||||
const t = translations[sample.language];
|
||||
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers: owner.headers, data: { language: sample.language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204);
|
||||
@@ -92,6 +123,130 @@ async function enter(page: Page, owner: Owner, sample: Sample) {
|
||||
} else await expect(page.getByRole('heading', { level: 1, name: sample.area === 'admin' ? t.users : sample.area === 'settings' ? t.settings : sample.area === 'dashboard' ? t.dashboard : sample.area === 'analytics' ? t.analytics : t.jobApplications, exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
for (const language of ['en', 'nb'] as const) {
|
||||
test(`[P6-02] ${language} failed account language preference reports local scope and retry persists`, async ({ page, owner }, info) => {
|
||||
const initial = language === 'nb' ? 'en' : 'nb';
|
||||
await enter(page, owner, { area: 'settings', path: '/settings', width: language === 'nb' ? 360 : 1440, language: initial, theme: language === 'nb' ? 'dark' : 'light' });
|
||||
const fail = async (route: import('@playwright/test').Route) => route.fulfill({ status: 500, contentType: 'application/json', body: '{}' });
|
||||
await page.route('**/api/auth/preferences/language', fail);
|
||||
await page.getByRole('combobox', { name: translations[initial].settingsPreferredLanguage, exact: true }).click();
|
||||
await page.getByRole('option', { name: translations[initial][language === 'nb' ? 'settingsNorwegian' : 'settingsEnglish'], exact: true }).click();
|
||||
const alert = page.getByRole('alert').filter({ hasText: translations[language].settingsLanguageSaveFailed });
|
||||
await expect(alert).toBeVisible();
|
||||
await expect(page.locator('html')).toHaveAttribute('lang', language === 'nb' ? 'nb-NO' : 'en');
|
||||
expect(await page.evaluate(() => localStorage.getItem('uiLanguage'))).toBe(language);
|
||||
await info.attach(`language-${language}-failed-account-save`, { body: await page.screenshot({ animations: 'disabled' }), contentType: 'image/png' });
|
||||
await page.unroute('**/api/auth/preferences/language', fail);
|
||||
const saved = page.waitForResponse(response => new URL(response.url()).pathname === '/api/auth/preferences/language' && response.request().method() === 'PUT');
|
||||
await alert.getByRole('button', { name: translations[language].retry, exact: true }).press('Enter');
|
||||
expect((await saved).status()).toBe(204);
|
||||
await page.reload();
|
||||
await expect(page.getByRole('combobox', { name: translations[language].settingsPreferredLanguage, exact: true })).toHaveText(translations[language][language === 'nb' ? 'settingsNorwegian' : 'settingsEnglish']);
|
||||
});
|
||||
}
|
||||
|
||||
for (const [language, theme, width] of [['en', 'light', 1920], ['nb', 'dark', 360], ['en', 'dark', 1280], ['nb', 'light', 390]] as const) {
|
||||
test(`[P6-03] ${language}/${theme} Settings panel relationships and reflow at ${width}px`, async ({ page, owner }, info) => {
|
||||
const sample: Sample = { area: 'settings', path: '/settings', width, language, theme };
|
||||
const t = translations[language];
|
||||
await enter(page, owner, sample);
|
||||
await expect(page.getByRole('tabpanel', { name: t.settingsTabGeneral, exact: true })).toBeVisible();
|
||||
if (width >= 600) {
|
||||
const tab = page.getByRole('tab', { name: t.settingsTabNotifications, exact: true });
|
||||
await tab.press('Enter');
|
||||
await expect(tab).toHaveAttribute('aria-controls', 'settings-panel-2');
|
||||
await expect(tab).toHaveAttribute('aria-selected', 'true');
|
||||
} else {
|
||||
await page.getByRole('combobox', { name: t.settingsNavigation, exact: true }).click();
|
||||
await page.getByRole('option', { name: t.settingsTabNotifications, exact: true }).click();
|
||||
}
|
||||
await expect(page.getByRole('tabpanel', { name: t.settingsTabNotifications, exact: true })).toHaveAttribute('id', 'settings-panel-2');
|
||||
const evidence = await collect(page, info, sample, 'named-notification-panel');
|
||||
expect(evidence.geometry.pageHorizontalOverflow).toBe(0);
|
||||
});
|
||||
}
|
||||
|
||||
test('[P6-04] measured native route, long CV and complete large Board workflows', async ({ page, owner, context }, info) => {
|
||||
test.setTimeout(360_000);
|
||||
await context.addInitScript(() => {
|
||||
const observations = { longTasks: [] as number[], layoutShift: 0 };
|
||||
Object.assign(window, { phase6Performance: observations });
|
||||
if (PerformanceObserver.supportedEntryTypes.includes('longtask')) new PerformanceObserver(list => observations.longTasks.push(...list.getEntries().map(entry => entry.duration))).observe({ type: 'longtask', buffered: true });
|
||||
if (PerformanceObserver.supportedEntryTypes.includes('layout-shift')) new PerformanceObserver(list => {
|
||||
for (const entry of list.getEntries()) if (!(entry as PerformanceEntry & { hadRecentInput: boolean }).hadRecentInput) observations.layoutShift += (entry as PerformanceEntry & { value: number }).value;
|
||||
}).observe({ type: 'layout-shift', buffered: true });
|
||||
});
|
||||
const session = await context.newCDPSession(page);
|
||||
await session.send('Performance.enable');
|
||||
const observations: object[] = [];
|
||||
const measure = async (name: string, path: string, ready: () => Promise<void>) => {
|
||||
const requests: string[] = [];
|
||||
const track = (request: import('@playwright/test').Request) => { if (new URL(request.url()).pathname.startsWith('/api/')) requests.push(`${request.method()} ${new URL(request.url()).pathname}`); };
|
||||
page.on('request', track);
|
||||
const destination = new URL(path, 'http://localhost:3300');
|
||||
const endpoint = destination.pathname === '/dashboard' || destination.pathname === '/analytics' ? '/api/jobapplications/stats'
|
||||
: destination.pathname === '/jobs' ? destination.searchParams.get('view') === 'board' ? '/api/jobapplications/board' : '/api/jobapplications'
|
||||
: destination.pathname.startsWith('/career/builder/') ? `/api/cv/variants/${owner.cvId}` : `/api/jobapplications/${owner.jobId}/workspace`;
|
||||
const loaded = page.waitForResponse(response => new URL(response.url()).pathname === endpoint && response.request().method() === 'GET');
|
||||
const start = Date.now();
|
||||
await page.goto(path);
|
||||
expect((await loaded).status()).toBe(200);
|
||||
await ready();
|
||||
await page.evaluate(async () => { await document.fonts.ready; await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); });
|
||||
const readyMs = Date.now() - start;
|
||||
const browser = await page.evaluate(() => {
|
||||
const timing = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
|
||||
const telemetry = (window as Window & { phase6Performance?: { longTasks: number[]; layoutShift: number } }).phase6Performance;
|
||||
return { navigation: { responseEndMs: timing.responseEnd, domContentLoadedMs: timing.domContentLoadedEventEnd, loadMs: timing.loadEventEnd }, ...telemetry, resources: performance.getEntriesByType('resource').map(entry => ({ path: new URL(entry.name).pathname, durationMs: entry.duration, transferSize: (entry as PerformanceResourceTiming).transferSize })) };
|
||||
});
|
||||
const metrics = (await session.send('Performance.getMetrics')).metrics.filter(metric => ['JSHeapUsedSize', 'Nodes', 'Documents', 'LayoutCount', 'RecalcStyleCount', 'TaskDuration'].includes(metric.name));
|
||||
page.off('request', track);
|
||||
observations.push({ name, path, readyMs, requests, browser, metrics });
|
||||
};
|
||||
const heading = (name: string) => async () => { await expect(page.getByRole('heading', { level: 1, name, exact: true })).toBeVisible(); };
|
||||
const t = translations.en;
|
||||
await measure('initial Jobs load', '/jobs', heading(t.jobApplications));
|
||||
await measure('Dashboard', '/dashboard', async () => { await expect(page.getByRole('heading', { name: t.dashboard, exact: true })).toBeVisible(); await expect(page.getByRole('progressbar')).toHaveCount(0); });
|
||||
await measure('Analytics 6 months', '/analytics?months=6', heading(t.analytics));
|
||||
await measure('Analytics 24 months', '/analytics?months=24', heading(t.analytics));
|
||||
await measure('Job details', `/jobs/${owner.jobId}`, async () => { await expect(page.getByRole('heading', { name: /^Audit platform engineer 1/ })).toBeVisible(); });
|
||||
await measure('Long CV initialization', `/career/builder/${owner.cvId}`, async () => {
|
||||
await expect.poll(() => page.getByTitle(t.cvEditorPreviewTitle, { exact: true }).evaluate(element => (element as HTMLIFrameElement).contentDocument?.documentElement.dataset.cvPreviewReady)).toBe('true');
|
||||
await expect(page.locator('[data-cv-page]').first()).toBeVisible();
|
||||
const preview = page.getByRole('region', { name: t.cvEditorLivePreview, exact: true });
|
||||
await expect(preview).toHaveAttribute('aria-busy', 'false');
|
||||
await expect.poll(() => preview.locator('[data-cv-page] iframe').evaluateAll(nodes => nodes.length > 0 && nodes.every(node => (node as HTMLIFrameElement).contentDocument?.documentElement.dataset.cvPreviewReady === 'true'))).toBe(true);
|
||||
expect(await page.locator('[data-cv-page]').count()).toBeGreaterThan(1);
|
||||
});
|
||||
observations.push({ name: 'Long CV visible pagination', pages: await page.locator('[data-cv-page]').count() });
|
||||
const editStart = Date.now();
|
||||
await page.getByLabel(t.cvEditorHeadlineOverride, { exact: true }).fill('Measured CV-only edit');
|
||||
await expect.poll(async () => (await (await page.request.get(`${apiUrl}/cv/variants/${owner.cvId}`)).json()).settings.headline).toBe('Measured CV-only edit');
|
||||
await expect(page.getByText(t.cvEditorSaved, { exact: true })).toBeVisible();
|
||||
observations.push({ name: 'CV section editing including existing autosave debounce', persistedMs: Date.now() - editStart });
|
||||
const company = await page.request.post(`${apiUrl}/companies`, { headers: owner.headers, data: { name: 'Phase6 capacity fixture' } });
|
||||
expect(company.status()).toBe(201);
|
||||
const companyId = (await company.json()).id;
|
||||
let count = 6;
|
||||
for (const size of [200, 1000]) {
|
||||
while (count < size) {
|
||||
const response = await page.request.post(`${apiUrl}/jobapplications`, { headers: owner.headers, data: { companyId, jobTitle: `Phase6 capacity ${++count}`, status: 'Saved' } });
|
||||
expect(response.status()).toBe(201);
|
||||
}
|
||||
const started = Date.now();
|
||||
const response = await page.request.get(`${apiUrl}/jobapplications/board`);
|
||||
expect(response.status()).toBe(200);
|
||||
const jobs = await response.json();
|
||||
expect(jobs).toHaveLength(size);
|
||||
observations.push({ name: `Board ${size} API`, apiMs: Date.now() - started, bytes: (await response.body()).length });
|
||||
await measure(`Board ${size} render`, '/jobs?view=board', async () => { await expect(page.locator('[data-kanban-card-id]')).toHaveCount(size, { timeout: 60_000 }); });
|
||||
expect(await page.locator('[data-kanban-card-id]').evaluateAll(nodes => nodes.map(node => Number(node.getAttribute('data-kanban-card-id'))).sort((a, b) => a - b))).toEqual(jobs.map((job: { id: number }) => job.id).sort((a: number, b: number) => a - b));
|
||||
await measure(`Table ${size}`, '/jobs?view=table', heading(t.jobApplications));
|
||||
await measure(`Filtered Board ${size}`, '/jobs?view=board&q=Phase6%20capacity', async () => { await expect(page.locator('[data-kanban-card-id]')).toHaveCount(size - 6); });
|
||||
}
|
||||
await info.attach('phase6-performance', { body: JSON.stringify({ browserVersion: context.browser()?.version(), viewport: page.viewportSize(), environment: { platform: process.platform, application: process.env.PLAYWRIGHT_STATIC_EXPORT === 'true' ? 'production static export' : 'Next development server', prebuiltExport: process.env.PLAYWRIGHT_PREBUILT_EXPORT === 'true', serverReuseAllowed: process.env.PLAYWRIGHT_REUSE_SERVERS === 'true', database: process.env.PLAYWRIGHT_REUSE_SERVERS === 'true' ? 'reused server database not independently verified' : 'isolated synthetic SQLite from native test configuration', api: 'Release API from native test configuration' }, observations, limits: ['Single reproducible diagnostic, no production budget or device qualification', 'Hard-navigation measurements include boot/auth; no React render-count inference', 'CDP heap samples are observations, not a leak diagnosis'] }), contentType: 'application/json' });
|
||||
});
|
||||
|
||||
async function collect(page: Page, info: TestInfo, sample: Sample, detail = '') {
|
||||
await page.evaluate(async () => { await document.fonts.ready; await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); });
|
||||
const state = `${sample.area}-${sample.width}x${sample.height ?? 900}-${sample.language}-${sample.theme}${detail ? `-${detail}` : ''}`;
|
||||
|
||||
Reference in New Issue
Block a user