// @vitest-environment jsdom 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 { requestActivity } from '../../src/api/request-activity'; import { resetEnginePreferencesForTests } from '../../src/app/engine-preferences'; import type { RegulatoryMappingBatchResponse } from '../../src/compliance/regulatory-mapping'; import { useRegulatoryMappingBatch } from '../../src/compliance/ui/RegulatoryMappingBatchDialog'; import { useRegulatoryMapping } from '../../src/compliance/ui/use-regulatory-mapping'; import type { RegulatoryMappingResponse } from '../../src/compliance/regulatory-mapping'; import { activitySnapshot, activityStatusSnapshot, clearFinishedActivities } from '../../src/platform/activity-store'; // The activity log names Compliance Engine work as such: its requests never read as Core client work, // and backend work keeps the log busy until it really ends. describe('Compliance Engine requests in the activity log', () => { it.each([ ['/projects/p/regulatory-mappings/scanner-finding%3Ao', 'GET', 'Loading ISO mapping'], ['/projects/p/regulatory-mapping-runs', 'POST', 'Generating ISO control proposals'], ['/projects/p/regulatory-mapping-decisions', 'POST', 'Saving ISO mapping decision'], ['/projects/p/regulatory-mapping-batches?assessmentIds=a&deviation=true', 'GET', 'Loading ISO mapping batch status'], ['/projects/p/regulatory-mapping-batches', 'POST', 'Starting ISO mapping batch'], ['/reference-data/iso-corpus', 'GET', 'Loading ISO corpus status'], ['/reference-data/iso-corpus', 'POST', 'Uploading ISO corpus'] ])('labels %s %s', (path, method, label) => { expect(requestActivity(path, method)).toEqual({ area: 'Compliance Engine', label }); }); it('keeps unrelated client requests in the Core', () => { expect(requestActivity('/projects/p/import-files')).toEqual({ area: 'Core Engine', label: 'Loading imported files' }); expect(requestActivity('/projects')).toEqual({ area: 'Core Engine', label: 'Loading clients' }); }); }); function batchResponse(state: RegulatoryMappingBatchResponse['state']): RegulatoryMappingBatchResponse { return { version: 1, projectId: 'dummy-project', scope: { assessmentIds: ['dummy-assessment'], scanRunIds: [], filters: {} }, generationEnabled: true, state, pollAfterMs: 5 } as RegulatoryMappingBatchResponse; } const scope = { assessmentIds: ['dummy-assessment'], scanRunIds: [], filters: {} }; function BatchProbe({ projectId = 'dummy-project' }: { projectId?: string }): ReactNode { useRegulatoryMappingBatch(projectId, scope); return null; } const batchRows = () => activitySnapshot().filter(row => row.area === 'Compliance Engine' && row.label.startsWith('Mapping all ISO controls')); const settle = () => act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); }); let host: HTMLDivElement; let root: Root; beforeEach(() => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); localStorage.clear(); resetEnginePreferencesForTests(); clearFinishedActivities(); host = document.createElement('div'); document.body.append(host); root = createRoot(host); }); afterEach(async () => { await act(async () => { root.unmount(); }); host.remove(); vi.restoreAllMocks(); vi.unstubAllGlobals(); clearFinishedActivities(); }); describe('Compliance Engine backend work in the activity log', () => { it('keeps a running ISO batch busy until the backend completes it', async () => { const states: RegulatoryMappingBatchResponse['state'][] = ['running', 'running', 'completed']; vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryMappingBatch').mockImplementation(async () => batchResponse(states.shift() ?? 'completed')); await act(async () => root.render()); await act(async () => { await Promise.resolve(); }); expect(batchRows().map(row => row.state)).toEqual(['running']); expect(activityStatusSnapshot().ready).toBe(false); const generation = activityStatusSnapshot().generation; await settle(); await settle(); expect(batchRows().map(row => row.state)).toEqual(['completed']); expect(activityStatusSnapshot().ready).toBe(true); // Repeated batch status checks start no further entries. expect(activityStatusSnapshot().generation).toBe(generation); }); it('marks the entry failed when the backend batch fails', async () => { const states: RegulatoryMappingBatchResponse['state'][] = ['running', 'failed']; vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryMappingBatch').mockImplementation(async () => batchResponse(states.shift() ?? 'failed')); await act(async () => root.render()); await settle(); await settle(); expect(batchRows().map(row => row.state)).toEqual(['failed']); expect(activityStatusSnapshot().failed).toBe(1); }); it('ends the entry with a note when the view stops observing a running batch', async () => { vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryMappingBatch').mockResolvedValue(batchResponse('running')); await act(async () => root.render()); await act(async () => { await Promise.resolve(); }); expect(batchRows().map(row => row.state)).toEqual(['running']); await act(async () => { root.unmount(); }); root = createRoot(host); expect(batchRows().map(row => [row.state, row.label])).toEqual([['completed', 'Mapping all ISO controls continues in the backend']]); expect(activityStatusSnapshot().ready).toBe(true); }); }); function mappingResponse(state: RegulatoryMappingResponse['state'], id: string): RegulatoryMappingResponse { return { version: 1, projectId: 'dummy-project', resultProjectionId: `scanner-finding:${id}`, generationEnabled: true, state, decisions: [], pollAfterMs: 5 }; } function MappingProbe({ id }: { id: string }): ReactNode { const mapping = useRegulatoryMapping('dummy-project', `scanner-finding:${id}`, 'connected'); return ; } const mappingRows = () => activitySnapshot().filter(row => row.area === 'Compliance Engine' && row.label.startsWith('Mapping ISO controls')); describe('Compliance Engine single mapping in the activity log', () => { it('keeps a generated mapping running after the window closes until the backend completes it', async () => { const states: RegulatoryMappingResponse['state'][] = ['empty', 'running', 'running', 'succeeded']; // Replaced below the activity logging, so every client logs exactly as in the application. const transport = vi.spyOn(AssessorApiClient.prototype as unknown as { performRequest(path: string, init: RequestInit): Promise }, 'performRequest') .mockImplementation(async (path: string, init: RequestInit) => init.method === 'POST' ? mappingResponse('queued', 'closed-window') : mappingResponse(states.shift() ?? 'succeeded', 'closed-window')); await act(async () => root.render()); await act(async () => { await Promise.resolve(); }); await act(async () => host.querySelector('button')!.click()); expect(mappingRows().map(row => row.state)).toEqual(['queued']); // Status checks while it runs start no entries of their own, so the activity panel does not reopen and jump. const generation = activityStatusSnapshot().generation; // Closing the window or leaving the finding does not end the observation. await act(async () => { root.unmount(); }); root = createRoot(host); expect(mappingRows().map(row => row.state)).toEqual(['queued']); expect(activityStatusSnapshot().ready).toBe(false); await settle(); await settle(); await settle(); expect(mappingRows().map(row => [row.state, row.label])).toEqual([['completed', 'Mapping ISO controls']]); expect(activityStatusSnapshot().ready).toBe(true); expect(transport.mock.calls.filter(([path]) => String(path).includes('/regulatory-mappings/')).length).toBeGreaterThanOrEqual(4); expect(activityStatusSnapshot().generation).toBe(generation); // Only the first load of the finding and the generate request are logged; the status checks are not. expect(activitySnapshot().filter(row => row.area === 'Compliance Engine').map(row => row.label).sort()) .toEqual(['Generating ISO control proposals', 'Loading ISO mapping', 'Mapping ISO controls']); }); it('marks the mapping failed when the backend run fails', async () => { const states: RegulatoryMappingResponse['state'][] = ['running', 'failed']; vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryMappings').mockImplementation(async () => mappingResponse(states.shift() ?? 'failed', 'failing')); await act(async () => root.render()); await settle(); await settle(); expect(mappingRows().map(row => row.state)).toEqual(['failed']); }); });