test(mail): qualify review recovery and draft conflict

This commit is contained in:
cesnimda
2026-11-09 22:30:10 +01:00
parent 71c927b15b
commit f79024df69
157 changed files with 3624 additions and 10 deletions
@@ -9,10 +9,12 @@ const compiled = ts.transpileModule(fs.readFileSync(new URL('../src/i18n/transla
const { translations } = await import(`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`);
const visualMatrix = process.env.A3_VISUAL_MATRIX === '1';
const labelOnly = process.env.A3_LABEL_COUNT === '1';
const mailOnly = process.env.A3_MAIL_FLAT === '1' || labelOnly;
const recoveryOnly = process.env.A3_MAIL_RECOVERY === '1';
assert.ok(!(labelOnly && recoveryOnly), 'Count and recovery are separate scopes.');
const mailOnly = process.env.A3_MAIL_FLAT === '1' || labelOnly || recoveryOnly;
const widths = process.env.A3_PILOT ? [320] : visualMatrix ? [320, 360, 390, 430, 768, 1024, 1280, 1440, 1920] : [320, 390, 768, 1440];
const expectedJourneys = widths.length * (process.env.A3_PILOT ? 1 : 4);
const out = path.resolve(import.meta.dirname, labelOnly ? '../../docs/ui-modernisation/visual-correction/evidence/mail-labels' : mailOnly ? '../../docs/ui-modernisation/visual-correction/evidence/mail-flat' : visualMatrix ? '../../docs/ui-modernisation/visual-correction/evidence/documents-inbox' : '../../docs/ui-modernisation/evidence/documents-inbox/browser'); fs.mkdirSync(out, { recursive: true });
const out = path.resolve(import.meta.dirname, recoveryOnly ? '../../docs/ui-modernisation/visual-correction/evidence/mail-recovery' : labelOnly ? '../../docs/ui-modernisation/visual-correction/evidence/mail-labels' : mailOnly ? '../../docs/ui-modernisation/visual-correction/evidence/mail-flat' : visualMatrix ? '../../docs/ui-modernisation/visual-correction/evidence/documents-inbox' : '../../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 {
@@ -26,21 +28,41 @@ try {
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;
let reviewState = 'unavailable';
const reviewThread = { threadId: 'review-thread-1', subject: 'Research technician application', latestDate: mail.date, messageCount: 2, routing: 'suggested', hasImportedMessages: false, decisionNote: '', matchedQueries: ['"Research technician"'], jobCandidates: [], messages: [] };
const suggestion = { threadId: reviewThread.threadId, companyName: 'Synthetic laboratory', suggestedJobTitle: 'Research technician', subject: reviewThread.subject, preview: 'Recorded suggestion' };
const draftText = 'Thank you. Please confirm the available interview times.';
const revisedDraft = 'Unsaved revision after the interview was rescheduled.';
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.');
const body = req.postDataJSON(); assert.equal(body.jobApplicationId, 42); assert.equal(body.bodyText, draftText);
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') {
if (recoveryOnly && draft && req.method() === 'PUT' && p === '/api/email/drafts/' + draft.id) {
const body = req.postDataJSON();
assert.equal(body.revision, draft.revision); assert.equal(body.bodyText, revisedDraft);
report.mockedWrites.push({ width, language, mode, action: 'draftConflict' });
return route.fulfill({ status: 409, contentType: 'application/problem+json', body: '{"detail":"Controlled revision conflict."}' });
}
if (!recoveryOnly && 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 (recoveryOnly && p === '/api/gmail/review-candidates') {
if (reviewState === 'unavailable') return route.fulfill({ status: 503, contentType: 'application/problem+json', body: '{"detail":"Controlled queue unavailable."}' });
const threads = reviewState === 'populated' ? [reviewThread] : [];
return answer({ queries: [], candidateThreadCount: threads.length, autoLinkThreadCount: 0, reviewThreadCount: 0, unmatchedThreadCount: 0, threads });
}
if (recoveryOnly && p === '/api/gmail/suggested-jobs') {
const items = reviewState === 'populated' ? [suggestion] : [];
return answer({ count: items.length, items });
}
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 }); }
@@ -68,6 +90,39 @@ try {
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', docs.getByText(document.name, { exact: true }));
}
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();
if (recoveryOnly) {
const review = page.getByRole('button', { name: t('emailInboxReviewSuggestions'), exact: true });
await review.press('Enter');
const panel = page.getByRole('region', { name: t('gmailReviewEmbeddedTitle'), exact: true });
const retry = panel.getByRole('button', { name: t('retry'), exact: true });
const empty = panel.getByText(t('gmailReviewNoCandidates'), { exact: true });
await expect(panel.getByText(t('gmailReviewLoadFailed'), { exact: true })).toBeVisible();
await expect(retry).toBeVisible(); await expect(empty).toHaveCount(0); await scan('review-unavailable', retry);
reviewState = 'empty'; await retry.press('Enter');
await expect(panel.getByRole('group', { name: t('gmailReviewEmbeddedTitle'), exact: true })).toBeFocused();
await expect(empty).toBeVisible(); await scan('review-empty', empty);
reviewState = 'populated'; await panel.getByRole('button', { name: t('correspondenceRefresh'), exact: true }).press('Enter');
const article = panel.getByRole('article', { name: reviewThread.subject, exact: true });
await expect(article).toContainText(suggestion.companyName);
await scan('review-suggestion', article);
await page.getByRole('button', { name: t('emailInboxLinkedMessages'), exact: true }).press('Enter');
await expect(panel).toBeHidden(); await expect(messages).toBeVisible();
await messages.getByRole('button', { name: t('emailInboxViewMessage'), exact: true }).click();
await messages.getByRole('button', { name: t('emailInboxReply'), exact: true }).click();
const compose = page.getByRole('region', { name: t('emailInboxDraftTitle'), exact: true });
const body = compose.getByRole('textbox', { name: t('emailInboxMessage'), exact: true });
const save = compose.getByRole('button', { name: t('emailInboxSaveDraft'), exact: true });
await compose.getByRole('textbox', { name: t('emailInboxRecipient'), exact: true }).fill('recruiter@example.test');
await body.fill(draftText); await save.click(); await expect.poll(() => draft?.revision).toBe(4);
const update = compose.getByRole('button', { name: t('profileSaveChanges'), exact: true });
await expect(update).toBeEnabled(); await body.fill(revisedDraft); await update.click();
const conflict = compose.getByText(t('emailInboxDraftChangedElsewhere'), { exact: true });
await expect(conflict).toBeVisible(); await expect(body).toHaveValue(revisedDraft);
assert.equal(draft.bodyText, draftText);
await scan('draft-conflict', conflict);
report.journeys.push({ width, language, mode, reviewUnavailableRetryEmptyAndSuggestion: 'PASS', revisionConflictPreservedUnsavedDraft: 'PASS', send: 'NOT RUN' });
await context.close(); continue;
}
if (labelOnly) {
const label = messages.getByText(t('emailInboxLabelCount').replace('{count}', '1'), { exact: true }); await expect(label).toBeVisible();
await scan('label-count', label);
@@ -84,7 +139,7 @@ try {
assert.ok(width > 1000 ? contextBounds.x >= contentBounds.x + contentBounds.width - 2 && Math.abs(contextBounds.y - contentBounds.y) < 2 : contextBounds.y + contextBounds.height <= contentBounds.y + 2, 'Recorded application context is right on desktop and before the body on narrow screens');
await scan('message', messageBody);
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('textbox', { name: t('emailInboxRecipient'), exact: true }).fill('recruiter@example.test'); await compose.getByRole('textbox', { name: t('emailInboxMessage'), exact: true }).fill(draftText);
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', dialog);
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();
@@ -92,5 +147,5 @@ try {
report.journeys.push({ width, language, mode, documentFilterFocusDownloadSource: mailOnly ? 'NOT RUN' : 'PASS', inboxFocusPlainTextReplySaveExplicitConfirmUncertainNoRetry: 'PASS' }); await context.close();
}
assert.equal(report.journeys.length, expectedJourneys); assert.equal(report.screens.length, report.journeys.length * (labelOnly ? 1 : mailOnly ? 4 : 6)); assert.equal(report.mockedWrites.length, labelOnly ? 0 : 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(); }
console.log(`PASS:${report.journeys.length} document/mail journeys,${report.screens.length} axe scans/captures,${report.mockedWrites.length} intercepted write 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.', recoveryOnly ? 'Review uses only fixture reads. Draft create and rejected update are intercepted; sending is blocked.' : '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(); }