From 58796da0a21e77ed7a4419544726ff5f4e32877f Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Mon, 5 Oct 2026 15:36:34 +0300 Subject: [PATCH] fix(web): Spec 109 UI lows - blocked offer with type filter, Home strip flash, optimistic health state, imported counts, telemetry refresh, disabled-server logs, sessions e2e (refs #1466, refs #1451) --- .../navigation-consistency.spec.ts | 14 ++++++ frontend/src/components/ImportServers.vue | 8 +-- frontend/src/components/OnboardingWizard.vue | 21 ++++++-- frontend/src/components/TelemetryBanner.vue | 17 ++++++- frontend/src/stores/servers.ts | 18 +++++++ frontend/src/utils/onboardingServersStep.ts | 15 ++++++ frontend/src/views/Activity.vue | 14 +++++- frontend/src/views/Home.vue | 4 +- frontend/src/views/ServerDetail.vue | 11 +++++ .../activity-blocked-in-calls-view.spec.ts | 14 ++++++ frontend/tests/unit/home-attention.spec.ts | 10 ++++ .../unit/import-servers-completion.spec.ts | 27 +++++++++- .../tests/unit/import-servers-shared.spec.ts | 2 +- .../onboarding-servers-step-state.spec.ts | 12 ++++- ...nboarding-wizard-import-completion.spec.ts | 2 +- .../server-detail-health-admin-state.spec.ts | 7 +++ frontend/tests/unit/servers-store.spec.ts | 49 +++++++++++++++++++ .../unit/telemetry-banner-wizard.spec.ts | 12 +++++ 18 files changed, 243 insertions(+), 14 deletions(-) diff --git a/e2e/web-ui-sweep/navigation-consistency.spec.ts b/e2e/web-ui-sweep/navigation-consistency.spec.ts index e7a3a80d1..5ce24697b 100644 --- a/e2e/web-ui-sweep/navigation-consistency.spec.ts +++ b/e2e/web-ui-sweep/navigation-consistency.spec.ts @@ -441,6 +441,20 @@ test('Activity sessions rows open their calls (SC-009)', async ({ page }) => { await page.waitForTimeout(500) const row = page.locator('[data-test="sessions-row"]').first() test.skip((await row.count()) === 0, 'no MCP sessions recorded on this instance') + + // The row's "View Activity" link must land on the calls view scoped to that + // session, and the page's own REST request must carry the session filter. + const seen = collect(page, /^\/api\/v1\/activity$/) + await row.locator('[data-test="session-view-activity"]').click() + await expect(page).toHaveURL(/\/ui\/activity\?(?:[^#]*&)?session=[^&]+/) + const session = new URL(page.url()).searchParams.get('session')! + await page.waitForTimeout(1000) + expect(seen.length, 'sessions row: no activity REST request was issued').toBeGreaterThan(0) + // toRest() routes a `ws-` work session to work_session_id, a transport id to session_id. + const param = session.startsWith('ws-') ? 'work_session_id' : 'session_id' + for (const req of seen) { + expect(new URL(req).searchParams.get(param), `sessions row: ${req}`).toBe(session) + } }) test('the status pill opens Servers and attention "See all" opens Home (Spec 109 FR-053)', async ({ page }) => { diff --git a/frontend/src/components/ImportServers.vue b/frontend/src/components/ImportServers.vue index ce2bd6a9c..883057c38 100644 --- a/frontend/src/components/ImportServers.vue +++ b/frontend/src/components/ImportServers.vue @@ -101,7 +101,7 @@ import { importSummary } from '@/utils/onboardingServersStep' // showEmpty: the wizard owns its own empty and completion states, so it turns // this one off; standalone use keeps the first-load empty line. const props = withDefaults(defineProps<{ detected?: boolean; showEmpty?: boolean; showMessage?: boolean }>(), { detected: false, showEmpty: true, showMessage: true }) -const emit = defineEmits<{ imported: [count: number] }>() +const emit = defineEmits<{ imported: [count: number, names?: string[]] }>() const content = ref('') const loading = ref(false) @@ -177,6 +177,7 @@ async function importDetected() { try { let imported = 0 let renamed = 0 + const importedNames: string[] = [] const skipped: Array<{ reason?: string }> = [] for (const source of detectedSources.value) { const server_names = source.servers.filter(server => source.selected[server.name]).map(server => server.name) @@ -189,12 +190,13 @@ async function importDetected() { const response = await api.importServersFromPath({ path: source.path, format: source.format, server_names, rename: Object.keys(rename).length ? rename : undefined, skip_quarantine: !detectedQuarantine.value }) if (!response.success) throw new Error(response.error || `Could not import ${source.name}`) imported += response.data?.summary?.imported ?? server_names.length + importedNames.push(...server_names.map(name => (rename as Record)[name] ?? name)) skipped.push(...(response.data?.skipped ?? [])) } detectedMessage.value = importSummary({ imported, renamed, skipped }) detectedImportedCount.value = imported importedOnce.value = true - emit('imported', imported) + emit('imported', imported, importedNames) await loadDetectedSources(false) } catch (error) { detectedError.value = error instanceof Error ? error.message : 'Import failed' } finally { detectedImporting.value = false } @@ -264,7 +266,7 @@ async function handleImport() { addError.value = resp.error || 'Import failed' return } - emit('imported', names.length) + emit('imported', resp.data?.summary?.imported ?? resp.data?.imported?.length ?? names.length, names) } catch (e) { addError.value = e instanceof Error ? e.message : 'Import failed' } finally { diff --git a/frontend/src/components/OnboardingWizard.vue b/frontend/src/components/OnboardingWizard.vue index 6c0c3e4d8..9ed27a2d2 100644 --- a/frontend/src/components/OnboardingWizard.vue +++ b/frontend/src/components/OnboardingWizard.vue @@ -694,7 +694,7 @@ import ReviewQueueList from '@/components/ReviewQueueList.vue' import TelemetryBanner from '@/components/TelemetryBanner.vue' import { useDialogOpen } from '@/composables/useDialogOpen' import { skipReasonLabel } from '@/utils/importSkipReason' -import { serversStepView, awaitingReviewSentence } from '@/utils/onboardingServersStep' +import { serversStepView, awaitingReviewSentence, countImportedStillQuarantined } from '@/utils/onboardingServersStep' import type { ClientStatus, ActivityRecord, ConnectPreview, ImportedServer } from '@/types' interface Props { @@ -873,6 +873,9 @@ const hasUsableServer = computed(() => onboarding.hasUsableServer) // Servers brought in by an import during THIS wizard open (reset in // onOpened). Manual add is not an import and does not count here. const importedThisSession = ref(0) +// Names of the servers an import of THIS session put into quarantine, so the +// "including the N you just imported" sentence counts only those still waiting. +const importedQuarantinedNames = ref>(new Set()) // Which body the Servers step shows: choose / review / imported / empty // (fix-usertest-web T200). Pure rules live in utils/onboardingServersStep.ts. const serversView = computed(() => serversStepView({ @@ -883,7 +886,10 @@ const serversView = computed(() => serversStepView({ })) const awaitingReviewText = computed(() => awaitingReviewSentence( quarantinedServersAwaitingReview.value.length, - Math.min(importedThisSession.value, quarantinedServersAwaitingReview.value.length), + countImportedStillQuarantined( + importedQuarantinedNames.value, + quarantinedServersAwaitingReview.value.map(server => server.name), + ), )) function selectionKey(path: string, name: string) { @@ -1069,6 +1075,7 @@ async function onOpened() { const requested = onboarding.consumeWizardInitialTab() importSession.value++ importedThisSession.value = 0 + importedQuarantinedNames.value = new Set() serverAddedJustNow.value = false connectMessage.value = '' // Backup lines are session-scoped (Spec 078 US2): don't replay backup @@ -1753,11 +1760,19 @@ async function onServerAdded() { }) } -async function onSharedImport(count: number) { +async function onSharedImport(count: number, names: string[] = []) { if (count === 0) return importedThisSession.value += count serverAddedJustNow.value = true + const quarantinedBefore = new Set(quarantinedServersAwaitingReview.value.map(server => server.name)) await Promise.all([fetchImportSources(), serversStore.fetchServers(), onboarding.fetchState()]) + // Whatever newly entered quarantine because of this import is "just imported". + const next = new Set(importedQuarantinedNames.value) + for (const name of names) next.add(name) + for (const server of quarantinedServersAwaitingReview.value) { + if (!quarantinedBefore.has(server.name)) next.add(server.name) + } + importedQuarantinedNames.value = next } // `dismiss` is the onClose handler useDialogOpen calls for a NATIVE close diff --git a/frontend/src/components/TelemetryBanner.vue b/frontend/src/components/TelemetryBanner.vue index 594282e80..59a468012 100644 --- a/frontend/src/components/TelemetryBanner.vue +++ b/frontend/src/components/TelemetryBanner.vue @@ -80,7 +80,7 @@ diff --git a/frontend/src/stores/servers.ts b/frontend/src/stores/servers.ts index 570914d85..d11e9bb8f 100644 --- a/frontend/src/stores/servers.ts +++ b/frontend/src/stores/servers.ts @@ -205,7 +205,21 @@ export const useServersStore = defineStore('servers', () => { } } + // Optimistic admin-state flip. The Health tile and badges read health.admin_state + // and health.status ahead of enabled/connected, so updating only the booleans + // left a stale "Online"/"Healthy" until the SSE refresh landed (#1466). + function applyOptimisticAdminState(server: Server, adminState: 'disabled' | 'quarantined') { + if (!server.health) return + server.health = { + ...server.health, + admin_state: adminState, + status: adminState === 'disabled' ? 'disabled' : 'needs_review', + usable: false, + } + } + async function disableServer(serverName: string) { + const prevHealth = servers.value.find(s => s.name === serverName)?.health try { const server = servers.value.find(s => s.name === serverName) @@ -214,6 +228,7 @@ export const useServersStore = defineStore('servers', () => { server.enabled = false server.connecting = false server.connected = false + applyOptimisticAdminState(server, 'disabled') } const response = await api.disableServer(serverName) @@ -224,6 +239,7 @@ export const useServersStore = defineStore('servers', () => { // Revert optimistic update on error if (server) { server.enabled = true + server.health = prevHealth } throw new Error(response.error || 'Failed to disable server') } @@ -233,6 +249,7 @@ export const useServersStore = defineStore('servers', () => { const server = servers.value.find(s => s.name === serverName) if (server) { server.enabled = true + server.health = prevHealth } throw error } @@ -311,6 +328,7 @@ export const useServersStore = defineStore('servers', () => { const server = servers.value.find(s => s.name === serverName) if (server) { server.quarantined = true + applyOptimisticAdminState(server, 'quarantined') } return true } else { diff --git a/frontend/src/utils/onboardingServersStep.ts b/frontend/src/utils/onboardingServersStep.ts index 1d34ba3a1..e649c28e8 100644 --- a/frontend/src/utils/onboardingServersStep.ts +++ b/frontend/src/utils/onboardingServersStep.ts @@ -32,6 +32,21 @@ export function serversStepView(input: ServersStepInput): ServersStepView { return 'empty' } +/** + * How many of the servers imported this wizard session are still waiting in + * quarantine. An import that was not quarantined (or has since been approved) + * must not be reported as "including the N you just imported" (#1466). + */ +export function countImportedStillQuarantined( + importedNames: Iterable, + quarantinedNames: Iterable, +): number { + const quarantined = new Set(quarantinedNames) + let n = 0 + for (const name of new Set(importedNames)) if (quarantined.has(name)) n++ + return n +} + /** The one-sentence status under "Approve a server to finish this step.". */ export function awaitingReviewSentence(awaiting: number, justImported: number): string { const plural = awaiting !== 1 diff --git a/frontend/src/views/Activity.vue b/frontend/src/views/Activity.vue index f8047f9c0..95262a4f2 100644 --- a/frontend/src/views/Activity.vue +++ b/frontend/src/views/Activity.vue @@ -553,7 +553,7 @@ type="button" class="btn btn-sm btn-outline mt-2" data-test="activity-empty-show-blocked" - @click="filterStatus = 'blocked'" + @click="showBlockedAttempts" > Show {{ blockedAttemptCount }} blocked attempt{{ blockedAttemptCount === 1 ? '' : 's' }} @@ -2058,6 +2058,18 @@ const summaryParts = computed(() => compactSummaryParts(summary.value)) const BLOCKED_CALLS_TITLE = 'Blocked call attempts in the last 24 h, including calls a profile or token refused. Click to list them.' const blockedAttemptCount = computed(() => summary.value?.blocked_count ?? 0) +// An explicit `type` filter overrides the view's types, so a filter that excludes +// `policy_decision` would leave the blocked status filter matching nothing. +function showBlockedAttempts() { + if (selectedTypes.value.length > 0 && !selectedTypes.value.includes('policy_decision')) { + // One URL write for both params: two back-to-back router.replace calls each + // start from the stale route.query and the second would drop the first. + // The route watcher hydrates both refs from the URL. + scopeQuery.set({ type: [...selectedTypes.value, 'policy_decision'].join(','), status: 'blocked' }) + return + } + filterStatus.value = 'blocked' +} const showBlockedOffer = computed( () => activeView.value === 'calls' && !filterStatus.value && blockedAttemptCount.value > 0 ) diff --git a/frontend/src/views/Home.vue b/frontend/src/views/Home.vue index bd71d649c..db12f6560 100644 --- a/frontend/src/views/Home.vue +++ b/frontend/src/views/Home.vue @@ -44,7 +44,7 @@ - + - + diff --git a/frontend/src/views/ServerDetail.vue b/frontend/src/views/ServerDetail.vue index e7fdebb00..d4a213607 100644 --- a/frontend/src/views/ServerDetail.vue +++ b/frontend/src/views/ServerDetail.vue @@ -2868,6 +2868,14 @@ function loadLogs() { async function _loadLogsWithGen(gen: number) { if (!server.value) return + // A disabled server has no running process and may have no log file: skip the + // request rather than surfacing a console error (#1466). + if (server.value.enabled === false) { + serverLogs.value = [] + logsError.value = null + logsLoading.value = false + return + } logsLoading.value = true logsError.value = null @@ -2877,6 +2885,9 @@ async function _loadLogsWithGen(gen: number) { if (gen !== loadGeneration) return if (response.success && response.data) { serverLogs.value = response.data.logs || [] + } else if (/\b404\b|not found/i.test(response.error || '')) { + // No log file yet: an empty state, not a fault. + serverLogs.value = [] } else { logsError.value = response.error || 'Failed to load logs' } diff --git a/frontend/tests/unit/activity-blocked-in-calls-view.spec.ts b/frontend/tests/unit/activity-blocked-in-calls-view.spec.ts index 82f2afe86..de0930061 100644 --- a/frontend/tests/unit/activity-blocked-in-calls-view.spec.ts +++ b/frontend/tests/unit/activity-blocked-in-calls-view.spec.ts @@ -117,6 +117,20 @@ describe('Activity - blocked attempts in the Tool calls view (T168)', () => { expect(wrapper.findAll('[data-test="activity-row"]')).toHaveLength(1) }) + it('the offer still lists refusals when an explicit type filter excludes policy_decision (#1466)', async () => { + mockState.rows = [REFUSAL] + mockState.summary = { period: '24h', total_count: 1, call_count: 0, blocked_count: 1 } + const { wrapper } = await mountActivityAt('/activity?view=calls&type=tool_call') + + const button = wrapper.find('[data-test="activity-empty-show-blocked"]') + expect(button.exists()).toBe(true) + await button.trigger('click') + await flushPromises() + await flushPromises() + expect(wrapper.findAll('[data-test="activity-row"]')).toHaveLength(1) + expect(wrapper.findAll('[data-test="activity-row"]')[0].text()).toContain('write') + }) + it('pluralises the offer for several blocked attempts', async () => { mockState.rows = [] mockState.summary = { period: '24h', total_count: 2, call_count: 0, blocked_count: 2 } diff --git a/frontend/tests/unit/home-attention.spec.ts b/frontend/tests/unit/home-attention.spec.ts index 38d7b8bb1..89cd548cb 100644 --- a/frontend/tests/unit/home-attention.spec.ts +++ b/frontend/tests/unit/home-attention.spec.ts @@ -120,6 +120,16 @@ describe('Home attention list (Spec 109 FR-001/FR-003)', () => { expect(stripBottom.exists()).toBe(false) }) + it('holds the top usage strip back until the server list has loaded (#1466)', async () => { + attentionSpy.mockResolvedValue({ success: true, data: { count: 0, items: [] } }) + const api = (await import('@/services/api')).default as unknown as { getServers: ReturnType } + // A fresh instance whose server list is still in flight: the strip must not + // flash only to be replaced by the getting-started card. + api.getServers.mockReturnValueOnce(new Promise(() => {})) + const wrapper = await mountHome() + expect(wrapper.find('[data-test="home-usage-strip-top"]').exists()).toBe(false) + }) + it('lists items in the order the API returns them, with fix buttons routing to fix.target', async () => { attentionSpy.mockResolvedValue({ success: true, diff --git a/frontend/tests/unit/import-servers-completion.spec.ts b/frontend/tests/unit/import-servers-completion.spec.ts index 8a54825a2..c645aec09 100644 --- a/frontend/tests/unit/import-servers-completion.spec.ts +++ b/frontend/tests/unit/import-servers-completion.spec.ts @@ -66,7 +66,7 @@ describe('ImportServers detected completion state', () => { expect(message.text()).toBe('✓ 2 servers imported') expect(message.text()).not.toContain('skipped') expect(wrapper.find('[data-test="detected-import-empty"]').exists()).toBe(false) - expect(wrapper.emitted('imported')?.[0]).toEqual([2]) + expect(wrapper.emitted('imported')?.[0]).toEqual([2, ['fetchy', 'thinker']]) }) it('renders nothing for the empty state when showEmpty is false and nothing is detected at mount', async () => { @@ -91,3 +91,28 @@ describe('ImportServers detected completion state', () => { expect(wrapper.find('[data-test="detected-import-message"]').text()).toBe('✓ 2 servers imported') }) }) + +describe('ImportServers paste import count (#1466)', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('emits the number the backend actually imported, not the number selected', async () => { + ;(api.importServersFromJSON as any) + .mockResolvedValueOnce({ + success: true, + data: { imported: [{ name: 'a' }, { name: 'b' }] }, + }) + .mockResolvedValueOnce({ + success: true, + data: { summary: { imported: 1 }, imported: [{ name: 'a' }], skipped: [{ name: 'b', reason: 'already_exists' }] }, + }) + const wrapper = mount(ImportServers, { props: { detected: false } }) + await wrapper.find('[data-test="import-content-textarea"]').setValue('{"mcpServers":{}}') + await wrapper.find('[data-test="import-preview-button"]').trigger('click') + await flushPromises() + await wrapper.find('[data-test="import-confirm-button"]').trigger('click') + await flushPromises() + expect(wrapper.emitted('imported')?.[0]).toEqual([1, ['a', 'b']]) + }) +}) diff --git a/frontend/tests/unit/import-servers-shared.spec.ts b/frontend/tests/unit/import-servers-shared.spec.ts index f056ad810..8d3361f22 100644 --- a/frontend/tests/unit/import-servers-shared.spec.ts +++ b/frontend/tests/unit/import-servers-shared.spec.ts @@ -45,7 +45,7 @@ describe('ImportServers', () => { await wrapper.find('[data-test="bulk-import-primary"]').trigger('click') await flushPromises() expect(api.importServersFromPath).toHaveBeenCalledWith(expect.objectContaining({ server_names: ['github'], skip_quarantine: false })) - expect(wrapper.emitted('imported')?.[0]).toEqual([1]) + expect(wrapper.emitted('imported')?.[0]).toEqual([1, ['github']]) }) it('renames duplicate selected names and can bypass quarantine explicitly', async () => { diff --git a/frontend/tests/unit/onboarding-servers-step-state.spec.ts b/frontend/tests/unit/onboarding-servers-step-state.spec.ts index 239f1798f..e9361e6eb 100644 --- a/frontend/tests/unit/onboarding-servers-step-state.spec.ts +++ b/frontend/tests/unit/onboarding-servers-step-state.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { serversStepView, awaitingReviewSentence, importSummary } from '@/utils/onboardingServersStep' +import { serversStepView, awaitingReviewSentence, importSummary, countImportedStillQuarantined } from '@/utils/onboardingServersStep' // Spec 109 US7-4 / FR-043 (fix-usertest-web T200): one completion state after // an import, and copy that reads as a sentence. @@ -56,3 +56,13 @@ describe('importSummary', () => { expect(importSummary({ imported: 0, skipped: [] })).toBe('No servers imported') }) }) + +describe('countImportedStillQuarantined (#1466)', () => { + it('counts only the imported names that are still quarantined', () => { + expect(countImportedStillQuarantined(['a', 'b', 'c'], ['b', 'x', 'y'])).toBe(1) + }) + it('is zero when the imports were not quarantined', () => { + expect(countImportedStillQuarantined(['a', 'b'], ['x', 'y'])).toBe(0) + expect(countImportedStillQuarantined([], ['x'])).toBe(0) + }) +}) diff --git a/frontend/tests/unit/onboarding-wizard-import-completion.spec.ts b/frontend/tests/unit/onboarding-wizard-import-completion.spec.ts index 9a32aa05b..41a203661 100644 --- a/frontend/tests/unit/onboarding-wizard-import-completion.spec.ts +++ b/frontend/tests/unit/onboarding-wizard-import-completion.spec.ts @@ -71,7 +71,7 @@ const ImportServersStub = { emits: ['imported'], props: { detected: Boolean, showMessage: { type: Boolean, default: true } }, template: - '

imported

', + '

imported

', } function makeRouter() { diff --git a/frontend/tests/unit/server-detail-health-admin-state.spec.ts b/frontend/tests/unit/server-detail-health-admin-state.spec.ts index 71203a1a9..4de5055ae 100644 --- a/frontend/tests/unit/server-detail-health-admin-state.spec.ts +++ b/frontend/tests/unit/server-detail-health-admin-state.spec.ts @@ -265,4 +265,11 @@ describe('ServerDetail — Health tile answers "can my client use it?" (F10)', ( }) expect(wrapper.find('[data-test="server-admin-state-desc"]').text()).toBe('set by you') }) + + it('does not request logs for a disabled server (#1466)', async () => { + const api = (await import('@/services/api')).default as unknown as { getServerLogs: ReturnType } + api.getServerLogs.mockClear() + await mountDetail({ ...base, enabled: false, connected: false }) + expect(api.getServerLogs).not.toHaveBeenCalled() + }) }) diff --git a/frontend/tests/unit/servers-store.spec.ts b/frontend/tests/unit/servers-store.spec.ts index 5f540ba1e..38d5317bd 100644 --- a/frontend/tests/unit/servers-store.spec.ts +++ b/frontend/tests/unit/servers-store.spec.ts @@ -8,6 +8,8 @@ vi.mock('@/services/api', () => ({ getServers: vi.fn(), securityApprove: vi.fn(), unquarantineServer: vi.fn(), + disableServer: vi.fn(), + quarantineServer: vi.fn(), }, })) @@ -334,3 +336,50 @@ describe('useServersStore — totalTools counts only available tools (#1064)', ( expect(store.quarantinedServers).toHaveLength(1) }) }) + +describe('useServersStore — optimistic disable/quarantine keep health.admin_state in step (#1466)', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + async function seeded() { + ;(api.getServers as any).mockResolvedValueOnce({ + success: true, + data: { + servers: [{ + name: 'srv', protocol: 'http', enabled: true, quarantined: false, connected: true, + connecting: false, tool_count: 1, + health: { level: 'healthy', admin_state: 'enabled', summary: 'Connected', status: 'ready' }, + }], + }, + }) + const store = useServersStore() + await store.fetchServers() + return store + } + + it('disable sets admin_state=disabled', async () => { + ;(api.disableServer as any).mockResolvedValueOnce({ success: true }) + const store = await seeded() + await store.disableServer('srv') + expect(store.servers[0].health?.admin_state).toBe('disabled') + expect(store.servers[0].health?.status).toBe('disabled') + }) + + it('a failed disable restores admin_state', async () => { + ;(api.disableServer as any).mockResolvedValueOnce({ success: false, error: 'nope' }) + const store = await seeded() + await expect(store.disableServer('srv')).rejects.toThrow() + expect(store.servers[0].health?.admin_state).toBe('enabled') + expect(store.servers[0].health?.status).toBe('ready') + }) + + it('quarantine sets admin_state=quarantined', async () => { + ;(api.quarantineServer as any).mockResolvedValueOnce({ success: true }) + const store = await seeded() + await store.quarantineServer('srv') + expect(store.servers[0].health?.admin_state).toBe('quarantined') + expect(store.servers[0].health?.status).toBe('needs_review') + }) +}) diff --git a/frontend/tests/unit/telemetry-banner-wizard.spec.ts b/frontend/tests/unit/telemetry-banner-wizard.spec.ts index 9dfd92fe4..450c8259a 100644 --- a/frontend/tests/unit/telemetry-banner-wizard.spec.ts +++ b/frontend/tests/unit/telemetry-banner-wizard.spec.ts @@ -51,6 +51,18 @@ describe('TelemetryBanner (Spec 109-b FR-044)', () => { expect(wrapper.find('[data-test="telemetry-banner"]').exists()).toBe(true) }) + it('reloads the telemetry state when the window regains focus and stops after unmount (#1466)', async () => { + const store = useOnboardingStore() + const load = vi.spyOn(store, 'loadTelemetryState').mockResolvedValue(undefined as never) + const wrapper = await mountBanner() + expect(load).toHaveBeenCalledTimes(1) + window.dispatchEvent(new Event('focus')) + expect(load).toHaveBeenCalledTimes(2) + wrapper.unmount() + window.dispatchEvent(new Event('focus')) + expect(load).toHaveBeenCalledTimes(2) + }) + it('does not render while the wizard is open', async () => { const store = useOnboardingStore() store.wizardOpen = true