// @vitest-environment jsdom import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { act, type ReactNode } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { AssessorApiClient } from '../../src/api/assessor-api-client'; import { resetEnginePreferencesForTests, setEngineEnabled } from '../../src/app/engine-preferences'; import { useApp } from '../../src/app/AppProvider'; import type { RegulatoryCorpusStatus } from '../../src/compliance/regulatory-corpus-management'; import type { RegulatoryMappingResponse } from '../../src/compliance/regulatory-mapping'; import { IsoCorpusLibrary } from '../../src/compliance/ui/IsoCorpusLibrary'; import { useRegulatoryMappingBatch } from '../../src/compliance/ui/RegulatoryMappingBatchDialog'; import { useAiOverview } from '../../src/compliance/ui/AiOverviewPanel'; import { FindingReader } from '../../src/features/finding-reader/FindingReader'; import { makeSheet } from '../../src/importers/sheet-factory'; vi.mock('../../src/app/AppProvider', () => { const useApp = vi.fn(); return { useApp, useOptionalApp: useApp }; }); // Compliance Engine switch (AGENTS.md, "Core Engine and engines"): switched off, the Finding Reader, // Result DB, Client Info AI overview and Data Hub show none of its controls and send none of its requests; switched on again, // the Finding Reader ISO panel starts closed. let host: HTMLDivElement; let root: Root; function scannerRecord() { const record = makeSheet('Dummy', [['Title', 'Status', 'Source: Scan'], ['Dummy SMB signing check', 'Fail', 'Purple Knight']], '', 'dummy.csv', { isProjectSheet: true }).analysis.records[0]; return { ...record, findingOccurrenceId: 'dummy-occurrence' }; } const mappingResponse: RegulatoryMappingResponse = { version: 1, projectId: 'dummy-project', resultProjectionId: 'scanner-finding:dummy-occurrence', generationEnabled: true, state: 'empty', decisions: [] }; async function renderReader() { await act(async () => { root.render(); await new Promise(resolve => setTimeout(resolve, 0)); }); } const isoButton = () => [...host.querySelectorAll('button')].find(button => button.textContent?.startsWith('Map ISO controls')); function BatchProbe(): ReactNode { const batch = useRegulatoryMappingBatch('dummy-project', { assessmentIds: ['dummy-assessment'], scanRunIds: [], filters: {} }); return {batch.available ? 'available' : 'off'}; } function AiOverviewProbe(): ReactNode { const overview = useAiOverview('dummy-project', ['dummy-assessment'], []); return {overview.available ? 'available' : 'off'}; } beforeEach(() => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); localStorage.clear(); resetEnginePreferencesForTests(); // The Context Engine is not under test here. setEngineEnabled('context', false); vi.mocked(useApp).mockReturnValue({ targetProjectId: 'dummy-project', activeAssessmentIds: [], dataset: null, setFacetFilter: vi.fn() } as unknown as ReturnType); host = document.createElement('div'); document.body.append(host); root = createRoot(host); }); afterEach(async () => { await act(async () => { root.unmount(); }); host.remove(); vi.restoreAllMocks(); vi.unstubAllGlobals(); resetEnginePreferencesForTests(); }); describe('Compliance Engine switch', () => { it('removes the Finding Reader ISO window, section and requests while off and reopens closed', async () => { const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryMappings').mockResolvedValue(mappingResponse); const isoWindow = () => document.querySelector('[role="dialog"].regulatory-mapping-dialog'); setEngineEnabled('compliance', false); await renderReader(); expect(isoButton()).toBeUndefined(); expect(request).not.toHaveBeenCalled(); expect(host.querySelector('[data-iso-controls]')).toBeNull(); // Switched on, the mapping loads with the finding; the button opens it as a window like Accepted Risk. await act(async () => { setEngineEnabled('compliance', true); await new Promise(resolve => setTimeout(resolve, 0)); }); expect(request).toHaveBeenCalledTimes(1); expect(isoWindow()).toBeNull(); await act(async () => { isoButton()!.click(); await new Promise(resolve => setTimeout(resolve, 0)); }); expect(isoWindow()?.querySelector('.regulatory-mapping-panel')).not.toBeNull(); expect(host.querySelector('.regulatory-mapping-panel')).toBeNull(); await act(async () => isoWindow()!.querySelector('[aria-label="Close window"]')!.click()); expect(isoWindow()).toBeNull(); await act(async () => { isoButton()!.click(); }); await act(async () => { setEngineEnabled('compliance', false); }); expect(isoButton()).toBeUndefined(); expect(isoWindow()).toBeNull(); await act(async () => { setEngineEnabled('compliance', true); }); expect(isoButton()?.getAttribute('aria-expanded')).toBe('false'); expect(isoWindow()).toBeNull(); expect(request).toHaveBeenCalledTimes(1); }); it('lists the controls accepted for the finding as a reader section like Description', async () => { const decision = (id: string, controlId: string, value: 'accepted' | 'rejected', createdAt: string) => ({ version: 1 as const, id, ruleKey: 'purple-knight:dummy', sourceTool: 'purple-knight', mappingId: 'dummy-mapping', controlId, decision: value, createdAt }); vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryMappings').mockResolvedValue({ ...mappingResponse, state: 'succeeded', run: { candidates: [{ controlId: '8.20', annexAControlId: 'A.8.20', title: 'Networks security', score: 0.9, evidence: [] }, { controlId: '5.1', annexAControlId: 'A.5.1', title: 'Policies for information security', score: 0.8, evidence: [] }] } as never, decisions: [decision('d3', '5.1', 'rejected', '2026-10-05T10:02:00Z'), decision('d2', '8.20', 'accepted', '2026-10-05T10:01:00Z'), decision('d1', '5.1', 'accepted', '2026-10-05T10:00:00Z'), decision('d0', '7.4', 'accepted', '2026-10-05T09:00:00Z')] }); await renderReader(); const section = host.querySelector('[data-iso-controls]')!; expect(section.querySelector('h4')?.textContent).toBe('ISO controls'); // A later rejection replaces an earlier acceptance; a control without a retrieved title keeps its Annex A id. expect([...section.querySelectorAll('li')].map(item => item.textContent)).toEqual(['A.7.4', 'A.8.20Networks security']); expect(isoButton()?.textContent).toBe('Map ISO controls (2)'); }); it('requests no Result DB batch status while off and hides the batch action', async () => { const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryMappingBatch').mockResolvedValue({ version: 1, projectId: 'dummy-project', scope: { assessmentIds: ['dummy-assessment'], scanRunIds: [], filters: {} }, generationEnabled: true, state: 'empty' } as never); setEngineEnabled('compliance', false); await act(async () => root.render()); expect(host.textContent).toBe('off'); expect(request).not.toHaveBeenCalled(); await act(async () => { setEngineEnabled('compliance', true); }); expect(host.textContent).toBe('available'); expect(request).toHaveBeenCalledTimes(1); const source = readFileSync(join(process.cwd(), 'src/features/results-browser/ResultsBrowser.tsx'), 'utf8'); expect(source).toContain('{regulatoryBatch.available && { const request = vi.spyOn(AssessorApiClient.prototype, 'getAiOverview').mockResolvedValue({ version: 1, projectId: 'dummy-project', scope: { assessmentIds: ['dummy-assessment'], scanRunIds: [] }, generationEnabled: true, state: 'empty', stale: false }); setEngineEnabled('compliance', false); await act(async () => { root.render(); await new Promise(resolve => setTimeout(resolve, 0)); }); expect(host.textContent).toBe('off'); expect(request).not.toHaveBeenCalled(); await act(async () => { setEngineEnabled('compliance', true); await new Promise(resolve => setTimeout(resolve, 0)); }); expect(host.textContent).toBe('available'); expect(request).toHaveBeenCalledTimes(1); const dashboard = readFileSync(join(process.cwd(), 'src/features/dashboard/DashboardPanel.tsx'), 'utf8'); expect(dashboard).toContain('const showAiOverview = aiOverview.available && '); const browser = readFileSync(join(process.cwd(), 'src/features/results-browser/ResultsBrowser.tsx'), 'utf8'); const exportButton = browser.slice(browser.lastIndexOf('{regulatoryBatch.available &&