| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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'; |
|
|
| |
| |
| |
| |
| |
| |
| |
| 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(); |
| }); |
|
|
| afterEach(() => { |
| recorder.restore(); |
| warnSpy.mockRestore(); |
| vi.useRealTimers(); |
| document.body.replaceChildren(); |
| }); |
|
|
| |
| async function clickExportPdf(): Promise<void> { |
| pdfOption().click(); |
| await vi.advanceTimersByTimeAsync(PRINT_DELAY_MS); |
| |
| |
| |
| 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 () => { |
| |
| |
| 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(); |
|
|
| |
| |
| 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 () => { |
| |
| |
| |
| 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')); |
| |
| |
| 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 () => { |
| |
| |
| |
| |
| |
| |
| 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); |
| |
| |
| 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(); |
| await clickExportPdf(); |
|
|
| expect(recorder.printed).toHaveLength(1); |
| expect(recorder.printed[0]).toContain('Baseline headline'); |
| }); |
| }); |
|
|