// @vitest-environment jsdom import { act } from 'react'; import { createRoot } from 'react-dom/client'; import { afterEach, expect, it, vi } from 'vitest'; import { useApp } from '../../src/app/AppProvider'; import { ObsidianGraphView } from '../../src/features/mitre-graph/ObsidianGraphView'; import { useFilteredRecords } from '../../src/features/search-filter/use-filtered-records'; import { useContextClusterIndex } from '../../src/features/context-layer/use-context-cluster-index'; import { contextClusterIndex } from '../../src/features/context-layer/context-cluster-facet'; import { buildAssessmentContext } from '../../src/assessment-context/context-engine'; import { RELEASED_ASSESSMENT_CONTEXT_CATALOG } from '../../src/assessment-context/context-catalog'; import type { UnifiedResultProjection } from '../../src/domain/unified-result'; import { makeSheet } from '../../src/importers/sheet-factory'; vi.mock('../../src/app/AppProvider', () => ({ useApp: vi.fn() })); vi.mock('../../src/features/search-filter/use-filtered-records', () => ({ useFilteredRecords: vi.fn() })); vi.mock('../../src/features/search-filter/SearchFilterBar', () => ({ SearchFilterBar: () => null, SearchTextControl: () => null })); vi.mock('../../src/features/view-bookmarks/ViewBookmarks', () => ({ ViewBookmarks: () => null })); vi.mock('../../src/features/search-filter/dynamic-facets', () => ({ deriveDynamicFacets: () => [] })); vi.mock('../../src/features/context-layer/use-context-cluster-index', () => ({ useContextClusterIndex: vi.fn() })); afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); Reflect.deleteProperty(Element.prototype, 'setPointerCapture'); }); // Synthetic context from a real engine run: one failing Purple Knight rule of the password cluster. function syntheticIndex() { const context = buildAssessmentContext({ projectId: 'dummy-project', assessmentIds: ['dummy-assessment'], generatedAt: '2026-09-30T08:00:00Z', repositoryRevision: 1, observations: [], analyses: [], catalog: RELEASED_ASSESSMENT_CONTEXT_CATALOG, scannerFindings: [{ resultProjectionId: 'scanner-finding:occ-weak', sourceKind: 'scanner-finding', sourceResultId: 'occ-weak', sourceTool: 'purple-knight', sourceRuleId: 'SI000096', outcomeNormalized: 'fail', title: 'Synthetic weak password policy' } as unknown as UnifiedResultProjection] }); return contextClusterIndex(context); } async function renderPathfinder(layerOn: boolean, setFacetFilter = vi.fn()) { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} }); vi.stubGlobal('requestAnimationFrame', vi.fn(() => 1)); vi.stubGlobal('cancelAnimationFrame', vi.fn()); Object.defineProperty(Element.prototype, 'setPointerCapture', { value: vi.fn(), configurable: true }); const sheet = makeSheet('Synthetic graph', [ ['Title', 'Severity', 'Category', 'Status', 'MITRE ATT&CK', 'Source Rule ID'], ['Synthetic weak password policy', 'High', 'Account Security', 'Failed', 'T1110', 'SI000096'] ], '', 'synthetic.csv'); sheet.analysis.records[0].findingOccurrenceId = 'occ-weak'; vi.mocked(useFilteredRecords).mockReturnValue(sheet.analysis.records); vi.mocked(useContextClusterIndex).mockReturnValue(layerOn ? syntheticIndex() : undefined); vi.mocked(useApp).mockReturnValue({ dataset: { sheets: [sheet], profileId: 'generic' }, activeSheet: sheet, activeSheetId: sheet.id, activeProfile: { defaults: () => ({ statusFilter: 'deviation' }) }, titleColumn: 'Title', reviews: {}, actionOnly: false, facetFilters: {}, pathFindingIds: [], search: '', statusFilter: 'deviation', setFacetFilter, setSelectedRecord: vi.fn(), setView: vi.fn(), toggleWorkspace: vi.fn(), updateReview: vi.fn() } as unknown as ReturnType); const container = document.createElement('div'); document.body.append(container); const root = createRoot(container); await act(async () => root.render( {}} />)); return { container, cleanup: async () => { await act(async () => root.unmount()); container.remove(); } }; } const pointer = (type: string) => { const event = new MouseEvent(type, { bubbles: true, button: 0, clientX: 100, clientY: 100 }); Object.defineProperty(event, 'pointerId', { value: 1 }); return event; }; it('shows context clusters as Pathfinder hubs with details while the layer is on', async () => { const setFacetFilter = vi.fn(); const { container, cleanup } = await renderPathfinder(true, setFacetFilter); try { const graph = container.querySelector('.obsidian-graph-surface')!; const hub = container.querySelector('.obsidian-node-context')!; expect(hub.getAttribute('aria-label')).toBe('Password Guessing Exposure'); expect(container.querySelectorAll('.obsidian-graph-edges .obsidian-edge-context')).toHaveLength(1); expect(graph.getAttribute('aria-label')).toContain('context clusters'); // Selecting the finding lists its cluster among the source-backed facts. const finding = container.querySelector('.obsidian-node-finding')!; await act(async () => { finding.dispatchEvent(pointer('pointerdown')); graph.dispatchEvent(pointer('pointerup')); }); expect(container.querySelector('[data-context-detail] dd')?.textContent).toBe('Password Guessing Exposure'); await act(async () => { finding.dispatchEvent(pointer('pointerdown')); graph.dispatchEvent(pointer('pointerup')); }); // Selecting the hub applies the shared Context Cluster filter and shows the cluster only in Details. await act(async () => { hub.dispatchEvent(pointer('pointerdown')); graph.dispatchEvent(pointer('pointerup')); }); expect(setFacetFilter).toHaveBeenCalledWith('context:cluster', { values: ['password-guessing-exposure'] }); const details = container.querySelector('.obsidian-graph-inspector-content')!; expect(details.querySelector('small')?.textContent).toBe('Context'); const facts = Object.fromEntries([...details.querySelectorAll('[data-context-cluster-detail] > div')].map(row => [ row.querySelector('dt')!.textContent, row.querySelector('dd')!.textContent ])); expect(facts.State).toBe('Undetermined'); expect(facts.Conditions).toMatch(/^1 of \d+ met, 2 required ยท \d+ unknown$/); expect(facts.Reading).toBe('Derived second reading; it does not change any result.'); expect(details.querySelector('.data-tile-hex')).toBeNull(); // Pathfinder rules stay: no in-graph cards, text still on. expect(container.querySelector('.obsidian-graph-cards')).toBeNull(); expect(container.querySelector('.obsidian-node-inline-label')).not.toBeNull(); } finally { await cleanup(); } }); it('renders the Pathfinder without any context element while the layer is off', async () => { const { container, cleanup } = await renderPathfinder(false); try { expect(container.querySelector('.obsidian-node-context')).toBeNull(); expect(container.querySelector('.obsidian-edge-context')).toBeNull(); expect(container.querySelector('.obsidian-graph-surface')?.getAttribute('aria-label')).not.toContain('context'); const graph = container.querySelector('.obsidian-graph-surface')!; const finding = container.querySelector('.obsidian-node-finding')!; await act(async () => { finding.dispatchEvent(pointer('pointerdown')); graph.dispatchEvent(pointer('pointerup')); }); expect(container.querySelector('[data-context-detail]')).toBeNull(); } finally { await cleanup(); } });