Files
jobtrackingapp/job-tracker-ui/scripts/verify-a3-documents-inbox.mjs
T

75 lines
12 KiB
JavaScript

// Built application, synthetic document/mail/provider fixtures; no delivery or real storage.
import { chromium, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
import ts from 'typescript';
import fs from 'node:fs';
import path from 'node:path';
import assert from 'node:assert/strict';
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 out = path.resolve(import.meta.dirname, '../../docs/ui-modernisation/evidence/documents-inbox/browser'); fs.mkdirSync(out, { recursive: true });
const report = { screens: [], journeys: [], errors: [], unexpectedWrites: [], mockedWrites: [], unavailableReads: [], blockedExternal: [] };
const browser = await chromium.launch();
try {
for (const width of (process.env.A3_PILOT ? [320] : [320, 390, 768, 1440])) for (const language of (process.env.A3_PILOT ? ['en'] : ['en', 'nb'])) for (const mode of (process.env.A3_PILOT ? ['light'] : ['light', 'dark'])) {
console.log(`Checking ${width}/${language}/${mode}`);
const t = key => { assert.ok(translations[language][key], key); return translations[language][key]; };
const context = await browser.newContext({ viewport: { width, height: 1000 }, reducedMotion: 'reduce', acceptDownloads: true }); const page = await context.newPage();
await page.addInitScript(({ language, mode }) => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', mode); }, { language, mode });
page.on('pageerror', error => report.errors.push(error.message));
const document = { id: 'attachment:9', resourceId: '9', type: 'attachment', name: 'First aid certificate.txt', state: 'current', visibility: 'private', jobApplicationId: 42, jobTitle: 'Primary teacher', companyName: 'Synthetic school', jobIsDeleted: false, recordedAt: '2026-02-12T09:15:00.1234567', dateKind: 'uploaded-local', version: null, historyCount: null, attachmentCount: null, cvVariantId: null, fileType: 'text/plain', fileSize: 2048, purpose: 'certificate', useForAi: false, sourceHref: '/jobs/42?section=documents', downloadHref: '/api/attachments/download/9', publicHref: null, hasCurrentContent: true };
const cv = { ...document, id: 'cv:3', resourceId: '3', type: 'cv', name: 'Teacher CV', dateKind: 'updated-utc', recordedAt: '2026-11-03T10:00:00Z', visibility: 'private', version: 4, sourceHref: '/career/builder/3', downloadHref: null };
const mail = { id: 1, jobApplicationId: 42, companyName: 'Synthetic school', jobTitle: 'Primary teacher', from: 'Recruiter', direction: 'inbound', subject: 'Interview invitation', channel: 'Email', date: '2026-11-03T10:00:00Z', contentPreview: 'Recorded invitation', externalThreadId: 'thread-1', externalMessageId: 'message-1', provider: 'gmail', externalFrom: 'recruiter@example.test', externalTo: 'alex@example.test', labelCount: 1, attachmentCount: 0 };
let draft = null, sends = 0, documentReads = 0, mailReads = 0;
await page.route('**/*', route => {
const req = route.request(), url = new URL(req.url()), p = url.pathname;
if (!['localhost', '127.0.0.1'].includes(url.hostname)) { report.blockedExternal.push(url.origin); return route.abort(); }
if (!p.startsWith('/api/')) return route.continue();
const answer = data => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(data) });
if (req.method() === 'POST' && p === '/api/email/drafts') {
const body = req.postDataJSON(); assert.equal(body.jobApplicationId, 42); assert.equal(body.bodyText, 'Thank you. Please confirm the available interview times.');
draft = { ...body, id: '00000000-0000-4000-8000-000000000101', revision: 4, clientRequestId: '00000000-0000-4000-8000-000000000202', createdAtUtc: '2026-11-03T10:00:00Z', updatedAtUtc: '2026-11-03T10:00:00Z' };
report.mockedWrites.push({ width, language, mode, action: 'saveDraft' }); return answer(draft);
}
if (req.method() === 'POST' && p === '/api/email/send') {
const body = req.postDataJSON(); assert.equal(body.confirmed, true); assert.equal(body.clientRequestId, draft.clientRequestId); assert.equal(body.bodyText, draft.bodyText); sends++;
report.mockedWrites.push({ width, language, mode, action: 'confirmedSendFixtureUncertain' }); return answer({ attemptId: body.clientRequestId, status: 'uncertain', duplicate: false, failureCategory: 'controlled-transport-interruption' });
}
if (req.method() !== 'GET') { report.unexpectedWrites.push(p); return route.abort(); }
if (p === '/api/attachments/download/9') return route.fulfill({ status: 200, contentType: 'text/plain', body: 'Controlled certificate fixture' });
if (p === '/api/documents') { documentReads++; const rows = [document, cv].filter(item => !url.searchParams.get('q') || item.name.toLowerCase().includes(url.searchParams.get('q').toLowerCase())); return answer({ items: rows, page: 1, pageSize: 25, total: rows.length, totalPages: 1 }); }
if (p === '/api/correspondence/page') { mailReads++; return answer({ items: [mail], page: 1, pageSize: 50, total: 1, totalPages: 1 }); }
const fixtures = {
'/api/auth/config': { requireAuth: true }, '/api/auth/me': { id: 'a3-mail-owner', sessionContext: 'a3-mail-session', provider: 'local', firstName: 'Alex', displayName: 'Alex Sample', roles: [], uiLanguage: language === 'nb' ? 'nb-NO' : 'en', aiEnabled: false }, '/api/companies': [], '/api/jobapplications/reminders': [], '/api/notifications/unread-count': { count: 0 },
'/api/email/providers': [{ provider: 'gmail', displayName: 'Gmail', connected: true, address: 'alex@example.test', canRead: true, canSend: true }], '/api/email/drafts': draft ? [draft] : [], '/api/jobapplications/choices': [{ id: 42, jobTitle: 'Primary teacher', companyName: 'Synthetic school' }],
'/api/email/message': { id: 'message-1', threadId: 'thread-1', subject: 'Interview invitation', from: 'recruiter@example.test', to: 'alex@example.test', date: mail.date, snippet: 'Recorded invitation', bodyText: 'Please choose an interview time. <script>inert text</script>', labels: ['INBOX'], attachments: [] },
};
if (Object.hasOwn(fixtures, p)) return answer(fixtures[p]); report.unavailableReads.push(p); return route.fulfill({ status: 404, contentType: 'application/json', body: '{}' });
});
const scan = async state => {
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1), false, state);
const axe = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa']).analyze(); report.screens.push({ width, language, mode, state, violations: axe.violations }); assert.deepEqual(axe.violations, []);
await page.evaluate(() => window.scrollTo(0, 0)); await page.screenshot({ path: path.join(out, `${state}-${language}-${mode}-${width}.png`), fullPage: true });
};
await page.goto('http://localhost:4338/documents');
const docs = page.getByRole('region', { name: t('documentsYourDocuments'), exact: true }); await docs.getByText(document.name, { exact: true }).waitFor(); await scan('documents');
const reads = documentReads; await page.getByRole('button', { name: t('documentsJumpResults'), exact: true }).press('Enter'); assert.equal(await docs.evaluate(node => node === window.document.activeElement), true);
await docs.getByRole('button', { name: t('documentsReturnFilters'), exact: true }).click(); const search = page.getByRole('textbox', { name: t('documentsSearch'), exact: true }); await expect(search).toBeFocused(); assert.equal(documentReads, reads);
await search.fill('certificate'); await expect(docs.getByText(cv.name, { exact: true })).toHaveCount(0); await docs.getByText(document.name, { exact: true }).waitFor();
const download = page.waitForEvent('download'); await docs.getByRole('button', { name: t('documentsDownloadAttachment'), exact: true }).click(); const file = await download; assert.equal(file.suggestedFilename(), document.name); assert.equal(fs.readFileSync(await file.path(), 'utf8'), 'Controlled certificate fixture');
assert.equal(await docs.getByRole('link', { name: t('documentsOpenSource'), exact: true }).getAttribute('href'), document.sourceHref); assert.equal(await docs.locator('time').getAttribute('datetime'), document.recordedAt); await scan('document-filtered');
await page.goto('http://localhost:4338/correspondence'); const messages = page.getByRole('region', { name: t('emailInboxLinkedMessages'), exact: true }); await messages.getByText(mail.subject, { exact: true }).waitFor(); await scan('inbox');
const initialMailReads = mailReads; await page.getByRole('button', { name: t('emailInboxJumpMessages'), exact: true }).press('Enter'); await expect(messages).toBeFocused(); await messages.getByRole('button', { name: t('emailInboxReturnFilters'), exact: true }).click(); await expect(page.getByRole('textbox', { name: t('correspondenceSearch'), exact: true })).toBeFocused(); assert.equal(mailReads, initialMailReads);
await messages.getByRole('button', { name: t('emailInboxViewMessage'), exact: true }).click(); await messages.getByText('Please choose an interview time. <script>inert text</script>', { exact: true }).waitFor(); await scan('message');
await messages.getByRole('button', { name: t('emailInboxReply'), exact: true }).click(); const compose = page.getByRole('region', { name: t('emailInboxDraftTitle'), exact: true });
await compose.getByRole('textbox', { name: t('emailInboxRecipient'), exact: true }).fill('recruiter@example.test'); await compose.getByRole('textbox', { name: t('emailInboxMessage'), exact: true }).fill('Thank you. Please confirm the available interview times.');
await compose.getByRole('button', { name: t('emailInboxSaveDraft'), exact: true }).click(); await expect.poll(() => draft?.revision).toBe(4);
await compose.getByRole('button', { name: t('emailInboxReviewSend'), exact: true }).click(); const dialog = page.getByRole('dialog', { name: t('emailInboxConfirmSendTitle'), exact: true }); await dialog.waitFor(); assert.equal(sends, 0); await scan('send-confirmation');
await dialog.getByRole('button', { name: t('emailInboxSendEmail'), exact: true }).click(); await compose.getByText(t('emailInboxDeliveryUncertain'), { exact: true }).waitFor(); await expect(compose.getByRole('button', { name: t('emailInboxReviewSend'), exact: true })).toBeDisabled();
assert.equal(sends, 1); await expect(compose.getByRole('textbox', { name: t('emailInboxMessage'), exact: true })).toHaveValue(draft.bodyText); await scan('uncertain-send');
report.journeys.push({ width, language, mode, documentFilterFocusDownloadSource: 'PASS', inboxFocusPlainTextReplySaveExplicitConfirmUncertainNoRetry: 'PASS' }); await context.close();
}
assert.equal(report.screens.length, report.journeys.length * 6); assert.equal(report.mockedWrites.length, report.journeys.length * 2); assert.deepEqual(report.errors, []); assert.deepEqual(report.unexpectedWrites, []); assert.ok(report.blockedExternal.every(origin => origin === 'https://fonts.googleapis.com'));
console.log(`PASS:${report.journeys.length} document/mail journeys,${report.screens.length} axe scans/captures,${report.mockedWrites.length} fixture-only save/send requests.`);
} finally { fs.writeFileSync(path.join(out, process.env.A3_PILOT ? 'PILOT.json' : 'BROWSER.json'), JSON.stringify({ ...report, limits: ['Synthetic metadata/binary/mail/provider fixtures; not live storage, mailbox, OAuth or delivery.', 'Browser covers draft save, explicit confirmation and controlled uncertain outcome only; other recovery/conflict paths remain targeted tests.', 'No real sending, native device, final integrated security or release acceptance.'] }, null, 2)); await browser.close(); }