fix: prevent stale AI overview and ISO mapping state

Partial pick of 3f6259d: both hooks bind each response to its request scope and discard a late response for a previous selection. The Result window palette, MITRE path gradient removal and Defender styles of that commit are not taken.
This commit is contained in:
Binh 2026-10-02 11:31:25 +02:00
parent 841b5f9d02
commit 169fd6884d
5 changed files with 192 additions and 28 deletions

View File

@ -4223,6 +4223,7 @@ Verifiziert 2026-09-08 (Phase 7/13B): GPO-Quellenkorrektur fuer Work und Client
| 2026-10-06 | Phase 7: Data Hub Deviation/Passed now use the same assessed-percentage helper as Client Info; all six donuts state their population basis. 29 focused tests and synthetic Chrome Dark/Light checks passed; absolute counts, other population shares and assessment semantics retained. Client build, design and UI/API version checks passed. Nine out-of-scope report test failures, a report Local-only failure and report server type errors prevent full release verification; attempted 2.16.9 reverted to 2.16.8 pending successful production builds. | | 2026-10-06 | Phase 7: Data Hub Deviation/Passed now use the same assessed-percentage helper as Client Info; all six donuts state their population basis. 29 focused tests and synthetic Chrome Dark/Light checks passed; absolute counts, other population shares and assessment semantics retained. Client build, design and UI/API version checks passed. Nine out-of-scope report test failures, a report Local-only failure and report server type errors prevent full release verification; attempted 2.16.9 reverted to 2.16.8 pending successful production builds. |
| 2026-10-06 | Phase 7: Client Info Deviations percentage now divides by Passed + Deviations and labels Of assessed checks; zero assessed shows a dash while preserving absolute zero. Permanent UI/click invariance tests in both themes and synthetic Chrome presentation verified; unchanged counts, verdicts, scores and other donuts. Patch 2.16.8: both builds and version contracts passed; full run 1,936 passed/15 skipped with one stale API-version assertion corrected and passed in the 43-test focused rerun; Local-only/design checks passed. | | 2026-10-06 | Phase 7: Client Info Deviations percentage now divides by Passed + Deviations and labels Of assessed checks; zero assessed shows a dash while preserving absolute zero. Permanent UI/click invariance tests in both themes and synthetic Chrome presentation verified; unchanged counts, verdicts, scores and other donuts. Patch 2.16.8: both builds and version contracts passed; full run 1,936 passed/15 skipped with one stale API-version assertion corrected and passed in the 43-test focused rerun; Local-only/design checks passed. |
| 2026-10-06 | Phase 13B: GPO folder read diagnostics retain only safe error names, stages and counts, distinguish empty/denied/unreadable selections, preserve cancellation and wrap complete timeslot messages. 27 focused GPO UI tests and synthetic Chrome Dark/Light checks passed; both builds and version contracts verified for patch 2.16.7. Full run: 1,932 passed, 15 skipped, one general import timeout passed on separate rerun; Local-only/design checks passed. Customer-specific read failure remains unconfirmed; no customer path/data used, Phase 13B remains in progress. | | 2026-10-06 | Phase 13B: GPO folder read diagnostics retain only safe error names, stages and counts, distinguish empty/denied/unreadable selections, preserve cancellation and wrap complete timeslot messages. 27 focused GPO UI tests and synthetic Chrome Dark/Light checks passed; both builds and version contracts verified for patch 2.16.7. Full run: 1,932 passed, 15 skipped, one general import timeout passed on separate rerun; Local-only/design checks passed. Customer-specific read failure remains unconfirmed; no customer path/data used, Phase 13B remains in progress. |
| 2026-10-02 | Phase 7/13F: Aus `3f6259d` nur den Stale-State-Schutz uebernommen. `useAiOverview` und `useRegulatoryMapping` binden jede Antwort an ihren Anfrage-Scope (Client, Timeslot bzw. Result-DB-Zeile); eine verspaetete Antwort einer frueheren Auswahl wird verworfen. Zwei synthetische Interaktionstests. Die gemeinsame Result-Fenster-Farbpalette, die Entfernung des Pfadverlaufs in `MitreGraphView` und die Defender-Stile desselben Commits wurden auf Nutzerentscheidung nicht uebernommen. |
| 2026-10-02 | Phase 13F, ausdruecklicher Nutzerauftrag: ISO-Control-Mapping auf GPO-Baselinevergleiche erweitert. `policy-evaluation`-Zeilen werden wie Scannerfindings gemappt (Einzelmapping im Finding Reader und gefiltertes Result-DB-Batch); Query und Mapperkontext entstehen aus Titel, Guidance und Empfehlung nach der Result-DB-Disclosure-Projektion, der Kundenwert bleibt zurueckgehalten. Globaler Regelschluessel `GPOs:<technische Setting-ID>`, daher teilen alle Baselinepakete einer Einstellung ein Mapping. Work-GPO-Zeilen tragen dafuer `policyComparisonId` ihres aktuellen Vergleichs; historische Vergleiche bieten kein Mapping. Rohe GPO- und Environment-Observations bleiben ausgeschlossen (Batchanzeige `Excluded context rows`). Retrievalversion auf Nutzerentscheidung unveraendert; die fruehere Festlegung `nur Scanner-Findings` ist damit fuer GPO-Vergleiche aufgehoben. Der Commit `18c6180` (Hybrid-/Finding-only-Retrieval 1.2-1.6) wurde auf Nutzerentscheidung nicht uebernommen; es gilt weiter das einfache Dense-Retrieval. Synthetische Tests fuer Querygrenze, Einzel- und Batchmapping sowie die Work-Zeilenidentitaet; kein realer Mappinglauf gegen Qdrant/LiteLLM. | | 2026-10-02 | Phase 13F, ausdruecklicher Nutzerauftrag: ISO-Control-Mapping auf GPO-Baselinevergleiche erweitert. `policy-evaluation`-Zeilen werden wie Scannerfindings gemappt (Einzelmapping im Finding Reader und gefiltertes Result-DB-Batch); Query und Mapperkontext entstehen aus Titel, Guidance und Empfehlung nach der Result-DB-Disclosure-Projektion, der Kundenwert bleibt zurueckgehalten. Globaler Regelschluessel `GPOs:<technische Setting-ID>`, daher teilen alle Baselinepakete einer Einstellung ein Mapping. Work-GPO-Zeilen tragen dafuer `policyComparisonId` ihres aktuellen Vergleichs; historische Vergleiche bieten kein Mapping. Rohe GPO- und Environment-Observations bleiben ausgeschlossen (Batchanzeige `Excluded context rows`). Retrievalversion auf Nutzerentscheidung unveraendert; die fruehere Festlegung `nur Scanner-Findings` ist damit fuer GPO-Vergleiche aufgehoben. Der Commit `18c6180` (Hybrid-/Finding-only-Retrieval 1.2-1.6) wurde auf Nutzerentscheidung nicht uebernommen; es gilt weiter das einfache Dense-Retrieval. Synthetische Tests fuer Querygrenze, Einzel- und Batchmapping sowie die Work-Zeilenidentitaet; kein realer Mappinglauf gegen Qdrant/LiteLLM. |
| 2026-10-02 | Phase 7: Privacy-Safe-`Export JSON` um GPO-Vergleiche ergaenzt. Handlungsrelevante `policy-evaluation`-Findings tragen einen `policy`-Block (Status, Baseline-Familie, Sollwert, Vergleich, Kundenzustand, Scope, Delivery, Diagnosecodes) ausschliesslich aus der bestehenden Result-DB-Disclosure-Projektion; der Kundenwert bleibt zurueckgehalten. Rohe `policy-observation`-Zeilen zaehlen nicht mehr als Findings, sondern separat als `policyObservationResults`; `policyStatuses` zaehlt die GPO-Urteile. Synthetischer Test in `tests/exports/result-db-copy.test.ts`. | | 2026-10-02 | Phase 7: Privacy-Safe-`Export JSON` um GPO-Vergleiche ergaenzt. Handlungsrelevante `policy-evaluation`-Findings tragen einen `policy`-Block (Status, Baseline-Familie, Sollwert, Vergleich, Kundenzustand, Scope, Delivery, Diagnosecodes) ausschliesslich aus der bestehenden Result-DB-Disclosure-Projektion; der Kundenwert bleibt zurueckgehalten. Rohe `policy-observation`-Zeilen zaehlen nicht mehr als Findings, sondern separat als `policyObservationResults`; `policyStatuses` zaehlt die GPO-Urteile. Synthetischer Test in `tests/exports/result-db-copy.test.ts`. |
| 2026-10-02 | Phase 7: AI-Integration auf den aktuellen Michael-Stand uebernommen (Branch `merge/michael-binh`). Der Privacy-Safe-`Export JSON` aus `result-db-copy` ergaenzt die Result DB als vierte Exportaktion; `Export JSONL`, `Export Excel` und `Export CSV` bleiben unveraendert. Die Einzelaktions-Festlegung vom 2026-09-14 gilt auf diesem Stand deshalb nicht als Ersatz des JSONL-Exports. Die Beispieldateien `test.json`, `test-output.json` und `artifacts/test-output-*` wurden nicht uebernommen. | | 2026-10-02 | Phase 7: AI-Integration auf den aktuellen Michael-Stand uebernommen (Branch `merge/michael-binh`). Der Privacy-Safe-`Export JSON` aus `result-db-copy` ergaenzt die Result DB als vierte Exportaktion; `Export JSONL`, `Export Excel` und `Export CSV` bleiben unveraendert. Die Einzelaktions-Festlegung vom 2026-09-14 gilt auf diesem Stand deshalb nicht als Ersatz des JSONL-Exports. Die Beispieldateien `test.json`, `test-output.json` und `artifacts/test-output-*` wurden nicht uebernommen. |

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AssessorApiClient } from '../../api/assessor-api-client'; import { AssessorApiClient } from '../../api/assessor-api-client';
import type { AiOverviewResponse, AiOverviewResult } from '../../domain/ai-overview'; import type { AiOverviewResponse, AiOverviewResult } from '../../domain/ai-overview';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
@ -14,7 +14,7 @@ export interface AiOverviewController {
} }
export function useAiOverview(projectId: string | undefined, assessmentIds: string[] = [], scanRunIds: string[] = []): AiOverviewController { export function useAiOverview(projectId: string | undefined, assessmentIds: string[] = [], scanRunIds: string[] = []): AiOverviewController {
const [response, setResponse] = useState<AiOverviewResponse>(); const [storedResponse, setStoredResponse] = useState<{ scope: { key: string }; value: AiOverviewResponse }>();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [generating, setGenerating] = useState(false); const [generating, setGenerating] = useState(false);
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
@ -23,21 +23,35 @@ export function useAiOverview(projectId: string | undefined, assessmentIds: stri
const normalizedAssessmentIds = useMemo(() => assessmentKey ? assessmentKey.split('\u0000') : [], [assessmentKey]); const normalizedAssessmentIds = useMemo(() => assessmentKey ? assessmentKey.split('\u0000') : [], [assessmentKey]);
const normalizedScanRunIds = useMemo(() => scanRunKey ? scanRunKey.split('\u0000') : [], [scanRunKey]); const normalizedScanRunIds = useMemo(() => scanRunKey ? scanRunKey.split('\u0000') : [], [scanRunKey]);
const scopeKey = `${projectId ?? ''}\u0001${assessmentKey}\u0001${scanRunKey}`; const scopeKey = `${projectId ?? ''}\u0001${assessmentKey}\u0001${scanRunKey}`;
const activeScope = useRef({ key: scopeKey });
if (activeScope.current.key !== scopeKey) activeScope.current = { key: scopeKey };
const requestScope = activeScope.current;
const response = storedResponse?.scope === requestScope ? storedResponse.value : undefined;
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
if (!projectId || (!normalizedAssessmentIds.length && !normalizedScanRunIds.length)) { setResponse(undefined); return; } if (!projectId || (!normalizedAssessmentIds.length && !normalizedScanRunIds.length)) {
if (activeScope.current === requestScope) setStoredResponse(undefined);
return;
}
try { try {
const next = await assessorApi.getAiOverview(projectId, normalizedAssessmentIds, normalizedScanRunIds); const next = await assessorApi.getAiOverview(projectId, normalizedAssessmentIds, normalizedScanRunIds);
setResponse(next); if (activeScope.current !== requestScope) return;
setStoredResponse({ scope: requestScope, value: next });
setError(undefined); setError(undefined);
} catch (cause) { } catch (cause) {
if (activeScope.current !== requestScope) return;
setError(cause instanceof Error ? cause.message : 'AI overview could not be loaded.'); setError(cause instanceof Error ? cause.message : 'AI overview could not be loaded.');
setResponse(current => current && ['queued', 'running'].includes(current.state) ? { ...current } : current); setStoredResponse(current => current?.scope === requestScope && ['queued', 'running'].includes(current.value.state)
? { ...current, value: { ...current.value } }
: current);
} }
}, [normalizedAssessmentIds, normalizedScanRunIds, projectId]); }, [normalizedAssessmentIds, normalizedScanRunIds, projectId, requestScope]);
useEffect(() => { useEffect(() => {
let active = true; let active = true;
setStoredResponse(undefined);
setError(undefined);
setGenerating(false);
setLoading(true); setLoading(true);
void refresh().finally(() => { if (active) setLoading(false); }); void refresh().finally(() => { if (active) setLoading(false); });
return () => { active = false; }; return () => { active = false; };
@ -54,17 +68,20 @@ export function useAiOverview(projectId: string | undefined, assessmentIds: stri
setGenerating(true); setGenerating(true);
setError(undefined); setError(undefined);
try { try {
setResponse(await assessorApi.generateAiOverview(projectId, { const next = await assessorApi.generateAiOverview(projectId, {
assessmentIds: normalizedAssessmentIds, assessmentIds: normalizedAssessmentIds,
scanRunIds: normalizedScanRunIds, scanRunIds: normalizedScanRunIds,
idempotencyKey: createIdempotencyKey() idempotencyKey: createIdempotencyKey()
})); });
if (activeScope.current === requestScope) setStoredResponse({ scope: requestScope, value: next });
} catch (cause) { } catch (cause) {
if (activeScope.current === requestScope) {
setError(cause instanceof Error ? cause.message : 'AI overview generation could not be started.'); setError(cause instanceof Error ? cause.message : 'AI overview generation could not be started.');
} finally {
setGenerating(false);
} }
}, [normalizedAssessmentIds, normalizedScanRunIds, projectId]); } finally {
if (activeScope.current === requestScope) setGenerating(false);
}
}, [normalizedAssessmentIds, normalizedScanRunIds, projectId, requestScope]);
return { response, loading, generating, error, generate }; return { response, loading, generating, error, generate };
} }

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { BackendStatus } from '../../app/app-state'; import type { BackendStatus } from '../../app/app-state';
import type { FindingRecord } from '../../domain/finding'; import type { FindingRecord } from '../../domain/finding';
import { AssessorApiClient } from '../../api/assessor-api-client'; import { AssessorApiClient } from '../../api/assessor-api-client';
@ -17,29 +17,37 @@ export function useRegulatoryMapping(
backendStatus: BackendStatus backendStatus: BackendStatus
) { ) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [response, setResponse] = useState<RegulatoryMappingResponse>(); const [storedResponse, setStoredResponse] = useState<{ scope: { key: string }; value: RegulatoryMappingResponse }>();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const scopeKey = `${projectId}\u0000${resultProjectionId}\u0000${backendStatus}`;
const activeScope = useRef({ key: scopeKey });
if (activeScope.current.key !== scopeKey) activeScope.current = { key: scopeKey };
const requestScope = activeScope.current;
const response = storedResponse?.scope === requestScope ? storedResponse.value : undefined;
useEffect(() => { useEffect(() => {
setOpen(false); setOpen(false);
setResponse(undefined); setStoredResponse(undefined);
setLoading(false); setLoading(false);
setError(''); setError('');
}, [projectId, resultProjectionId]); }, [scopeKey]);
const load = useCallback(async () => { const load = useCallback(async () => {
if (!projectId || !resultProjectionId || backendStatus !== 'connected') return; if (!projectId || !resultProjectionId || backendStatus !== 'connected' || activeScope.current !== requestScope) return;
setLoading(true); setLoading(true);
try { try {
setResponse(await assessorApi.getRegulatoryMappings(projectId, resultProjectionId)); const next = await assessorApi.getRegulatoryMappings(projectId, resultProjectionId);
if (activeScope.current !== requestScope) return;
setStoredResponse({ scope: requestScope, value: next });
setError(''); setError('');
} catch (reason) { } catch (reason) {
if (activeScope.current !== requestScope) return;
setError(reason instanceof Error ? reason.message : 'Regulatory mapping could not be loaded.'); setError(reason instanceof Error ? reason.message : 'Regulatory mapping could not be loaded.');
} finally { } finally {
setLoading(false); if (activeScope.current === requestScope) setLoading(false);
} }
}, [backendStatus, projectId, resultProjectionId]); }, [backendStatus, projectId, requestScope, resultProjectionId]);
useEffect(() => { if (open && !response && !loading && !error) void load(); }, [error, load, loading, open, response]); useEffect(() => { if (open && !response && !loading && !error) void load(); }, [error, load, loading, open, response]);
useEffect(() => { useEffect(() => {
@ -49,33 +57,43 @@ export function useRegulatoryMapping(
}, [load, open, response]); }, [load, open, response]);
const start = useCallback(async () => { const start = useCallback(async () => {
if (!projectId || !resultProjectionId) return; if (!projectId || !resultProjectionId || activeScope.current !== requestScope) return;
setLoading(true); setLoading(true);
try { try {
setResponse(await assessorApi.generateRegulatoryMappings(projectId, { resultProjectionId })); const next = await assessorApi.generateRegulatoryMappings(projectId, { resultProjectionId });
if (activeScope.current !== requestScope) return;
setStoredResponse({ scope: requestScope, value: next });
setError(''); setError('');
} catch (reason) { } catch (reason) {
if (activeScope.current !== requestScope) return;
setError(reason instanceof Error ? reason.message : 'Regulatory mapping could not be started.'); setError(reason instanceof Error ? reason.message : 'Regulatory mapping could not be started.');
} finally { setLoading(false); } } finally {
}, [projectId, resultProjectionId]); if (activeScope.current === requestScope) setLoading(false);
}
}, [projectId, requestScope, resultProjectionId]);
const decide = useCallback(async (candidate: RegulatoryMappingCandidate, decision: RegulatoryMappingDecisionValue, comment?: string) => { const decide = useCallback(async (candidate: RegulatoryMappingCandidate, decision: RegulatoryMappingDecisionValue, comment?: string) => {
if (!projectId || !resultProjectionId || !response?.run) return; if (!projectId || !resultProjectionId || !response?.run || activeScope.current !== requestScope) return;
const latest = response.decisions.find(item => item.controlId === candidate.controlId); const latest = response.decisions.find(item => item.controlId === candidate.controlId);
setLoading(true); setLoading(true);
try { try {
setResponse(await assessorApi.saveRegulatoryMappingDecision(projectId, { const next = await assessorApi.saveRegulatoryMappingDecision(projectId, {
resultProjectionId, resultProjectionId,
controlId: candidate.controlId, controlId: candidate.controlId,
decision, decision,
...(comment?.trim() ? { comment: comment.trim() } : {}), ...(comment?.trim() ? { comment: comment.trim() } : {}),
...(latest ? { expectedLatestDecisionId: latest.id } : {}) ...(latest ? { expectedLatestDecisionId: latest.id } : {})
})); });
if (activeScope.current !== requestScope) return;
setStoredResponse({ scope: requestScope, value: next });
setError(''); setError('');
} catch (reason) { } catch (reason) {
if (activeScope.current !== requestScope) return;
setError(reason instanceof Error ? reason.message : 'The mapping decision could not be saved.'); setError(reason instanceof Error ? reason.message : 'The mapping decision could not be saved.');
} finally { setLoading(false); } } finally {
}, [projectId, response, resultProjectionId]); if (activeScope.current === requestScope) setLoading(false);
}
}, [projectId, requestScope, response, resultProjectionId]);
const acceptedCount = useMemo(() => { const acceptedCount = useMemo(() => {
const latest = new Map<string, RegulatoryMappingDecisionValue>(); const latest = new Map<string, RegulatoryMappingDecisionValue>();

View File

@ -0,0 +1,61 @@
// @vitest-environment jsdom
import { act, type ReactNode } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, expect, it, vi } from 'vitest';
import { AssessorApiClient } from '../../src/api/assessor-api-client';
import type { AiOverviewResponse } from '../../src/domain/ai-overview';
import { useAiOverview } from '../../src/features/dashboard/AiOverviewPanel';
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>(next => { resolve = next; });
return { promise, resolve };
}
function response(projectId: string): AiOverviewResponse {
return {
version: 1,
projectId,
scope: { assessmentIds: [`assessment-${projectId}`], scanRunIds: [] },
generationEnabled: true,
state: 'empty',
stale: false
};
}
function HookProbe({ projectId }: { projectId: string }): ReactNode {
const controller = useAiOverview(projectId, [`assessment-${projectId}`]);
return <output>{controller.response?.projectId ?? 'empty'}</output>;
}
it('does not show a previous project response when it resolves after the active project', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const first = deferred<AiOverviewResponse>();
const second = deferred<AiOverviewResponse>();
const request = vi.spyOn(AssessorApiClient.prototype, 'getAiOverview').mockImplementation(projectId =>
projectId === 'project-a' ? first.promise : second.promise);
const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);
try {
await act(async () => root.render(<HookProbe projectId="project-a" />));
await act(async () => root.render(<HookProbe projectId="project-b" />));
expect(request).toHaveBeenCalledTimes(2);
await act(async () => { second.resolve(response('project-b')); await Promise.resolve(); });
expect(container.textContent).toBe('project-b');
await act(async () => { first.resolve(response('project-a')); await Promise.resolve(); });
expect(container.textContent).toBe('project-b');
} finally {
await act(async () => root.unmount());
container.remove();
}
});

View File

@ -0,0 +1,67 @@
// @vitest-environment jsdom
import { act, type ReactNode } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, expect, it, vi } from 'vitest';
import { AssessorApiClient } from '../../src/api/assessor-api-client';
import type { RegulatoryMappingResponse } from '../../src/domain/regulatory-mapping';
import { useRegulatoryMapping } from '../../src/features/finding-reader/use-regulatory-mapping';
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>(next => { resolve = next; });
return { promise, resolve };
}
function response(projectId: string, occurrenceId: string): RegulatoryMappingResponse {
return {
version: 1,
projectId,
resultProjectionId: `scanner-finding:${occurrenceId}`,
generationEnabled: true,
state: 'empty',
decisions: []
};
}
function HookProbe({ occurrenceId }: { occurrenceId: string }): ReactNode {
const controller = useRegulatoryMapping('project-1', `scanner-finding:${occurrenceId}`, 'connected');
return <>
<button type="button" onClick={() => controller.setOpen(true)}>Open mapping</button>
<output>{controller.response?.resultProjectionId ?? 'empty'}</output>
</>;
}
it('does not show a previous finding mapping when it resolves after the active finding', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const first = deferred<RegulatoryMappingResponse>();
const second = deferred<RegulatoryMappingResponse>();
const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryMappings').mockImplementation((_projectId, resultProjectionId) =>
resultProjectionId === 'scanner-finding:occurrence-a' ? first.promise : second.promise);
const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);
try {
await act(async () => root.render(<HookProbe occurrenceId="occurrence-a" />));
await act(async () => container.querySelector<HTMLButtonElement>('button')!.click());
await act(async () => root.render(<HookProbe occurrenceId="occurrence-b" />));
await act(async () => container.querySelector<HTMLButtonElement>('button')!.click());
expect(request).toHaveBeenCalledTimes(2);
await act(async () => { second.resolve(response('project-1', 'occurrence-b')); await Promise.resolve(); });
expect(container.textContent).toContain('scanner-finding:occurrence-b');
await act(async () => { first.resolve(response('project-1', 'occurrence-a')); await Promise.resolve(); });
expect(container.textContent).toContain('scanner-finding:occurrence-b');
expect(container.textContent).not.toContain('scanner-finding:occurrence-a');
} finally {
await act(async () => root.unmount());
container.remove();
}
});