// @vitest-environment jsdom import { act } 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 type { ImportFileRecord, ScanRun, Assessment } from '../../src/domain/canonical-model'; import type { TimeslotComparisonReport } from '../../src/comparison/timeslot-comparison-report'; import type { TimeslotDomainComparison } from '../../src/comparison/timeslot-domain-comparison'; import { ComparisonTab } from '../../src/features/comparison/ComparisonTab'; import { resetTimeslotComparisonRequestsForTests } from '../../src/features/comparison/use-timeslot-comparison'; // Client Info `Compare` tab content (mockup A): A/B selection with B as the open timeslot, score with a // labelled delta, change facts that open Work and a source table. Synthetic data only. const assessment = (id: string, effectiveAt: string): Assessment => ({ id, projectId: 'dummy-project', name: id, createdAt: effectiveAt, updatedAt: effectiveAt, effectiveAt }); const run = (id: string, assessmentId: string, at: string): ScanRun => ({ id, assessmentId, sequence: 1, label: id, tool: { id: 'cis-cat', name: 'CIS-CAT Pro' }, importedAt: at }); const file = (runId: string, at: string) => ({ id: `file-${runId}`, scanRunId: runId, fileName: 'synthetic.html', format: 'HTML', sizeBytes: 1, importedAt: at, lastModifiedAt: at, detectedTool: { id: 'cis-cat', name: 'CIS-CAT Pro' } }) as unknown as ImportFileRecord; const dates = { a: '2026-01-10T10:00:00.000Z', b: '2026-02-10T10:00:00.000Z', c: '2026-03-10T10:00:00.000Z' }; const report: TimeslotComparisonReport = { version: '1.0.0', projectId: 'dummy-project', baselineRunIds: ['run-b'], targetRunIds: ['run-c'], sources: [{ sourceId: 'cis-cat', state: 'compared', reasons: [] }, { sourceId: 'maester', state: 'not-comparable', reasons: ['baseline-source-missing'] }], changes: [ { sourceId: 'cis-cat', change: 'resolved', side: 'baseline', recordIds: ['b-1'], counterpartIds: ['c-1'], reasons: [] }, { sourceId: 'cis-cat', change: 'regressed', side: 'target', recordIds: ['c-3'], counterpartIds: [], reasons: [] }, { sourceId: 'maester', change: 'not-comparable', side: 'target', recordIds: ['m-1'], counterpartIds: [], reasons: ['baseline-source-missing'] } ], summary: { resolved: 1, unchanged: 0, 'value-changed': 0, unverified: 0, 'not-comparable': 0, 'not-applicable': 0, new: 0, regressed: 1, targetNotComparable: 1 }, score: { baselineScore: 52.4, targetScore: 55.2, change: 2.8, sources: [ { sourceId: 'cis-cat', weight: 1, method: 'paired-scans', reasons: [], baselineScore: 52.4, targetScore: 55.2, change: 2.8 }, { sourceId: 'maester', weight: 1, method: 'paired-scans', reasons: ['baseline-source-missing'] } ], excludedSources: [{ sourceId: 'maester', reasons: ['baseline-source-missing'] }] }, scanner: {} as TimeslotComparisonReport['scanner'] }; const side = (score: number | undefined, passed: number, deviations: number) => ({ ...(score === undefined ? {} : { score }), passed, deviations }); const domains: TimeslotDomainComparison = { version: '1.0.0', sources: ['cis-cat'], domains: [ { id: 'identity', label: 'Identity & Authentication', baseline: side(48, 12, 13), target: side(55.2, 16, 13), onlyInSources: [], resolved: 4, worse: 1 }, { id: 'cloud', label: 'Cloud & Collaboration', baseline: side(undefined, 0, 0), target: side(46, 6, 7), onlyIn: 'target', onlyInSources: ['maester'], resolved: 0, worse: 0 }, { id: 'monitoring', label: 'Monitoring & Recovery', baseline: side(61, 11, 7), target: side(58, 11, 8), onlyInSources: [], resolved: 0, worse: 2 } ] }; let host: HTMLDivElement; let root: Root; beforeEach(() => { resetTimeslotComparisonRequestsForTests(); vi.spyOn(AssessorApiClient.prototype, 'listProjectImportFiles').mockResolvedValue([file('run-a', dates.a), file('run-b', dates.b), file('run-c', dates.c)]); vi.spyOn(AssessorApiClient.prototype, 'listProjectAssessmentHistory').mockResolvedValue({ assessments: [assessment('assessment-a', dates.a), assessment('assessment-b', dates.b), assessment('assessment-c', dates.c)], scanRuns: [run('run-a', 'assessment-a', dates.a), run('run-b', 'assessment-b', dates.b), run('run-c', 'assessment-c', dates.c)] } as never); vi.spyOn(AssessorApiClient.prototype, 'listProjectGpoBackupStatus').mockResolvedValue({ records: [] } as never); vi.spyOn(AssessorApiClient.prototype, 'getTimeslotDomainComparison').mockResolvedValue(domains as never); host = document.createElement('div'); document.body.append(host); root = createRoot(host); }); afterEach(async () => { await act(async () => root.unmount()); host.remove(); vi.restoreAllMocks(); }); const settle = () => act(async () => { for (let index = 0; index < 5; index++) await Promise.resolve(); }); describe('Comparison Engine Compare tab', () => { it('compares the open timeslot with the one before it and opens Work from a change', async () => { const request = vi.spyOn(AssessorApiClient.prototype, 'getTimeslotComparison').mockResolvedValue(report); const onOpenChange = vi.fn(); await act(async () => root.render()); await settle(); expect(request).toHaveBeenCalledWith('dummy-project', expect.objectContaining({ assessmentIds: ['assessment-b'] }), expect.objectContaining({ assessmentIds: ['assessment-c'] })); const selects = [...host.querySelectorAll('.comparison-tab__timeslots select')] as HTMLSelectElement[]; expect(selects.map(select => select.value)).toEqual(['timeslot-2026-02-10', 'timeslot-2026-03-10']); expect([...host.querySelectorAll('.comparison-score dd')].map(item => item.textContent)).toEqual(['52', '55']); expect(host.querySelector('.comparison-tile .comparison-delta')?.textContent).toBe('▲ +3 Better'); const facts = [...host.querySelectorAll('.comparison-fact')]; const fact = (label: string) => facts.find(item => item.querySelector('.comparison-fact__label')?.textContent === label)!; expect(fact('Regressed').querySelector('.comparison-fact__value')?.className).toContain('is-worse'); expect(fact('Not comparable').textContent).toContain('1'); expect(fact('New').disabled).toBe(true); await act(async () => fact('Resolved').click()); expect(onOpenChange).toHaveBeenCalledWith('resolved', expect.objectContaining({ id: 'timeslot-2026-02-10' }), expect.objectContaining({ id: 'timeslot-2026-03-10' })); const maester = [...host.querySelectorAll('.comparison-table tbody tr')].find(row => row.textContent?.includes('Maester')); expect(maester?.textContent).toContain('Not in timeslot A'); }); it('lists the changed findings under the source table and filters them by change', async () => { const titled: TimeslotComparisonReport = { ...report, changes: [ { sourceId: 'cis-cat', change: 'unchanged', side: 'baseline', recordIds: ['b-2'], counterpartIds: ['c-2'], title: 'Synthetic unchanged rule', ruleId: '1.2', reasons: [] }, { sourceId: 'cis-cat', change: 'regressed', side: 'target', recordIds: ['c-4'], counterpartIds: [], title: 'Synthetic low rule', ruleId: '5.2', severity: 'low', reasons: [] }, { sourceId: 'cis-cat', change: 'regressed', side: 'target', recordIds: ['c-3'], counterpartIds: [], title: 'Synthetic regressed rule', ruleId: '5.1', severity: 'critical', reasons: [] }, { sourceId: 'cis-cat', change: 'resolved', side: 'baseline', recordIds: ['b-1'], counterpartIds: ['c-1'], title: 'Synthetic resolved rule', ruleId: '1.1', reasons: [] }, { sourceId: 'group-policy', change: 'value-changed', side: 'baseline', recordIds: ['g-1'], counterpartIds: ['g-2'], title: 'Synthetic setting', reasons: [], values: { baseline: ['7'], target: ['10'] } } ] }; vi.spyOn(AssessorApiClient.prototype, 'getTimeslotComparison').mockResolvedValue(titled); await act(async () => root.render()); await settle(); const rows = () => [...host.querySelectorAll('.comparison-item')].map(row => ({ severity: row.className.replace('comparison-item ', '').replace(' is-selected', ''), title: row.querySelector('.comparison-item__title')?.textContent, badges: [...row.querySelectorAll('.comparison-item__meta > span')].map(cell => cell.textContent), badge: row.querySelector(':scope > .reader-severity')?.textContent })); // `All changes` leaves out the unchanged ones; better first, then worse by severity, then neutral. expect(rows()).toEqual([ { severity: 'severity-unknown', title: 'Synthetic resolved rule', badges: ['▼ Resolved', 'CIS-CAT Pro', '1.1 · Deviation in A · passed in B'], badge: 'Unknown' }, { severity: 'severity-critical', title: 'Synthetic regressed rule', badges: ['▲ Regressed', 'CIS-CAT Pro', '5.1 · Passed in A'], badge: 'Critical' }, { severity: 'severity-low', title: 'Synthetic low rule', badges: ['▲ Regressed', 'CIS-CAT Pro', '5.2 · Passed in A'], badge: 'Low' }, { severity: 'severity-unknown', title: 'Synthetic setting', badges: ['≠ Value changed', 'GPOs', 'Deviation in A and B · 7 → 10'], badge: 'Unknown' } ]); expect(host.querySelector('.comparison-badge.is-worse')?.textContent).toBe('▲ Regressed'); // Rows are clickable like the Work list: the first is selected, a click or arrow key selects another and // the detail beside the list shows that change with A and B side by side. const items = () => [...host.querySelectorAll('.comparison-item')]; const detailTitle = () => host.querySelector('.comparison-detail__title')?.textContent; expect(items()[0].getAttribute('aria-pressed')).toBe('true'); expect(detailTitle()).toBe('Synthetic resolved rule'); await act(async () => items()[3].click()); expect(items()[3].className).toContain('is-selected'); expect(detailTitle()).toBe('Synthetic setting'); const valueRow = [...host.querySelectorAll('.comparison-detail tbody tr')].find(row => row.textContent?.startsWith('Configured value')); expect([...valueRow!.querySelectorAll('td')].slice(1).map(cell => cell.textContent)).toEqual(['7', '10']); await act(async () => host.querySelector('.comparison-list')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }))); expect(detailTitle()).toBe('Synthetic low rule'); expect(document.activeElement).toBe(items()[2]); const filter = host.querySelector('.comparison-tile__filter select')!; await act(async () => { filter.value = 'unchanged'; filter.dispatchEvent(new Event('change', { bubbles: true })); }); expect(rows().map(row => row.title)).toEqual(['Synthetic unchanged rule']); await act(async () => { filter.value = 'new'; filter.dispatchEvent(new Event('change', { bubbles: true })); }); expect(host.textContent).toContain('No finding has this change.'); }); it('shows Security Domains A → B with a labelled change, resolved against worse bars and a domain only in B', async () => { vi.spyOn(AssessorApiClient.prototype, 'getTimeslotComparison').mockResolvedValue(report); await act(async () => root.render()); await settle(); const rows = [...host.querySelectorAll('.comparison-domain:not(.is-header)')]; const cells = (row: Element) => [...row.children].map(cell => cell.textContent); expect(cells(rows[0]).slice(0, 5)).toEqual(['Identity & Authentication', '48', '→', '55', '▲ +7']); expect(rows[0].querySelector('.comparison-domain__bar.is-better')?.style.width).toBe('100%'); expect(rows[0].querySelector('.comparison-domain__bar.is-worse')?.style.width).toBe('25%'); expect(cells(rows[1])).toEqual(['Cloud & Collaboration', '—', '→', '46', '○ Only in B', 'Maester only in B · not comparable']); expect(cells(rows[2])[4]).toBe('▼ −3'); expect(rows[2].querySelector('.comparison-delta')?.className).toContain('is-worse'); }); it('shows a scanner value change with both Problem values side by side', async () => { const changed: TimeslotComparisonReport = { ...report, changes: [{ sourceId: 'purple-knight', change: 'value-changed', side: 'baseline', recordIds: ['b-5'], counterpartIds: ['c-5'], title: 'Synthetic templates', ruleId: 'SI-1', reasons: [], problem: { baseline: [{ label: 'Result', value: 'Found 1 synthetic template' }, { label: 'Affected', value: '1' }], target: [{ label: 'Result', value: 'Found 1 synthetic template' }, { label: 'Affected', value: '3' }] } }] }; vi.spyOn(AssessorApiClient.prototype, 'getTimeslotComparison').mockResolvedValue(changed); await act(async () => root.render()); await settle(); expect(host.querySelector('.comparison-item__note')?.textContent).toBe('SI-1 · Deviation in A and B · Affected 1 → 3'); const rows = [...host.querySelectorAll('.comparison-detail .finding-comparison tbody tr')].map(row => [...row.children].map(cell => cell.textContent)); // Only what differs between A and B; the identical Result line stays in the Problem block. expect(rows).toEqual([['Verdict', '✕ Deviation', '✕ Deviation'], ['Affected', '1', '3']]); // Like GPO configured values: red where the timeslot deviates (here both), green where it passes. expect([...host.querySelectorAll('.comparison-detail .finding-comparison .policy-value-mark')].map(item => [item.textContent, item.className])).toEqual([ ['1', 'policy-value-mark is-deviating'], ['3', 'policy-value-mark is-deviating']]); }); it('marks a resolved finding red in A and green in B and keeps the expected value neutral', async () => { const resolved: TimeslotComparisonReport = { ...report, changes: [{ sourceId: 'cis-cat', change: 'resolved', side: 'baseline', recordIds: ['b-6'], counterpartIds: ['c-6'], title: 'Synthetic rule', ruleId: '1.1.1', reasons: [], problem: { baseline: [{ label: 'Expected', value: '>= 14' }, { label: 'Found', value: '7' }], target: [{ label: 'Expected', value: '>= 14' }, { label: 'Found', value: '14' }] } }] }; vi.spyOn(AssessorApiClient.prototype, 'getTimeslotComparison').mockResolvedValue(resolved); await act(async () => root.render()); await settle(); const row = (label: string) => [...host.querySelectorAll('.comparison-detail tbody tr')].find(item => item.firstElementChild?.textContent === label)!; expect([...row('Found').querySelectorAll('.policy-value-mark')].map(item => item.className)).toEqual(['policy-value-mark is-deviating', 'policy-value-mark is-aligned']); expect(row('Expected')).toBeUndefined(); }); it('shows a CIS-CAT evidence row that changed only in its value by that value, and hides identical rows', async () => { const found = (value: string) => [{ label: 'Expected', value: 'Criterion: synthetic' }, { label: 'Found', value: 'Type · String · Exists · reg_dword' }, { label: 'Found', value: `Value · Int · Exists · ${value}` }]; const cis: TimeslotComparisonReport = { ...report, changes: [{ sourceId: 'cis-cat', change: 'value-changed', side: 'baseline', recordIds: ['b-7'], counterpartIds: ['c-7'], title: 'Synthetic registry rule', ruleId: '2.3.17.2', reasons: [], problem: { baseline: found('5'), target: found('0') } }] }; vi.spyOn(AssessorApiClient.prototype, 'getTimeslotComparison').mockResolvedValue(cis); await act(async () => root.render()); await settle(); expect(host.querySelector('.comparison-item__note')?.textContent).toBe('2.3.17.2 · Deviation in A and B · Found 5 → 0'); const rows = [...host.querySelectorAll('.comparison-detail .finding-comparison tbody tr')].map(row => [...row.children].map(cell => cell.textContent)); expect(rows).toEqual([['Verdict', '✕ Deviation', '✕ Deviation'], ['Found · Value', '5', '0']]); }); it('explains that the oldest timeslot has nothing before it', async () => { const request = vi.spyOn(AssessorApiClient.prototype, 'getTimeslotComparison'); await act(async () => root.render()); await settle(); expect(host.querySelectorAll('select')[1].value).toBe('timeslot-2026-01-10'); expect(host.textContent).toContain('no earlier timeslot'); expect(request).not.toHaveBeenCalled(); }); it('offers only earlier timeslots as A and requests the new pair on change', async () => { const request = vi.spyOn(AssessorApiClient.prototype, 'getTimeslotComparison').mockResolvedValue(report); await act(async () => root.render()); await settle(); const [baseline] = [...host.querySelectorAll('select')]; expect([...baseline.options].map(option => option.value)).toEqual(['timeslot-2026-01-10', 'timeslot-2026-02-10']); await act(async () => { baseline.value = 'timeslot-2026-01-10'; baseline.dispatchEvent(new Event('change', { bubbles: true })); }); await settle(); expect(request).toHaveBeenLastCalledWith('dummy-project', expect.objectContaining({ assessmentIds: ['assessment-a'] }), expect.objectContaining({ assessmentIds: ['assessment-c'] })); }); it('explains a client with a single timeslot instead of comparing', async () => { vi.spyOn(AssessorApiClient.prototype, 'listProjectImportFiles').mockResolvedValue([file('run-a', dates.a)]); vi.spyOn(AssessorApiClient.prototype, 'listProjectAssessmentHistory').mockResolvedValue({ assessments: [assessment('assessment-a', dates.a)], scanRuns: [run('run-a', 'assessment-a', dates.a)] } as never); const request = vi.spyOn(AssessorApiClient.prototype, 'getTimeslotComparison'); await act(async () => root.render()); await settle(); expect(host.textContent).toContain('only one timeslot'); expect(request).not.toHaveBeenCalled(); }); });