Assessor_2.0/tests/components/activity-center.test.tsx

251 lines
17 KiB
TypeScript

// @vitest-environment jsdom
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, expect, it, vi } from 'vitest';
import { ActivityCenter } from '../../src/components/ActivityCenter';
import { activitySnapshot, activityStatusSnapshot, beginActivity, clearFinishedActivities, clearCompletedActivities, dismissActivity, trackActivity } from '../../src/platform/activity-store';
import { AssessorApiClient } from '../../src/api/assessor-api-client';
import { requestActivity } from '../../src/api/request-activity';
afterEach(() => { clearFinishedActivities(); vi.useRealTimers(); vi.unstubAllGlobals(); });
it('shows concurrent real API operations and preserves failures while other work completes', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT',true);
const pending: ((r:Response)=>void)[]=[];
const api=new AssessorApiClient({fetcher:vi.fn(()=>new Promise<Response>(resolve=>pending.push(resolve)))});
const container=document.createElement('div');document.body.append(container);const root=createRoot(container);
try {
await act(async()=>root.render(<ActivityCenter/>));
let core!:Promise<unknown>;let context!:Promise<unknown>;let report!:Promise<unknown>;
await act(async()=>{
core=api.loadAssessmentModel('private-client');
context=api.getAssessmentContext('private-client', ['private-slot'], []);
report=api.createReportRun('private-client',{kind:'technical',assessmentIds:['private-slot']}).catch(()=>undefined);
});
expect(container.textContent).toContain('3 running');
for(const area of ['Core Engine','Context Engine','Report Engine']) expect(container.textContent).toContain(area);
expect(container.textContent).not.toContain('private-client');
await act(async()=>{pending[1](new Response('{}'));await context;});
expect(container.textContent).toContain('2 running');expect(container.textContent).toContain('Completed');
await act(async()=>{pending[2](new Response('{}',{status:500}));await report;});
expect(container.textContent).toContain('Failed');expect(container.textContent).toContain('1 running');
await act(async()=>{pending[0](new Response('{}'));await core;});
expect(container.textContent).toContain('Not ready');
const toggle=container.querySelector<HTMLButtonElement>('button[aria-controls]')!;
await act(async()=>toggle.click());expect(container.querySelector('ul')).toBeNull();
await act(async()=>container.querySelector<HTMLButtonElement>('.activity-minimized button')!.click());expect(container.querySelectorAll('li')).toHaveLength(3);
await act(async()=>[...container.querySelectorAll('button')].find(b=>b.textContent==='Clear completed')!.click());
expect(container.textContent).toContain('Not ready');
expect(container.querySelectorAll('li')).toHaveLength(1);
await act(async()=>container.querySelector<HTMLButtonElement>('button[aria-label^="Dismiss"]')!.click());
expect(container.textContent!.toLowerCase()).toContain('ready');
} finally {await act(async()=>root.unmount());container.remove();}
});
it('keeps failure signals during concurrent work and after the success history limit', () => {
const failure = beginActivity('Report Engine', 'Generating report'); failure.fail();
const cancelled = beginActivity('Core Engine', 'Cancelled request'); cancelled.cancel();
const active = beginActivity('Core Engine', 'Opening client');
for (let i = 0; i < 60; i++) beginActivity('Core Engine', 'Loading filters').complete();
clearCompletedActivities();
expect(activitySnapshot().map(item => item.state)).toEqual(['failed', 'cancelled', 'running']);
dismissActivity(activitySnapshot().at(-1)!.id);
expect(activitySnapshot()).toHaveLength(3);
active.complete();
});
it('groups history repeats and separates final readiness from history', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
for (let i = 0; i < 3; i++) beginActivity('Core Engine', 'Loading reference recognition').complete();
beginActivity('Context Engine', 'Loading reference recognition').complete();
const container = document.createElement('div'); const root = createRoot(container);
try {
await act(async () => root.render(<ActivityCenter />));
expect(container.querySelector('#activity-items')).not.toBeNull();
await act(async () => [...container.querySelectorAll('button')].find(b => b.textContent === 'History (4)')!.click());
expect(container.querySelector<HTMLDetailsElement>('#activity-history')!.open).toBe(true);
const group = container.querySelector<HTMLDetailsElement>('.activity-group details')!;
expect(group.textContent).toContain('3 completed');
expect(group.querySelectorAll('li')).toHaveLength(3);
expect(group.open).toBe(false);
await act(async () => { group.open = true; group.dispatchEvent(new Event('toggle')); });
await act(async () => beginActivity('Core Engine', 'Loading filters').complete());
expect(container.querySelector<HTMLDetailsElement>('#activity-history')!.open).toBe(false);
expect(container.textContent).toContain('Ready');
expect(container.textContent).toContain('<1s');
await act(async () => [...container.querySelectorAll('button')].find(b => b.textContent === 'Clear completed')!.click());
expect(container.textContent!.toLowerCase()).toContain('ready');
} finally { await act(async () => root.unmount()); }
});
it.each(['dark', 'light'])('shows recovered Ready, minimizes exactly after five seconds and reopens for new work in %s', async theme => {
vi.useFakeTimers(); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
document.documentElement.dataset.theme = theme;
const failed = beginActivity('Core Engine', 'Loading MITRE catalog'); failed.fail();
const container = document.createElement('div'); const root = createRoot(container);
try {
await act(async () => root.render(<ActivityCenter/>));
expect(container.querySelector('[role="status"]')!.textContent).toContain('Not ready');
expect(container.querySelector('.activity-ready-dot')).toBeNull();
expect(container.querySelector('[aria-label="Minimize activity"]')).toBeNull();
await act(async () => beginActivity('Core Engine', 'Loading MITRE catalog').complete());
expect(container.querySelector('[role="status"]')!.textContent).toBe('Ready');
expect(container.querySelector('.activity-toggle .activity-ready-dot')!.getAttribute('aria-hidden')).toBe('true');
expect(container.querySelector('.activity-header-actions')!.lastElementChild!.getAttribute('aria-label')).toBe('Minimize activity');
expect(container.querySelector<HTMLDetailsElement>('#activity-history')!.open).toBe(false);
expect(activitySnapshot().some(item => item.state === 'failed')).toBe(true);
await act(async () => vi.advanceTimersByTime(4999));
expect(container.querySelector('#activity-items')).not.toBeNull();
await act(async () => vi.advanceTimersByTime(1));
expect(container.querySelector('#activity-items')).toBeNull();
expect(container.querySelector('.activity-center')).toBeNull();
expect(container.querySelectorAll('button')).toHaveLength(1);
expect(container.querySelector('button')!.textContent).toBe('Ready');
expect(container.querySelector('.activity-minimized .activity-ready-dot')).not.toBeNull();
await act(async () => container.querySelector<HTMLButtonElement>('button')!.click());
expect(container.querySelector<HTMLDetailsElement>('#activity-history')!.open).toBe(true);
expect(container.textContent).toContain('Failed');
await act(async () => vi.advanceTimersByTime(6000));
expect(container.querySelector('#activity-items')).not.toBeNull();
const retainedHistory = activitySnapshot();
await act(async () => container.querySelector<HTMLButtonElement>('[aria-label="Minimize activity"]')!.click());
expect(container.querySelector('.activity-center')).toBeNull();
expect(container.querySelectorAll('button')).toHaveLength(1);
expect(container.querySelector('.activity-ready-dot')).not.toBeNull();
expect(activitySnapshot()).toBe(retainedHistory);
await act(async () => container.querySelector<HTMLButtonElement>('.activity-minimized button')!.click());
expect(container.querySelector<HTMLDetailsElement>('#activity-history')!.open).toBe(true);
expect(container.textContent).toContain('Failed');
let pending!: ReturnType<typeof beginActivity>;
await act(async () => { pending = beginActivity('Core Engine', 'Loading filters'); pending.queue(); });
expect(container.querySelector('[role="status"]')!.textContent).toBe('1 waiting');
expect(container.querySelector('.activity-ready-dot')).toBeNull();
expect(container.querySelector('[aria-label="Minimize activity"]')).toBeNull();
await act(async () => vi.advanceTimersByTime(6000));
expect(container.querySelector('#activity-items')).not.toBeNull();
await act(async () => { pending.run(); pending.complete(); });
expect(container.querySelector('[role="status"]')!.textContent).toBe('Ready');
await act(async () => vi.advanceTimersByTime(5000));
expect(container.querySelector('#activity-items')).toBeNull();
await act(async () => { pending = beginActivity('Core Engine', 'Loading metrics'); });
expect(container.querySelector('.activity-minimized')).toBeNull();
expect(container.querySelector('.activity-center')).not.toBeNull();
expect(container.querySelector('[role="status"]')!.textContent).toBe('1 running');
await act(async () => pending.complete());
} finally { await act(async () => root.unmount()); delete document.documentElement.dataset.theme; }
});
it('does not hide unfinished work at an old Ready deadline or let dismissing failures manufacture readiness', async () => {
vi.useFakeTimers(); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
beginActivity('Core Engine', 'Loading catalog').complete();
const container = document.createElement('div'); const root = createRoot(container);
try {
await act(async () => root.render(<ActivityCenter/>));
await act(async () => vi.advanceTimersByTime(4000));
let pending!: ReturnType<typeof beginActivity>;
await act(async () => { pending = beginActivity('Core Engine', 'Opening client'); });
await act(async () => vi.advanceTimersByTime(2000));
expect(container.querySelector('#activity-items')).not.toBeNull();
expect(container.querySelector('[role="status"]')!.textContent).toBe('1 running');
await act(async () => pending.fail());
const failure = activitySnapshot().find(item => item.state === 'failed')!;
await act(async () => { dismissActivity(failure.id); clearCompletedActivities(); });
await act(async () => vi.advanceTimersByTime(6000));
expect(container.querySelector('[role="status"]')!.textContent).toContain('Not ready');
expect(container.querySelector('#activity-items')).not.toBeNull();
} finally { await act(async () => root.unmount()); }
});
it('matches recovery to the exact API request scope and survives success-history pruning', async () => {
const api = new AssessorApiClient({ fetcher: vi.fn(async () => new Response('{}', { status: 500 })) });
await expect(api.loadAssessmentModel('client-a')).rejects.toThrow();
const success = new AssessorApiClient({ fetcher: vi.fn(async () => new Response('{}')) });
await success.loadAssessmentModel('client-b');
expect(activityStatusSnapshot().ready).toBe(false);
await success.loadAssessmentModel('client-a');
expect(activityStatusSnapshot().ready).toBe(true);
for (let i = 0; i < 60; i++) beginActivity('Core Engine', 'Short operation').complete();
clearCompletedActivities();
expect(activityStatusSnapshot().ready).toBe(true);
expect(activitySnapshot().some(item => item.state === 'failed')).toBe(true);
});
it('keeps manual expansion through completion and distinguishes queued, cancelled and failed operations', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const running = beginActivity('Core Engine', 'Opening client');
const queued = beginActivity('Context Engine', 'Loading context'); queued.queue();
const failed = beginActivity('Report Engine', 'Generating report'); failed.fail();
const cancelled = beginActivity('Core Engine', 'Loading metrics'); cancelled.cancel();
const container = document.createElement('div'); const root = createRoot(container);
try {
await act(async () => root.render(<ActivityCenter />));
expect(container.querySelector('[role="status"]')!.textContent).toBe('1 running · 1 waiting');
expect(container.querySelector('section')!.getAttribute('aria-label')).toBe('Running and waiting');
const toggle = container.querySelector<HTMLButtonElement>('.activity-toggle')!;
await act(async () => toggle.click());
await act(async () => container.querySelector<HTMLButtonElement>('.activity-minimized button')!.click());
await act(async () => { running.complete(); queued.complete(); });
expect(container.querySelector('#activity-items')).not.toBeNull();
expect(container.textContent).toContain('Cancelled');
await act(async () => [...container.querySelectorAll('button')].find(b => b.getAttribute('aria-label') === 'Dismiss Generating report')!.click());
expect(container.textContent).not.toContain('Generating report');
expect(container.textContent).toContain('Loading metrics');
} finally { await act(async () => root.unmount()); }
});
it('never changes operation results and marks aborts as cancelled',async()=>{
const failure=new DOMException('Cancelled','AbortError');
await expect(trackActivity('Future Engine','Loading future data',async()=>{throw failure;})).rejects.toBe(failure);
expect(activitySnapshot().at(-1)?.state).toBe('cancelled');
const op=beginActivity('Core Engine','Opening client');op.update('Preparing findings');op.fail();op.complete();
expect(activitySnapshot().at(-1)).toMatchObject({state:'failed',label:'Preparing findings'});
});
it('retains active tasks when clearing or bounding completed history',()=>{
const active=beginActivity('Core Engine','Long-running operation');active.queue();
expect(activitySnapshot().at(-1)?.state).toBe('queued');
for(let i=0;i<60;i++) beginActivity('Core Engine','Short operation').complete();
expect(activitySnapshot()).toHaveLength(41);clearFinishedActivities();expect(activitySnapshot()).toHaveLength(1);active.complete();
});
it('names catalog updates and omits background heartbeat requests',()=>{
expect(requestActivity('/health')).toBeUndefined();
expect(requestActivity('/reference-data/mitre-attack/refresh','POST')?.label).toBe('Updating MITRE catalog');
expect(requestActivity('/reference-data/mitre-attack/source')?.label).toBe('Downloading MITRE source');
expect(requestActivity('/reference-data/policy-evaluation-rule-catalog')?.label).toContain('catalog');
expect(requestActivity('/future?secret=private')?.label).toBe('Loading application data');
});
it('allows future engines to describe operations without importing them into Core',async()=>{
const api=new AssessorApiClient({fetcher:vi.fn(async()=>new Response('{}')),describeActivity:()=>({area:'Future Engine',label:'Loading future data'})});
await api.listProjects();
expect(activitySnapshot().at(-1)).toMatchObject({area:'Future Engine',label:'Loading future data',state:'completed'});
});
it.each(['dark', 'light'])('reloads from incomplete expanded and minimized Activity but hides reload while running or recovered in %s', async theme => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
document.documentElement.dataset.theme = theme;
const container = document.createElement('div'); const root = createRoot(container); const onReload = vi.fn();
const operation = beginActivity('Core Engine', 'Loading synthetic client');
try {
await act(async () => root.render(<ActivityCenter onReload={onReload}/>));
expect(container.querySelector('[aria-label="Reload application"]')).toBeNull();
await act(async () => operation.fail());
expect(container.textContent).toContain('Not ready · Loading incomplete');
const original = JSON.stringify(activitySnapshot());
const reload = container.querySelector<HTMLButtonElement>('[aria-label="Reload application"]')!;
expect(reload.querySelector('svg')).not.toBeNull();
expect(reload.closest('.activity-header-actions')).not.toBeNull();
await act(async () => reload.click());
expect(onReload).toHaveBeenCalledOnce();
expect(JSON.stringify(activitySnapshot())).toBe(original);
await act(async () => container.querySelector<HTMLButtonElement>('.activity-toggle')!.click());
const compactReload = container.querySelector<HTMLButtonElement>('.activity-minimized [aria-label="Reload application"]')!;
await act(async () => compactReload.click());
expect(onReload).toHaveBeenCalledTimes(2);
let recovery!: ReturnType<typeof beginActivity>;
await act(async () => { recovery = beginActivity('Core Engine', 'Loading synthetic client'); });
expect(container.querySelector('[aria-label="Reload application"]')).toBeNull();
await act(async () => recovery.complete());
expect(container.textContent).toContain('Ready');
expect(container.querySelector('[aria-label="Reload application"]')).toBeNull();
expect(activitySnapshot().some(item => item.state === 'failed')).toBe(true);
} finally { await act(async () => root.unmount()); }
});