106 lines
7.5 KiB
TypeScript
106 lines
7.5 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { act } from 'react';
|
|
import { createRoot } from 'react-dom/client';
|
|
import { afterEach, expect, it, vi } from 'vitest';
|
|
|
|
const appState = vi.hoisted(() => ({ current: {} as Record<string, unknown> }));
|
|
|
|
vi.mock('../../src/app/AppProvider', () => ({
|
|
useApp: () => appState.current,
|
|
useOptionalApp: () => appState.current,
|
|
RetainedAppState: ({ children }: { children: unknown }) => children
|
|
}));
|
|
vi.mock('../../src/remediation/use-work-packages', () => ({ useWorkPackages: vi.fn() }));
|
|
vi.mock('../../src/features/project-import/DataHub', () => ({ DataHub: () => <div>Data Hub</div> }));
|
|
vi.mock('../../src/features/dashboard/DashboardPanel', () => ({ DashboardPanel: () => <div data-testid="client-info">Synthetic Client Info</div> }));
|
|
vi.mock('../../src/features/db-structure/DbStructurePanel', () => ({ DbStructurePanel: () => null }));
|
|
vi.mock('../../src/features/results-browser/ResultsBrowser', () => ({ ResultsBrowser: () => null }));
|
|
vi.mock('../../src/features/search-filter/SearchFilterBar', () => ({
|
|
SearchFilterBar: () => null, SearchTextControl: () => null, SheetAndSearchControls: () => null, WorkTimeslotControl: () => null
|
|
}));
|
|
vi.mock('../../src/features/search-filter/use-filtered-records', () => ({ useFilteredRecords: () => [] }));
|
|
// Work stub: the real ring and focus provider decide what is shown; the stub only reports it.
|
|
vi.mock('../../src/features/dataset-viewer/DatasetViewer', async () => {
|
|
const related = await import('../../src/features/related-findings/RelatedFindings');
|
|
return { DatasetViewer: () => {
|
|
const context = related.useRelatedFindings();
|
|
const sheet = (appState.current.activeSheet as { analysis: { records: Array<{ id: string; title: string }> } });
|
|
const shown = context?.selection ? sheet.analysis.records.filter(record => context.selection!.group.recordIds.includes(record.id)) : sheet.analysis.records;
|
|
return <div data-testid="work" data-focused={Boolean(context?.selection) || undefined}>
|
|
{shown.map(record => <article key={record.id} className="record-card">{record.title}<related.RelatedFindingRing record={record as never}/></article>)}
|
|
</div>;
|
|
} };
|
|
});
|
|
vi.mock('../../src/features/mitre-graph/ObsidianGraphView', () => ({ ObsidianGraphView: () => null }));
|
|
vi.mock('../../src/features/actionlist/ActionlistControls', () => ({ ActionlistControls: () => null }));
|
|
vi.mock('../../src/features/import-workflow/ImportWorkflowDialog', () => ({ ImportWorkflowDialog: () => null }));
|
|
vi.mock('../../src/features/sidebar/SidebarSecurityDomains', () => ({ SidebarSecurityDomains: () => null }));
|
|
vi.mock('../../src/features/view-bookmarks/ViewBookmarks', () => ({ ViewBookmarks: () => null }));
|
|
vi.mock('../../src/features/mitre-graph/PathfinderFilterTrail', () => ({ PathfinderFilterTrail: () => null }));
|
|
vi.mock('../../src/features/about/AboutDialog', () => ({ AboutDialog: () => null }));
|
|
|
|
import { App } from '../../src/app/App';
|
|
import { RelatedFindingsProvider } from '../../src/features/related-findings/RelatedFindings';
|
|
import { useWorkPackages } from '../../src/remediation/use-work-packages';
|
|
import { workRecordKey } from '../../src/remediation/work-package-index';
|
|
import { makeSheet } from '../../src/importers/sheet-factory';
|
|
|
|
afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks(); delete document.documentElement.dataset.theme; });
|
|
|
|
it.each(['dark', 'light'])('ends a same-change focus when another area is opened and stays in that area (%s)', async theme => {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => setTimeout(() => callback(0), 0));
|
|
vi.stubGlobal('cancelAnimationFrame', clearTimeout);
|
|
document.documentElement.dataset.theme = theme;
|
|
const sheet = makeSheet('Synthetic', [['Name', 'Status', 'Severity'],
|
|
['Synthetic template allows requester SAN', 'Failed', 'High'],
|
|
['Synthetic authentication template subject can be modified', 'Failed', 'High'],
|
|
['Synthetic unrelated finding', 'Failed', 'Low']], '', 'synthetic.csv', { isProjectSheet: true });
|
|
const records = sheet.analysis.records;
|
|
records.forEach((record, index) => { record.findingOccurrenceId = `synthetic-occurrence-${index}`; });
|
|
const pkg = { id: 'package', code: 'WP-01', title: 'Synthetic', clusterLabel: 'Synthetic', facts: { tasks: 1, findings: 2 },
|
|
tasks: [{ id: 'same-template-change', redundant: true, findingKeys: records.slice(0, 2).map(workRecordKey) }] };
|
|
vi.mocked(useWorkPackages).mockReturnValue({ status: 'ready', index: { packages: [pkg], packageFor: () => pkg,
|
|
unassigned: () => false, facet: { id: 'remediation:work-package', label: 'Work Package', values: [], valuesFor: () => [] } } } as never);
|
|
const applyViewBookmark = vi.fn();
|
|
const resetFilters = vi.fn();
|
|
const dataset = { sheets: [sheet], profileId: 'generic', fileName: 'Synthetic' };
|
|
appState.current = {
|
|
dataset, activeSheet: sheet, activeSheetId: sheet.id, reviews: {}, snapshot: () => null, theme, setTheme: vi.fn(),
|
|
delimiter: ',', setDelimiter: vi.fn(), view: 'records', setView: vi.fn(), actionOnly: false,
|
|
sidebarCollapsed: false, toggleSidebar: vi.fn(), toggleWorkspace: vi.fn(), backendStatus: 'connected',
|
|
targetProjectId: 'synthetic-project', activeScanRunIds: ['synthetic-run'], activeAssessmentIds: ['synthetic-slot'],
|
|
workScopeId: 'synthetic-scope', search: 'synthetic search', statusFilter: 'deviation', sortColumn: undefined, sortDirection: 'asc',
|
|
titleColumn: undefined, facetFilters: {}, pathFindingIds: [], selectedRecordId: records[2].id,
|
|
resetFilters, applyViewBookmark, setSelectedRecord: vi.fn(),
|
|
setPathFindingIds: vi.fn(), setStatusFilter: vi.fn(), setFacetFilter: vi.fn(), setActiveSheet: vi.fn(),
|
|
openProject: vi.fn(), setTargetProjectId: vi.fn(), importWorkflow: { phase: 'idle' }
|
|
};
|
|
const container = document.createElement('div'); document.body.append(container);
|
|
const root = createRoot(container);
|
|
const nav = (label: string) => [...container.querySelectorAll<HTMLButtonElement>('.sidebar-tab-btn')].find(button => button.textContent === label)!;
|
|
const work = () => container.querySelector<HTMLElement>('[data-testid="work"]');
|
|
try {
|
|
await act(async () => root.render(<RelatedFindingsProvider><App/></RelatedFindingsProvider>));
|
|
await act(async () => nav('Work').click());
|
|
expect(work()!.querySelectorAll('.record-card')).toHaveLength(3);
|
|
await act(async () => work()!.querySelector<HTMLButtonElement>('.related-finding-ring')!.click());
|
|
expect(work()!.dataset.focused).toBe('true');
|
|
expect(work()!.querySelectorAll('.record-card')).toHaveLength(2);
|
|
|
|
await act(async () => nav('Client Info').click());
|
|
await act(async () => new Promise(resolve => setTimeout(resolve, 0)));
|
|
// The focus ends, the previous Work state is restored and the chosen area stays open.
|
|
expect(applyViewBookmark).toHaveBeenCalledOnce();
|
|
expect(applyViewBookmark.mock.calls[0][0]).toMatchObject({ search: 'synthetic search', statusFilter: 'deviation', view: 'records', selectedRecordId: records[2].id });
|
|
expect(nav('Client Info').classList.contains('active')).toBe(true);
|
|
expect(container.querySelector('[data-testid="client-info"]')).not.toBeNull();
|
|
expect(!work() || Boolean(work()!.closest('[hidden]'))).toBe(true);
|
|
|
|
await act(async () => nav('Work').click());
|
|
expect(work()!.dataset.focused).toBeUndefined();
|
|
expect(work()!.querySelectorAll('.record-card')).toHaveLength(3);
|
|
expect(work()!.querySelectorAll('.related-finding-ring[aria-pressed="true"]')).toHaveLength(0);
|
|
} finally { await act(async () => root.unmount()); container.remove(); }
|
|
});
|