Files
jobtrackingapp/job-tracker-ui/scripts/verify-correspondence-recovery.mjs
T

128 lines
10 KiB
JavaScript

// Real exported application; intercepted synthetic API failures/writes, no mail provider.
import { chromium, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import ts from 'typescript';
const compiled = ts.transpileModule(fs.readFileSync(new URL('../src/i18n/translations.ts', import.meta.url), 'utf8'), { compilerOptions: { module: ts.ModuleKind.ESNext } }).outputText;
const { translations } = await import(`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`);
const pasted = process.env.A3_PASTED_EMAIL === '1';
const flatCorrespondence = process.env.A3_FLAT_CORRESPONDENCE === '1';
const out = path.resolve(import.meta.dirname, process.env.A3_EVIDENCE_DIR ?? `../../docs/ui-modernisation/evidence/${pasted ? 'pasted-email-recovery' : 'correspondence-recovery'}`);
fs.mkdirSync(out, { recursive: true });
const widths = flatCorrespondence ? [390, 768, 1440] : [390, 1440];
const configurations = process.env.A3_PILOT ? [[390, 'nb', 'dark']] : widths.flatMap(width => ['en', 'nb'].flatMap(language => ['light', 'dark'].map(theme => [width, language, theme])));
const report = { journeys: [], captures: [], writes: [], errors: [], unavailableReads: [], blockedExternal: [] };
const browser = await chromium.launch();
try {
for (const [width, language, theme] of configurations) {
const name = `${width}-${language}-${theme}`, copy = translations[language];
console.log(`Checking ${name}`);
const context = await browser.newContext({ viewport: { width, height: 1000 }, hasTouch: width === 390, isMobile: width === 390, reducedMotion: 'reduce' });
const page = await context.newPage();
page.on('pageerror', error => report.errors.push({ name, error: error.message }));
await page.addInitScript(({ language, theme }) => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', theme); }, { language, theme });
let reads = 0, posts = 0, release;
const held = new Promise(resolve => { release = resolve; });
const job = { id: 42, version: 7, jobTitle: 'Synthetic teacher role', companyId: 1, status: 'Applied', dateApplied: '2026-11-01', company: { id: 1, name: 'Synthetic school' }, description: 'Support learners in a synthetic teaching fixture.' };
await page.route('**/*', async route => {
const request = route.request(), url = new URL(request.url());
if (url.origin !== 'http://localhost:4338' && !(url.origin === 'http://localhost:5202' && url.pathname.startsWith('/api/'))) { report.blockedExternal.push(url.origin); return route.abort(); }
if (!url.pathname.startsWith('/api/')) return route.continue();
if (request.method() !== 'GET') {
assert.equal(request.method(), 'POST'); assert.equal(url.pathname, '/api/correspondence');
posts += 1;
const body = request.postDataJSON();
assert.deepEqual(body, pasted ? { jobApplicationId: 42, from: 'Me', channel: 'Email', subject: 'Synthetic interview', content: 'Synthetic email body', date: '2026-11-11T12:00:00.000Z', externalFrom: 'recruiter@example.test', externalTo: 'applicant@example.test' } : { jobApplicationId: 42, from: 'Me', content: 'Retain the synthetic note' });
report.writes.push({ name, attempt: posts, body, result: posts === 1 ? 'fixture500' : 'fixtureCommitted' });
if (posts === 1) return route.fulfill({ status: 500, json: {} });
assert.equal(posts, 2); await held;
return route.fulfill({ json: { id: 91 } });
}
if (url.pathname === '/api/correspondence/42') {
reads += 1;
if (reads === 1 || reads === 3) return route.fulfill({ status: 500, json: {} });
return route.fulfill({ json: reads > 3 ? [{ id: 91, jobApplicationId: 42, from: 'Me', content: pasted ? 'Synthetic email body' : 'Retain the synthetic note', date: '2026-11-11T12:00:00Z', channel: pasted ? 'Email' : 'Note' }] : [] });
}
const fixtures = {
'/api/auth/config': { requireAuth: true },
'/api/auth/me': { id: 'a3-correspondence-owner', sessionContext: 'a3-correspondence-session', firstName: 'Alex', displayName: 'Alex Sample', roles: [], uiLanguage: language === 'nb' ? 'nb-NO' : 'en' },
'/api/notifications/unread-count': { count: 0 },
'/api/companies': [job.company],
'/api/jobapplications/reminders': [],
'/api/jobapplications/42/workspace': { ...job, revision: 7, company: job.company.name, stageGroup: 'Applied', stageOrder: 1, tags: [], notes: '', applicationAnswerDraft: '', recruiterMessageDraft: '', hasJobDescription: true, cv: { variantId: null, variantName: null, themeId: null, hasTailoredCvText: false, updatedAtUtc: null }, hasCoverLetter: false, documentCount: 0, hasPortfolio: false, aiInteractionCount: 0, lastAiAtUtc: null, recentActivity: [], nextStep: null, checklistProgress: { total: 0, completed: 0, dismissed: 0, percent: 0 } },
'/api/jobapplications/42/status-suggestion': null,
'/api/gmail/status': { connected: false },
};
if (Object.hasOwn(fixtures, url.pathname)) return route.fulfill({ json: fixtures[url.pathname] });
report.unavailableReads.push(url.pathname); return route.fulfill({ status: 404, json: {} });
});
const capture = async state => {
const scan = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa']).analyze();
report.captures.push({ name, state, violations: scan.violations });
assert.deepEqual(scan.violations, []);
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
await page.screenshot({ path: path.join(out, `${name}-${state}.png`) });
};
await page.goto('http://localhost:4338/jobs/42?section=timeline&sub=correspondence');
const retry = page.getByRole('button', { name: copy.retry, exact: true });
await expect(retry).toBeVisible();
if (flatCorrespondence) await expect(page.locator('.MuiPaper-outlined')).toHaveCount(0);
const note = page.getByRole('textbox', { name: copy.correspondenceLogNoteOrMessage, exact: true });
let draft = note;
await draft.fill('Retain the synthetic note');
await capture('read-error');
const otherLanguage = language === 'nb' ? 'en' : 'nb';
await page.evaluate(value => { localStorage.setItem('uiLanguage', value); window.dispatchEvent(new StorageEvent('storage', { key: 'uiLanguage', newValue: value })); }, otherLanguage);
await expect(page.getByRole('textbox', { name: translations[otherLanguage].correspondenceLogNoteOrMessage, exact: true })).toHaveValue('Retain the synthetic note');
await expect(page.getByRole('button', { name: translations[otherLanguage].retry, exact: true })).toBeVisible();
await expect(page.getByText(copy.correspondenceLoadFailed, { exact: true })).toHaveCount(0);
assert.equal(reads, 1); assert.equal(posts, 0);
await page.evaluate(value => { localStorage.setItem('uiLanguage', value); window.dispatchEvent(new StorageEvent('storage', { key: 'uiLanguage', newValue: value })); }, language);
await expect(retry).toBeVisible(); assert.equal(reads, 1);
await retry.press('Enter'); await expect(retry).toBeHidden();
await expect(draft).toHaveValue('Retain the synthetic note'); assert.equal(reads, 2); assert.equal(posts, 0);
let add = page.getByRole('button', { name: copy.correspondenceAdd, exact: true });
const importDialog = page.getByRole('dialog', { name: copy.correspondenceImportTitle, exact: true });
const rawEmail = 'From: recruiter@example.test\nTo: applicant@example.test\nSubject: Synthetic interview\nDate: Wed, 11 Nov 2026 12:00:00 +0000\n\nSynthetic email body';
if (pasted) {
await page.getByRole('button', { name: copy.correspondenceImportEmail, exact: true }).click();
draft = importDialog.getByRole('textbox', { name: copy.correspondencePasteEmail, exact: true });
await draft.fill(rawEmail);
add = importDialog.getByRole('button', { name: copy.correspondenceLogEmail, exact: true });
}
await add.click();
await expect(page.getByText(pasted ? copy.addJobModalImportFailed : copy.correspondenceAddFailed, { exact: true })).toBeVisible();
await expect(draft).toHaveValue(pasted ? rawEmail : 'Retain the synthetic note'); await expect(add).toBeEnabled();
await capture('write-error');
if (!pasted) await page.keyboard.press('Escape');
await add.click(); await expect(add).toBeDisabled(); await expect(draft).toBeDisabled();
await add.evaluate(button => { button.click(); button.click(); });
if (pasted) {
await expect(importDialog.getByRole('button', { name: copy.correspondenceClose, exact: true })).toBeDisabled();
await expect(importDialog.getByRole('tab', { name: copy.google, exact: true })).toBeDisabled();
await page.keyboard.press('Escape'); await expect(importDialog).toBeVisible();
}
assert.equal(posts, 2); await capture('write-pending');
release();
await expect(retry).toBeVisible();
if (pasted) await expect(importDialog).toBeHidden();
await expect(note).toBeEnabled(); await expect(note).toHaveValue(pasted ? 'Retain the synthetic note' : '');
await capture('committed-read-error');
await retry.press('Enter');
await expect(page.getByText(pasted ? 'Synthetic email body' : 'Retain the synthetic note', { exact: true })).toBeVisible();
await expect(retry).toBeHidden(); assert.equal(reads, 4); assert.equal(posts, 2);
await capture('recovered');
report.journeys.push({ name, readRetryPreservesDraft: true, rejectedWritePreservesDraft: true, pendingWriteExcludesDuplicate: true, committedReadFailureRetriesGetOnly: true, reads, posts });
await context.close();
}
assert.equal(report.journeys.length, configurations.length);
assert.deepEqual(report.errors, []); assert.deepEqual(report.unavailableReads, []);
console.log(`PASS ${report.journeys.length} recovery journeys, ${report.captures.length} accessibility captures, ${report.writes.length} intercepted write attempts`);
} finally {
fs.writeFileSync(path.join(out, process.env.A3_PILOT ? 'PILOT.json' : 'BROWSER.json'), JSON.stringify(report, null, 2));
await browser.close();
}