Skip to content

Commit 28d883c

Browse files
committed
fix(frontend): complete cancel-finalize contract — clear stale report on cancel
Codex round-2 P2 on PR #700: a tab opened mid-scan loads a previous report, and a cancelled scan may produce none — so finalizeScan must also CLEAR the rendered report on cancel, not just skip the reload. Otherwise the Security tab renders a stale report after a cancel. Add a clearReport effect to scanFinalizeEffects (true for cancel) and extract applyScanFinalize() — the ordered side-effect applier — out of the component so the full contract is test-driven against fake handlers. finalizeScan now wires its refs/stores into applyScanFinalize. Full cancel contract now: stopScanPolling + clearScanRunState (spinner off), clear scanReport (no stale report), refresh server store (status not 'scanning'), 'Scan cancelled' toast, NO success toast, NO report reload. New behavioral test: tab-open-mid-scan stale report → cancelled via poll → asserts scanLoading=false, scanReport cleared, store refreshed, no success toast. plus success/error contract tests. Related #700 Related MCP-2755
1 parent bd8b05c commit 28d883c

3 files changed

Lines changed: 153 additions & 28 deletions

File tree

frontend/src/utils/scanState.ts

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,13 @@ export function decideScanReconcile(
102102
export interface ScanFinalizeEffects {
103103
/** Set the scan-error banner and stop (failed/errored job). */
104104
setError: boolean
105+
/**
106+
* Clear the currently-rendered report. Required for cancellation: a tab opened
107+
* mid-scan may have a previous (stale) report loaded, and a cancelled scan may
108+
* have produced none — so the stale one must be dropped rather than left on
109+
* screen or reloaded (MCP-2755).
110+
*/
111+
clearReport: boolean
105112
/** Reload the report (only for a successful scan that produced one). */
106113
reloadReport: boolean
107114
/**
@@ -130,15 +137,55 @@ export function scanFinalizeEffects(
130137
wasLoading: boolean,
131138
): ScanFinalizeEffects {
132139
if (decision.isError) {
133-
// Failed/errored: surface the error and short-circuit (no reload / refresh / toast).
134-
return { setError: true, reloadReport: false, refreshServers: false, successToast: false, cancelledToast: false }
140+
// Failed/errored: surface the error and short-circuit (no clear / reload / refresh / toast).
141+
return { setError: true, clearReport: false, reloadReport: false, refreshServers: false, successToast: false, cancelledToast: false }
135142
}
136143
if (decision.isCancelled) {
137-
// Terminal but NOT a success: refresh the store so the cancelled status clears
138-
// the spinner, but do NOT reload a report that may be stale or absent.
139-
return { setError: false, reloadReport: false, refreshServers: true, successToast: false, cancelledToast: wasLoading }
144+
// Terminal but NOT a success: drop any stale report and refresh the store so the
145+
// cancelled status clears the spinner — but do NOT reload a report that may be
146+
// stale or absent.
147+
return { setError: false, clearReport: true, reloadReport: false, refreshServers: true, successToast: false, cancelledToast: wasLoading }
140148
}
141149
// Success: refresh the freshest report + store, and announce completion if a
142150
// scan was actually running in this tab.
143-
return { setError: false, reloadReport: true, refreshServers: true, successToast: wasLoading, cancelledToast: false }
151+
return { setError: false, clearReport: false, reloadReport: true, refreshServers: true, successToast: wasLoading, cancelledToast: false }
152+
}
153+
154+
/** Imperative side-effect handlers the host component wires to its own refs/stores. */
155+
export interface ScanFinalizeHandlers {
156+
/** Drop the currently-rendered report (e.g. `scanReport.value = null`). */
157+
clearReport: () => void
158+
/** Reload the freshest report from the backend. */
159+
reloadReport: () => Promise<void> | void
160+
/** Refresh the server store so derived status leaves "scanning". */
161+
refreshServers: () => Promise<void> | void
162+
/** Set the scan-error banner from the terminal payload. */
163+
setError: () => void
164+
/** Show the "Scan Complete" success toast. */
165+
successToast: () => void
166+
/** Show the neutral "Scan cancelled" info toast. */
167+
cancelledToast: () => void
168+
}
169+
170+
/**
171+
* Apply a finalize decision's side-effects in the one correct order. Extracted from
172+
* the (heavy) ServerDetail component so the full cancel-finalize contract is driven
173+
* by tests against fake handlers, not just asserted as flags (MCP-2755).
174+
*
175+
* Order matters: an error short-circuits; otherwise the stale report is cleared
176+
* BEFORE any (success-only) reload, the store is refreshed, then toasts fire last.
177+
*/
178+
export async function applyScanFinalize(
179+
effects: ScanFinalizeEffects,
180+
handlers: ScanFinalizeHandlers,
181+
): Promise<void> {
182+
if (effects.setError) {
183+
handlers.setError()
184+
return
185+
}
186+
if (effects.clearReport) handlers.clearReport()
187+
if (effects.reloadReport) await handlers.reloadReport()
188+
if (effects.refreshServers) await handlers.refreshServers()
189+
if (effects.successToast) handlers.successToast()
190+
if (effects.cancelledToast) handlers.cancelledToast()
144191
}

frontend/src/views/ServerDetail.vue

Lines changed: 16 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1249,7 +1249,7 @@ import type { Server, Tool, ToolApproval, SecurityScanReport } from '@/types'
12491249
import api from '@/services/api'
12501250
import { useSecurityScannerStatus } from '@/composables/useSecurityScannerStatus'
12511251
import { serverDisplayName, scanReportPath } from '@/utils/serverRoute'
1252-
import { isTerminalScanStatus, decideScanReconcile, scanFinalizeEffects } from '@/utils/scanState'
1252+
import { isTerminalScanStatus, decideScanReconcile, scanFinalizeEffects, applyScanFinalize } from '@/utils/scanState'
12531253
import { selectQuarantinedTools } from '@/utils/toolQuarantine'
12541254
import { oauthSignInState } from '@/utils/health'
12551255
import { computeToolDiffSections } from '@/utils/toolDiff'
@@ -2815,27 +2815,23 @@ function clearScanRunState() {
28152815
// was actually in flight, so reconciling on a fresh tab-open stays silent.
28162816
async function finalizeScan(data: any, decision: { isError: boolean; isCancelled: boolean }) {
28172817
const wasLoading = scanLoading.value
2818+
// clearScanRunState() stops polling and drops scanLoading + activeScanJobId.
28182819
clearScanRunState()
2819-
// MCP-2755: derive the side-effects per terminal outcome from a pure, tested
2820-
// reducer. A cancelled scan is terminal but NOT a success: it may not have
2821-
// produced a report (so we skip the reload), yet the store MUST still be
2822-
// refreshed — otherwise, after clearScanRunState() drops scanLoading, the
2823-
// Security tab keeps deriving "scanning" from the stale store and the spinner
2824-
// never clears.
2820+
// MCP-2755: derive the per-outcome side-effects from a pure, tested reducer and
2821+
// apply them in the one correct order. A cancelled scan is terminal but NOT a
2822+
// success: it may not have produced a report, and a tab opened mid-scan may have
2823+
// a previous (stale) report loaded — so we CLEAR the report and refresh the store
2824+
// (clearing the spinner) WITHOUT reloading or showing the success toast.
28252825
const effects = scanFinalizeEffects(decision, wasLoading)
2826-
if (effects.setError) {
2827-
scanError.value = data?.error || 'Scan failed'
2828-
return
2829-
}
2830-
if (effects.reloadReport) await loadScanReport(true, true)
2831-
if (effects.refreshServers) await serversStore.fetchServers()
2832-
// server is a computed from the store — no manual reassignment needed.
2833-
if (effects.successToast) {
2834-
systemStore.addToast({ type: 'success', title: 'Scan Complete', message: `Security scan for ${server.value?.name} finished.` })
2835-
}
2836-
if (effects.cancelledToast) {
2837-
systemStore.addToast({ type: 'info', title: 'Scan cancelled', message: `Security scan for ${server.value?.name} was cancelled.` })
2838-
}
2826+
await applyScanFinalize(effects, {
2827+
clearReport: () => { scanReport.value = null },
2828+
reloadReport: () => loadScanReport(true, true),
2829+
refreshServers: () => serversStore.fetchServers(),
2830+
setError: () => { scanError.value = data?.error || 'Scan failed' },
2831+
// server is a computed from the store — no manual reassignment needed.
2832+
successToast: () => systemStore.addToast({ type: 'success', title: 'Scan Complete', message: `Security scan for ${server.value?.name} finished.` }),
2833+
cancelledToast: () => systemStore.addToast({ type: 'info', title: 'Scan cancelled', message: `Security scan for ${server.value?.name} was cancelled.` }),
2834+
})
28392835
}
28402836
28412837
function startScanPolling() {

frontend/tests/unit/scan-state-reconcile.spec.ts

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
1-
import { describe, it, expect } from 'vitest'
2-
import { isTerminalScanStatus, decideScanReconcile, scanFinalizeEffects } from '@/utils/scanState'
1+
import { describe, it, expect, vi } from 'vitest'
2+
import {
3+
isTerminalScanStatus,
4+
decideScanReconcile,
5+
scanFinalizeEffects,
6+
applyScanFinalize,
7+
} from '@/utils/scanState'
38

49
// MCP-2740: A finished security scan stayed stuck on "Scanning… 5/5 complete"
510
// with the Report button disabled because terminal state was only derived from a
@@ -152,6 +157,16 @@ describe('scanFinalizeEffects (MCP-2755)', () => {
152157
expect(e.successToast).toBe(false)
153158
})
154159

160+
it('cancelled clears any previously-loaded (stale) report', () => {
161+
expect(scanFinalizeEffects({ isError: false, isCancelled: true }, true).clearReport).toBe(true)
162+
expect(scanFinalizeEffects({ isError: false, isCancelled: true }, false).clearReport).toBe(true)
163+
})
164+
165+
it('success/error do NOT clear the report (success reloads it, error keeps current)', () => {
166+
expect(scanFinalizeEffects({ isError: false, isCancelled: false }, true).clearReport).toBe(false)
167+
expect(scanFinalizeEffects({ isError: true, isCancelled: false }, true).clearReport).toBe(false)
168+
})
169+
155170
it('error: set error and short-circuit (no reload, no toast)', () => {
156171
const e = scanFinalizeEffects({ isError: true, isCancelled: false }, true)
157172
expect(e.setError).toBe(true)
@@ -161,3 +176,70 @@ describe('scanFinalizeEffects (MCP-2755)', () => {
161176
expect(e.cancelledToast).toBe(false)
162177
})
163178
})
179+
180+
// MCP-2755 (Codex round-2 P2 on PR #700): the full cancel-finalize contract.
181+
// Drives the real finalize side-effect applier against fake refs/handlers so the
182+
// behavior — not just the effect flags — is asserted end to end.
183+
describe('applyScanFinalize — cancel-finalize contract (MCP-2755)', () => {
184+
it('tab opened mid-scan (stale report loaded) → cancelled via poll: spinner+report cleared, store refreshed, no success toast', async () => {
185+
// Tab was opened while a scan was running: a previous (stale) report is on
186+
// screen and the spinner is up.
187+
const state = { scanLoading: true, activeScanJobId: 'job-A', scanReport: { stale: true } as unknown, scanError: '' }
188+
const handlers = {
189+
clearReport: vi.fn(() => { state.scanReport = null }),
190+
reloadReport: vi.fn(async () => { state.scanReport = { fresh: true } }),
191+
refreshServers: vi.fn(async () => {}),
192+
setError: vi.fn(() => { state.scanError = 'boom' }),
193+
successToast: vi.fn(),
194+
cancelledToast: vi.fn(),
195+
}
196+
197+
// finalizeScan sequence: snapshot wasLoading, then clearScanRunState() drops
198+
// the spinner + tracked job, then apply the per-outcome effects.
199+
const wasLoading = state.scanLoading
200+
state.scanLoading = false
201+
state.activeScanJobId = null
202+
const decision = decideScanReconcile(
203+
{ status: 'cancelled', jobId: 'job-B' }, // a sub-2s/other-tab cancel: id need not match
204+
{ scanLoading: wasLoading, activeScanJobId: 'job-A' },
205+
)
206+
await applyScanFinalize(scanFinalizeEffects(decision, wasLoading), handlers)
207+
208+
expect(state.scanLoading).toBe(false) // spinner gone
209+
expect(state.scanReport).toBeNull() // stale report cleared, NOT replaced
210+
expect(handlers.clearReport).toHaveBeenCalledTimes(1)
211+
expect(handlers.reloadReport).not.toHaveBeenCalled() // no report reload
212+
expect(handlers.refreshServers).toHaveBeenCalledTimes(1) // store refreshed → status not 'scanning'
213+
expect(handlers.successToast).not.toHaveBeenCalled() // no 'Scan Complete'
214+
expect(handlers.cancelledToast).toHaveBeenCalledTimes(1)
215+
expect(handlers.setError).not.toHaveBeenCalled()
216+
expect(state.scanError).toBe('')
217+
})
218+
219+
it('success: reloads the report and refreshes the store (no clear, no cancelled toast)', async () => {
220+
const handlers = {
221+
clearReport: vi.fn(), reloadReport: vi.fn(async () => {}), refreshServers: vi.fn(async () => {}),
222+
setError: vi.fn(), successToast: vi.fn(), cancelledToast: vi.fn(),
223+
}
224+
await applyScanFinalize(scanFinalizeEffects({ isError: false, isCancelled: false }, true), handlers)
225+
expect(handlers.clearReport).not.toHaveBeenCalled()
226+
expect(handlers.reloadReport).toHaveBeenCalledTimes(1)
227+
expect(handlers.refreshServers).toHaveBeenCalledTimes(1)
228+
expect(handlers.successToast).toHaveBeenCalledTimes(1)
229+
expect(handlers.cancelledToast).not.toHaveBeenCalled()
230+
})
231+
232+
it('error: only sets the error (no clear/reload/refresh/toast)', async () => {
233+
const handlers = {
234+
clearReport: vi.fn(), reloadReport: vi.fn(), refreshServers: vi.fn(),
235+
setError: vi.fn(), successToast: vi.fn(), cancelledToast: vi.fn(),
236+
}
237+
await applyScanFinalize(scanFinalizeEffects({ isError: true, isCancelled: false }, true), handlers)
238+
expect(handlers.setError).toHaveBeenCalledTimes(1)
239+
expect(handlers.clearReport).not.toHaveBeenCalled()
240+
expect(handlers.reloadReport).not.toHaveBeenCalled()
241+
expect(handlers.refreshServers).not.toHaveBeenCalled()
242+
expect(handlers.successToast).not.toHaveBeenCalled()
243+
expect(handlers.cancelledToast).not.toHaveBeenCalled()
244+
})
245+
})

0 commit comments

Comments
 (0)