import { resetEnginePreferencesForTests, setEngineEnabled } from '../../src/app/engine-preferences'; import { reportContext } from '../reporting/context-report-fixture'; import { EnginesControl } from '../../src/components/EnginesControl'; // @vitest-environment jsdom import { act } from 'react'; import { createRoot } from 'react-dom/client'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { useApp } from '../../src/app/AppProvider'; import { AssessorApiClient } from '../../src/api/assessor-api-client'; import type { UnifiedResultProjection } from '../../src/domain/unified-result'; import { DashboardPanel } from '../../src/features/dashboard/DashboardPanel'; import { buildClientReportProjection } from '../../src/reporting/client-report-projection'; import { renderReportFiles } from '../../src/reporting/report-run'; import { makeSheet } from '../../src/importers/sheet-factory'; import { browserDownloadAdapter } from '../../src/platform/browser/browser-download-adapter'; import { genericProfile } from '../../src/profiles/generic/generic-profile'; vi.mock('../../src/app/AppProvider', () => { const useApp = vi.fn(); return { useApp, useOptionalApp: useApp }; }); vi.mock('../../src/features/search-filter/WorkTimeslotControl', () => ({ WorkTimeslotControl: () => Dummy timeslot })); afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); describe('Client Info report buttons', () => { it('exports separate HTML reports from every result in the selected Dummy timeslot, ignoring the Source filter', async () => { localStorage.clear(); resetEnginePreferencesForTests(); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { callback(0); return 1; }); const sheet = makeSheet('Dummy', [ ['Name', 'Status', 'Severity', 'Source: Scan'], ['Visible filtered row', 'Failed', 'High', 'Scanner A'], ['Other source row', 'Failed', 'Medium', 'Scanner B'] ], '', 'dummy.csv', { isProjectSheet: true }); sheet.analysis.records.forEach((record, index) => { record.findingOccurrenceId = index === 0 ? 'dummy-source' : 'dummy-other-source'; }); const dataset = { fileName: 'Dummy', sheets: [sheet], scanTool: 'Generic' } as never; const app = { dataset, activeSheet: sheet, activeProfile: genericProfile, reviews: {}, targetProjectId: 'dummy-project', activeScanRunIds: ['dummy-run'], activeAssessmentIds: ['dummy-assessment'], facetFilters: { 'provenance:source-scan': { values: ['Scanner A'] } }, setStatusFilter: vi.fn(), setFacetFilter: vi.fn(), setPathFindingIds: vi.fn(), resetFilters: vi.fn(), setView: vi.fn(), setActionOnly: vi.fn(), setActiveSheet: vi.fn() } as unknown as ReturnType; vi.mocked(useApp).mockReturnValue(app); const project = { id: 'dummy-project', name: 'Dummy Customer', createdAt: '', updatedAt: '', attributes: {} }; const scopes: Array<{ projectId: string; scanRunIds?: string[]; assessmentIds?: string[] }> = []; const result = { resultProjectionId: 'dummy-result', sourceKind: 'scanner-finding', sourceResultId: 'dummy-source', projectId: 'dummy-project', assessmentId: 'dummy-assessment', collectionRunId: 'dummy-run', scanRunId: 'dummy-run', importArtifactId: 'dummy-artifact-secret', sourceTool: 'Scanner B', sourceRuleId: 'DUMMY-RULE-123', title: 'Dummy technical deviation', description: 'A synthetic control failed.', recommendation: 'Apply the fix.', severityNormalized: 'high', outcomeNormalized: 'fail', isDeviation: true, categories: [], frameworkReferences: [], policyActualValue: 0, policyBaselineValue: 1 } as unknown as UnifiedResultProjection; // The server-side Report Engine renders and archives; this synthetic stand-in uses the same renderers. vi.spyOn(AssessorApiClient.prototype, 'createReportRun').mockImplementation(async (projectId, request) => { scopes.push({ projectId, scanRunIds: request.scanRunIds, assessmentIds: request.assessmentIds }); const projection = buildClientReportProjection({ project, dataset, results: [result], assessmentIds: request.assessmentIds, generatedAt: '2026-09-23T10:00:00Z' }); projection.context = reportContext(); const cluster = projection.context.clusters[0]; cluster.findingKeys = [...projection.workspaceDeviationKeys!]; cluster.resultIds.push('extra-backup-row-one', 'extra-backup-row-two', 'extra-baseline-row'); projection.overallScore = 67.5; const files = renderReportFiles(projection); return { runId: '20260923T100000Z-00000000', generatedAt: projection.generatedAt, repositoryRevision: 1, kind: request.kind, fileName: `assessor-${request.kind}-report-2026-09-23.html`, html: files[request.kind] }; }); vi.spyOn(AssessorApiClient.prototype, 'getEnvironmentSnapshot').mockResolvedValue({ version: 1, projectId: 'dummy-project', scanRunIds: ['dummy-run'], generatedAt: '2026-09-23T00:00:00Z', coverage: { observed: 0, explicitlyAbsent: 0, empty: 0, restricted: 0, incomplete: 0 }, observations: [], sections: [] }); vi.spyOn(AssessorApiClient.prototype, 'listProjectGpoBackupStatus').mockResolvedValue({ records: [] } as never); const downloads = vi.spyOn(browserDownloadAdapter, 'download').mockImplementation(() => undefined); const host = document.createElement('div'); document.body.append(host); const root = createRoot(host); await act(async () => { root.render(<>); }); const click = async (label: string) => { const button = [...host.querySelectorAll('button')].find(item => item.textContent?.trim() === label); expect(button).toBeTruthy(); await act(async () => { button!.click(); await new Promise(resolve => setTimeout(resolve, 0)); }); }; await click('Management Report'); await click('Technical Report'); expect(scopes).toHaveLength(2); expect(scopes.every(scope => scope.projectId === 'dummy-project' && scope.assessmentIds?.[0] === 'dummy-assessment' && scope.scanRunIds?.[0] === 'dummy-run')).toBe(true); expect(downloads).toHaveBeenCalledTimes(2); const management = downloads.mock.calls[0][0]; const technical = downloads.mock.calls[1][0]; expect(management.fileName).toMatch(/management-report-.*\.html$/); expect(technical.fileName).toMatch(/technical-report-.*\.html$/); expect(management.mediaType).toBe('text/html;charset=utf-8'); expect(management.text).toContain('Context-Heatmap'); expect(management.text).toContain('Handlungsfelder'); expect(management.text).toContain('Dummy Customer'); expect(management.text).not.toContain('Dummy technical deviation'); expect(management.text).toContain('ASSESSOR Overall Score: 67,5 von 100'); expect(management.text).toContain('Streng vertraulich'); const exportedReport = document.createElement('div'); exportedReport.innerHTML = management.text!; const controlTheme = [...exportedReport.querySelectorAll('.business-overview tbody tr')] .find(row => row.firstElementChild?.textContent === 'Kontrolle & Vertrauen'); expect(controlTheme?.children[2]?.textContent).toBe('2'); const chartCounts = [...exportedReport.querySelectorAll('.bar-row strong')].map(item => Number(item.textContent)); expect(chartCounts.reduce((sum, count) => sum + count, 0)).toBe(2); expect(exportedReport.querySelector('.business-overview')?.parentElement?.textContent).toContain('Gesamt: 2 Abweichungen'); expect(management.text).not.toMatch(/Scanner A|Scanner B|maester/i); expect(management.text).not.toContain('DUMMY-RULE-123'); expect(management.text).not.toContain('dummy-artifact-secret'); // Technical Report (rebuilt 2026-10-08): ASSESSOR as the single source. Every Work record deviation of the // timeslot appears once as a finding line, independent of the Source filter; no scan tool, artifact or // result identifier leaves through it. const technicalReport = document.createElement('div'); technicalReport.innerHTML = technical.text!; expect([...technicalReport.querySelectorAll('section.report-section h2')].map(item => item.textContent)) .toEqual(expect.arrayContaining(['Lagebild', 'Beziehungen (Pathfinder)', 'Problemfelder', 'Datenbasis und Datenstand'])); expect([...technicalReport.querySelectorAll('details.finding summary')].map(item => item.textContent).sort()) .toEqual(['Other source row', 'Visible filtered row']); expect(technical.text).not.toMatch(/Scanner A|Scanner B/); expect(technical.text).not.toContain('dummy-artifact-secret'); expect(technical.text).not.toContain('DUMMY-RULE-123'); await act(async () => setEngineEnabled('context', false)); await click('Management Report'); expect(scopes).toHaveLength(2); expect(host.querySelector('[role="alert"]')?.textContent).toContain('requires Context Engine'); await act(async () => setEngineEnabled('context', true)); await click('Engines'); const toggle = [...document.querySelectorAll('.engines-option')] .find(label => label.textContent === 'Report Engine')!.querySelector('input')!; await act(async () => { toggle.click(); }); const reportButtons = [...host.querySelectorAll('button')].filter(button => /^(Management|Technical) Report$/.test(button.textContent ?? '')); expect(reportButtons.every(button => button.disabled)).toBe(true); await click('Management Report'); await click('Technical Report'); expect(scopes).toHaveLength(2); await act(async () => { toggle.click(); }); expect(reportButtons.every(button => !button.disabled)).toBe(true); await click('Management Report'); expect(scopes).toHaveLength(3); await act(async () => { root.unmount(); }); host.remove(); }); });