128 lines
10 KiB
JavaScript
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();
|
|
}
|