File size: 12,066 Bytes
a20a23c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 | /**
* T2 (#5634) β the Export PDF click cycle, composed.
*
* `tests/export-pdf-report.test.mts` covers each stage in isolation:
* `buildDataReportDocument` against a fixture, and `printReportDocument`
* against an INJECTED fake document. Nothing joined them β so the wiring
* between the menu button, `exportToPDF`, the builder and the real print
* surface was unproven, and so was every state transition the button makes
* while the report is in flight.
*
* This file clicks the actual "Export PDF" option in a mounted `ExportPanel`
* and asserts what reached the printed document, with no dependency injection
* anywhere in the chain. happy-dom has no `window.print()`, so the harness
* shims one that records the document it was called on β see
* `helpers/print-recorder.mts`.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi, type Mock, type MockInstance } from 'vitest';
import { ExportPanel, type ExportData } from '@/utils/export';
import { initTestI18n, tt } from './helpers/i18n.mts';
import { installPrintRecorder, type PrintRecorder } from './helpers/print-recorder.mts';
/**
* Mirrors `printReportDocument`'s own defaults (`deps.printDelayMs ?? 300`,
* `deps.cleanupDelayMs ?? 5000`). They are inline literals over there, so
* nothing links these to them β `clickExportPdf` below therefore asserts the
* cycle actually settled, so a raised production delay fails with a message
* naming the cause instead of an opaque "printed is empty".
*/
const PRINT_DELAY_MS = 300;
const CLEANUP_DELAY_MS = 5000;
const TS = Date.parse('2026-07-25T12:00:00.000Z');
const MAIN_CSS = readFileSync(resolve(process.cwd(), 'src/styles/main.css'), 'utf8');
function exportData(overrides: Partial<ExportData> = {}): ExportData {
return {
meta: { exportedAt: '2026-07-25T12:00:00.000Z', note: 'Export reflects all active sources.' },
timestamp: TS,
news: [
{
source: 'Reuters',
title: 'Baseline headline',
link: 'https://example.com/a',
pubDate: new Date(TS),
isAlert: false,
},
],
...overrides,
} as ExportData;
}
let recorder: PrintRecorder;
let panel: ExportPanel;
let getData: Mock<() => ExportData>;
let warnSpy: MockInstance<typeof console.warn>;
const root = () => panel.getElement();
const trigger = () => root().querySelector<HTMLButtonElement>('.export-btn')!;
const menu = () => root().querySelector<HTMLElement>('.export-menu')!;
const pdfOption = () => root().querySelector<HTMLButtonElement>('.export-option[data-format="pdf"]')!;
const frames = () => document.body.querySelectorAll('iframe');
const toast = () => document.querySelector<HTMLElement>('.toast-notification');
beforeAll(async () => {
await initTestI18n();
});
beforeEach(() => {
document.body.replaceChildren();
vi.useFakeTimers();
recorder = installPrintRecorder();
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
getData = vi.fn<() => ExportData>(() => exportData());
panel = new ExportPanel(getData);
document.body.appendChild(root());
trigger().click(); // open the menu
});
afterEach(() => {
recorder.restore();
warnSpy.mockRestore();
vi.useRealTimers();
document.body.replaceChildren();
});
/** Run the whole click β build β print cycle to completion. */
async function clickExportPdf(): Promise<void> {
pdfOption().click();
await vi.advanceTimersByTimeAsync(PRINT_DELAY_MS);
// The button is restored in a `finally`, so it is false on BOTH the success
// and failure paths once the cycle settles. Still busy means we did not
// advance far enough β i.e. PRINT_DELAY_MS no longer matches production.
if (pdfOption().disabled) {
throw new Error(
`[export-pdf-cycle] cycle had not settled after ${PRINT_DELAY_MS}ms β ` +
"printReportDocument's printDelayMs default has probably changed",
);
}
}
describe('Export PDF click cycle β button to printed document', () => {
it('only exposes the formats selected by the entitlement-backed menu', () => {
panel.setAvailableFormats(['json']);
expect(root().querySelector<HTMLButtonElement>('[data-format="json"]')!.hidden).toBe(false);
expect(root().querySelector<HTMLButtonElement>('[data-format="csv"]')!.hidden).toBe(true);
expect(root().querySelector<HTMLButtonElement>('[data-format="pdf"]')!.hidden).toBe(true);
});
it('keeps unavailable options out of layout under the production CSS cascade', () => {
const productionExportRules = MAIN_CSS
.match(/\.export-option(?:\[hidden\])?\s*\{[^}]*\}/g)
?.join('\n');
expect(productionExportRules).toBeTruthy();
const style = document.createElement('style');
style.textContent = productionExportRules!;
document.head.appendChild(style);
try {
panel.setAvailableFormats(['json']);
expect(getComputedStyle(root().querySelector('[data-format="csv"]')!).display).toBe('none');
expect(getComputedStyle(root().querySelector('[data-format="pdf"]')!).display).toBe('none');
expect(getComputedStyle(root().querySelector('[data-format="json"]')!).display).toBe('block');
} finally {
style.remove();
}
});
it('prints a report built from the data the panel holds at CLICK time', async () => {
// Not construction time: the dashboard mutates constantly, and a report
// built from a stale snapshot is the whole reason getData is a callback.
getData.mockReturnValue(exportData({ news: [
{ source: 'AFP', title: 'Fresh headline at click time', link: 'https://example.com/b', pubDate: new Date(TS), isAlert: false },
] } as Partial<ExportData>));
await clickExportPdf();
expect(getData).toHaveBeenCalledTimes(1);
expect(recorder.printed).toHaveLength(1);
expect(recorder.printed[0]).toContain('Fresh headline at click time');
expect(recorder.printed[0]).toContain('AFP');
});
it('carries the report chrome through the whole chain', async () => {
await clickExportPdf();
expect(recorder.printed[0]).toContain('Baseline headline');
expect(recorder.printed[0]).toContain('Export reflects all active sources.');
});
it('escapes untrusted feed strings on the way to the printed document', async () => {
getData.mockReturnValue(exportData({ news: [
{
source: 'Reuters',
title: '<script>alert(1)</script> headline',
link: 'https://example.com/a',
pubDate: new Date(TS),
isAlert: false,
},
] } as Partial<ExportData>));
await clickExportPdf();
// The composed proof the per-stage tests could not give: no script element
// survives into the document that actually gets printed.
const printedDoc = document.createElement('div');
printedDoc.innerHTML = recorder.printed[0]!;
expect(printedDoc.querySelector('script')).toBeNull();
expect(recorder.printed[0]).toContain('<script>');
});
it('strips LLM threat annotations, and only those, on the way to the report', async () => {
// The stripper keys on threat.source === 'llm' so AI output cannot feed
// back into itself. Keyword/ML classifications are intentionally kept β
// asserting both halves keeps a blanket "drop every threat" from passing.
getData.mockReturnValue(exportData({ news: [
{
source: 'Reuters',
title: 'LLM-annotated headline',
link: 'https://example.com/a',
pubDate: new Date(TS),
isAlert: false,
threat: { level: 'high', category: 'LLM_ONLY_CATEGORY', source: 'llm' },
},
{
source: 'AFP',
title: 'Keyword-annotated headline',
link: 'https://example.com/b',
pubDate: new Date(TS),
isAlert: false,
threat: { level: 'medium', category: 'KEYWORD_CATEGORY', source: 'keyword' },
},
] } as unknown as Partial<ExportData>));
await clickExportPdf();
expect(recorder.printed[0]).toContain('LLM-annotated headline');
expect(recorder.printed[0]).not.toContain('LLM_ONLY_CATEGORY');
expect(recorder.printed[0]).toContain('KEYWORD_CATEGORY');
});
it('prints through an offscreen, AT-invisible iframe', async () => {
pdfOption().click();
const frame = frames()[0]!;
expect(frame.getAttribute('aria-hidden')).toBe('true');
expect(frame.style.position).toBe('fixed');
expect(frame.style.left).toBe('-9999px');
await vi.advanceTimersByTimeAsync(PRINT_DELAY_MS);
});
it('cleans the iframe up rather than leaking one per export', async () => {
await clickExportPdf();
await vi.advanceTimersByTimeAsync(CLEANUP_DELAY_MS);
expect(frames()).toHaveLength(0);
});
});
describe('Export PDF click cycle β button state while in flight', () => {
it('goes busy synchronously and keeps the menu open until it settles', () => {
pdfOption().click();
expect(pdfOption().disabled).toBe(true);
expect(pdfOption().classList.contains('is-exporting')).toBe(true);
expect(pdfOption().textContent).toBe(tt('components.exportGate.preparingPdf'));
// The other formats close immediately; PDF must not, or the user loses
// the only surface showing progress.
expect(menu().classList.contains('hidden')).toBe(false);
});
it('restores the label and closes the menu once the report is printed', async () => {
const label = pdfOption().textContent;
await clickExportPdf();
expect(pdfOption().disabled).toBe(false);
expect(pdfOption().classList.contains('is-exporting')).toBe(false);
expect(pdfOption().textContent).toBe(label);
expect(menu().classList.contains('hidden')).toBe(true);
});
it('produces exactly one report for a double click', async () => {
// Enforcement note: this invariant is carried by `button.disabled`, which
// stops the second event from ever being dispatched. The additional
// `if (button.disabled) return` re-entrancy guard inside
// exportPdfWithFeedback is therefore UNREACHABLE through any DOM event
// path β removing it does not turn this test red. It is defence in depth
// for a future programmatic caller, not something these tests can cover.
pdfOption().click();
pdfOption().click();
await vi.advanceTimersByTimeAsync(PRINT_DELAY_MS);
expect(getData).toHaveBeenCalledTimes(1);
expect(recorder.printed).toHaveLength(1);
});
});
describe('Export PDF click cycle β failure surfaces', () => {
it('surfaces a toast instead of a button that silently did nothing', async () => {
recorder.failNext(new Error('print blocked'));
await clickExportPdf();
expect(toast()).not.toBeNull();
expect(toast()!.textContent).toBe(tt('components.exportGate.pdfFailed'));
expect(toast()!.getAttribute('role')).toBe('status');
expect(recorder.printed).toHaveLength(0);
// The user-facing toast says nothing useful for debugging; the cause has
// to reach the console too.
expect(warnSpy).toHaveBeenCalledWith('[export] PDF report failed:', expect.any(Error));
});
it('restores the button and closes the menu after a failure', async () => {
const label = pdfOption().textContent;
recorder.failNext(new Error('print blocked'));
await clickExportPdf();
expect(pdfOption().disabled).toBe(false);
expect(pdfOption().classList.contains('is-exporting')).toBe(false);
expect(pdfOption().textContent).toBe(label);
expect(menu().classList.contains('hidden')).toBe(true);
});
it('does not leak the iframe when printing throws', async () => {
recorder.failNext(new Error('print blocked'));
await clickExportPdf();
expect(frames()).toHaveLength(0);
});
it('leaves the export usable: a retry after a failure still prints', async () => {
recorder.failNext(new Error('print blocked'));
await clickExportPdf();
trigger().click(); // reopen
await clickExportPdf();
expect(recorder.printed).toHaveLength(1);
expect(recorder.printed[0]).toContain('Baseline headline');
});
});
|