// @vitest-environment jsdom import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; 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 { clearStoredMitreMatrix, MITRE_MATRIX_CHANGED_EVENT, saveStoredMitreMatrix } from '../../src/features/mitre-graph/mitre-graph'; import { useFilteredRecords } from '../../src/features/search-filter/use-filtered-records'; 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: () => [] })); afterEach(() => { clearStoredMitreMatrix(); vi.restoreAllMocks(); vi.unstubAllGlobals(); Reflect.deleteProperty(Element.prototype, 'setPointerCapture'); }); it('shows node text by default, keeps it on after selecting a node, and toggles it only with Text', async () => { 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 finding', 'High', 'Synthetic category', 'Failed', 'T1001', 'S-Synthetic'] ], '', 'synthetic.csv'); vi.mocked(useFilteredRecords).mockReturnValue(sheet.analysis.records); 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: vi.fn(), 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 host = document.createElement('div'); host.id = 'pathfinder-controls'; document.body.append(host); const root = createRoot(container); try { await act(async () => root.render( {}} />)); expect(host.querySelector('.pathfinder-controls .workbar')).not.toBeNull(); expect(container.querySelector('.obsidian-graph-layout')?.firstElementChild?.classList.contains('obsidian-graph-inspector')).toBe(true); await act(async () => root.render( {}} />)); expect(host.childElementCount).toBe(0); await act(async () => root.render( {}} />)); expect(host.querySelector('.pathfinder-controls')).not.toBeNull(); const graph = container.querySelector('.obsidian-graph-surface')!; const textButton = [...container.querySelectorAll('.obsidian-graph-toolbar button')] .find(button => button.textContent === 'Text')!; expect(textButton).toBeDefined(); expect(textButton.getAttribute('aria-pressed')).toBe('true'); expect(container.querySelector('.obsidian-node-inline-label')).not.toBeNull(); expect(container.querySelector('.obsidian-graph-cards')).toBeNull(); await act(async () => { saveStoredMitreMatrix({ version: '19.2', tacticOrder: ['Command and Control'], techniques: [ { id: 'T1001', name: 'Synthetic updated name', tactics: ['Command and Control'], platforms: [], parent: null, url: '', sourceCount: 0 } ], entities: [] }); window.dispatchEvent(new Event(MITRE_MATRIX_CHANGED_EVENT)); }); expect(container.querySelector('.obsidian-node-technique')?.getAttribute('aria-label')).toContain('Synthetic updated name'); await act(async () => { clearStoredMitreMatrix(); window.dispatchEvent(new Event(MITRE_MATRIX_CHANGED_EVENT)); }); expect(container.querySelector('.obsidian-node-technique')?.getAttribute('aria-label')).toContain('Data Obfuscation'); expect([...container.querySelectorAll('.obsidian-graph-toolbar button')].map(button => button.textContent)).not.toContain('Off'); expect([...container.querySelectorAll('.obsidian-graph-toolbar button')].map(button => button.textContent)).not.toContain('Labels'); const node = container.querySelector('.obsidian-node-finding')!; 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; }; await act(async () => { node.dispatchEvent(pointer('pointerdown')); graph.dispatchEvent(pointer('pointerup')); }); expect(node.getAttribute('aria-pressed')).toBe('true'); const details = container.querySelector('.obsidian-graph-inspector-content')!; const inspectorStyles = readFileSync(resolve(process.cwd(), 'src/styles/obsidian-graph.css'), 'utf8'); const fieldRowsRule = inspectorStyles.match(/\.obsidian-graph-inspector dl div \{[^}]*\}/g)?.at(-1); const fieldValueRule = inspectorStyles.match(/\.obsidian-graph-inspector dd \{[^}]*\}/g)?.at(-1); expect(fieldRowsRule).toBeDefined(); expect(fieldValueRule).toBeDefined(); const style = document.createElement('style'); style.textContent = `${fieldRowsRule}\n${fieldValueRule}`; document.head.append(style); const sourceRuleRow = [...details.querySelectorAll('dl > div')] .find(row => row.querySelector('dt')?.textContent === 'Source Rule ID')!; expect(getComputedStyle(sourceRuleRow).gridTemplateColumns).toBe('minmax(0, 1fr) minmax(0, 1.4fr)'); expect(getComputedStyle(sourceRuleRow.querySelector('dd')!).overflowWrap).toBe('normal'); expect(getComputedStyle(sourceRuleRow.querySelector('dd')!).wordBreak).toBe('normal'); style.remove(); expect(details.textContent).toContain('Synthetic finding'); expect(details.querySelector('.obsidian-graph-inspector-title.severity-high .data-tile-hex')).not.toBeNull(); const facts = Object.fromEntries([...details.querySelectorAll('dl > div')].map(row => [ row.querySelector('dt')!.textContent, row.querySelector('dd')!.textContent ])); expect(facts).toMatchObject({ 'Source status': 'Failed', 'Source Rule ID': 'S-Synthetic', Severity: 'High', Category: 'Synthetic category', 'MITRE ATT&CK': 'T1001' }); expect(textButton.getAttribute('aria-pressed')).toBe('true'); expect(container.querySelector('.obsidian-node-inline-label')).not.toBeNull(); expect(container.querySelector('.obsidian-graph-cards')).toBeNull(); await act(async () => textButton.click()); expect(textButton.getAttribute('aria-pressed')).toBe('false'); expect(container.querySelector('.obsidian-node-inline-label')).toBeNull(); expect(container.querySelector('.obsidian-graph-inspector-content')).not.toBeNull(); await act(async () => { node.dispatchEvent(pointer('pointerdown')); graph.dispatchEvent(pointer('pointerup')); }); expect(node.getAttribute('aria-pressed')).toBe('false'); expect(textButton.getAttribute('aria-pressed')).toBe('false'); const resetButton = [...container.querySelectorAll('.obsidian-graph-toolbar button')] .find(button => button.textContent?.includes('Reset view'))!; await act(async () => resetButton.click()); expect(textButton.getAttribute('aria-pressed')).toBe('true'); expect(container.querySelector('.obsidian-node-inline-label')).not.toBeNull(); expect(container.querySelector('.obsidian-graph-inspector-empty')).not.toBeNull(); await act(async () => textButton.click()); expect(textButton.getAttribute('aria-pressed')).toBe('false'); await act(async () => textButton.click()); expect(textButton.getAttribute('aria-pressed')).toBe('true'); expect(container.querySelector('.obsidian-node-inline-label')).not.toBeNull(); const categoryNode = container.querySelector('.obsidian-node-category')!; await act(async () => { categoryNode.dispatchEvent(pointer('pointerdown')); graph.dispatchEvent(pointer('pointerup')); }); const categoryDetails = container.querySelector('.obsidian-graph-inspector-content')!; expect(categoryDetails.textContent).toContain('Synthetic category'); expect(categoryDetails.querySelector('.data-tile-hex')).toBeNull(); expect(categoryDetails.querySelector('dl')).toBeNull(); } finally { await act(async () => root.unmount()); container.remove(); host.remove(); } }); it('extracts only the selected Pathfinder path and restores the graph on exit', async () => { 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'], ['First finding', 'High', 'Shared category', 'Failed', 'T1001', 'S-First'], ['Second finding', 'High', 'Shared category', 'Failed', 'T1002', 'S-Second'] ], '', 'synthetic.csv'); vi.mocked(useFilteredRecords).mockReturnValue(sheet.analysis.records); 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: vi.fn(), 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); try { await act(async () => root.render( {}} />)); const graph = container.querySelector('.obsidian-graph-surface')!; const extractButton = [...container.querySelectorAll('.obsidian-graph-toolbar button')] .find(button => button.textContent === 'Extract Path')!; const allNodeCount = container.querySelectorAll('.obsidian-node').length; expect(allNodeCount).toBeGreaterThan(3); expect(extractButton.disabled).toBe(true); 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; }; const selectNode = async (node: Element) => act(async () => { node.dispatchEvent(pointer('pointerdown')); graph.dispatchEvent(pointer('pointerup')); }); await selectNode(container.querySelector('.obsidian-node-category')!); expect(extractButton.disabled).toBe(false); await act(async () => extractButton.click()); expect(extractButton.textContent).toBe('Exit Extract Path'); expect(container.querySelectorAll('.obsidian-node')).toHaveLength(3); expect(container.querySelectorAll('.obsidian-graph-edges line')).toHaveLength(2); expect(container.querySelector('.obsidian-node-technique')).toBeNull(); await act(async () => extractButton.click()); expect(extractButton.textContent).toBe('Extract Path'); expect(container.querySelectorAll('.obsidian-node')).toHaveLength(allNodeCount); await selectNode(container.querySelector('.obsidian-node-finding')!); await act(async () => extractButton.click()); expect(container.querySelectorAll('.obsidian-node-finding')).toHaveLength(1); expect(container.querySelectorAll('.obsidian-graph-edges line').length).toBeGreaterThan(0); } finally { await act(async () => root.unmount()); container.remove(); } });