| Name |
Kind |
- Profile |
+ Profile |
Mode |
Prefix |
Expires |
@@ -151,7 +151,7 @@
{{ isClientCredential(token) ? 'Client' : 'Agent' }} |
- {{ profilesStore.titleFor(token.profile_pin) }}
+ {{ profilesStore.titleFor(token.profile_pin) }}
—
|
@@ -558,23 +558,28 @@ function permissionBadgeClass(perm: string): string {
}
// Data loading
+let tokensTicket = 0
async function loadTokens() {
+ const ticket = ++tokensTicket
loading.value = true
error.value = null
try {
const rest = scopeQuery?.toRest()
const response = await apiClient.listAgentTokens({ profile: rest?.profile, token: rest?.token })
+ // A newer load owns the rows and the loading flag.
+ if (ticket !== tokensTicket) return
if (response.success && response.data) {
tokens.value = response.data.tokens || []
} else {
error.value = response.error || 'Failed to load tokens'
}
} catch (err: any) {
+ if (ticket !== tokensTicket) return
error.value = err.message || 'Failed to load tokens'
console.error('Failed to load tokens:', err)
} finally {
- loading.value = false
+ if (ticket === tokensTicket) loading.value = false
}
}
diff --git a/frontend/src/views/Profiles.vue b/frontend/src/views/Profiles.vue
index 8bde5f97b..895ae8b97 100644
--- a/frontend/src/views/Profiles.vue
+++ b/frontend/src/views/Profiles.vue
@@ -112,6 +112,6 @@ onMounted(() => {
consumeCreateParam()
void store.fetchProfiles()
// Who uses a profile (client names) comes from the clients store.
- if (!tenant.value && !clients.clients.length) void clients.refreshPresence()
+ if (!tenant.value && (!clients.allClients.length || clients.stale)) void clients.refreshPresence()
})
diff --git a/frontend/tests/unit/agent-tokens-stale-load-1446.spec.ts b/frontend/tests/unit/agent-tokens-stale-load-1446.spec.ts
new file mode 100644
index 000000000..fd92b1763
--- /dev/null
+++ b/frontend/tests/unit/agent-tokens-stale-load-1446.spec.ts
@@ -0,0 +1,60 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { flushPromises, mount } from '@vue/test-utils'
+import { createPinia, setActivePinia } from 'pinia'
+import { createMemoryHistory, createRouter } from 'vue-router'
+import AgentTokens from '@/views/AgentTokens.vue'
+import api from '@/services/api'
+import { setAvailableFeatures } from '@/composables/useScopeQuery'
+
+// Issue #1446 item 8: an older GET /tokens must not overwrite a newer one.
+// Item 17: the Profile column keeps a minimum width and its chip never wraps.
+
+vi.mock('@/services/api', () => ({
+ default: {
+ hasAPIKey: vi.fn(() => true),
+ listAgentTokens: vi.fn(),
+ getServers: vi.fn(),
+ getProfiles: vi.fn(),
+ },
+}))
+
+const future = '2030-01-01T00:00:00Z'
+const tok = (name: string) => ({ name, token_prefix: 'mcp_agt_aa', allowed_servers: ['*'], permissions: ['read'], expires_at: future, created_at: future, last_used_at: null, revoked: false, kind: 'agent', legacy_scope: false })
+const stub = { template: '' }
+
+describe('Agent tokens list (#1446)', () => {
+ const original = (HTMLDialogElement.prototype as any).showModal
+ beforeEach(() => {
+ setActivePinia(createPinia())
+ vi.clearAllMocks()
+ ;(HTMLDialogElement.prototype as any).showModal = vi.fn()
+ setAvailableFeatures(['scope_filters'])
+ ;(api.getServers as any).mockResolvedValue({ success: true, data: { servers: [] } })
+ ;(api.getProfiles as any).mockResolvedValue({ profiles: [] })
+ })
+ afterEach(() => { ;(HTMLDialogElement.prototype as any).showModal = original })
+
+ it('the latest load wins when responses resolve out of order', async () => {
+ let resolveFirst!: (v: any) => void
+ ;(api.listAgentTokens as any)
+ .mockReturnValueOnce(new Promise(r => { resolveFirst = r }))
+ .mockResolvedValue({ success: true, data: { tokens: [tok('newer')] } })
+ const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/clients', name: 'clients', component: stub }, { path: '/profiles', name: 'profiles', component: stub }] })
+ await router.push('/clients?tab=tokens')
+ await router.isReady()
+ const wrapper = mount(AgentTokens, { global: { plugins: [router] }, attachTo: document.body })
+ await vi.waitFor(() => expect((api.listAgentTokens as any).mock.calls.length).toBeGreaterThanOrEqual(1))
+ const refresh = (wrapper.vm as any).refreshTokens ?? (wrapper.vm as any).loadTokens
+ if (refresh) {
+ await refresh()
+ } else {
+ await router.push('/clients?tab=tokens&profile=x')
+ }
+ await flushPromises()
+ resolveFirst({ success: true, data: { tokens: [tok('older')] } })
+ await flushPromises()
+ expect(wrapper.find('[data-test="token-row-newer"]').exists()).toBe(true)
+ expect(wrapper.find('[data-test="token-row-older"]').exists()).toBe(false)
+ wrapper.unmount()
+ })
+})
diff --git a/frontend/tests/unit/clients-stale-async-1446.spec.ts b/frontend/tests/unit/clients-stale-async-1446.spec.ts
new file mode 100644
index 000000000..976a34883
--- /dev/null
+++ b/frontend/tests/unit/clients-stale-async-1446.spec.ts
@@ -0,0 +1,351 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { flushPromises, mount } from '@vue/test-utils'
+import { createPinia, setActivePinia } from 'pinia'
+import api from '@/services/api'
+import { useClientsStore } from '@/stores/clients'
+import { useProfilesStore } from '@/stores/profiles'
+import AssignClientDialog from '@/components/profiles/AssignClientDialog.vue'
+import BulkMoveDialog from '@/components/clients/BulkMoveDialog.vue'
+import ClientConnectList from '@/components/ClientConnectList.vue'
+import { WORK_RO, WORK_FULL, makeClient } from './fixtures/profiles108i'
+
+// Issue #1446 (Spec 108-i review): stale-async and scope-list follow-ups.
+
+vi.mock('@/services/api', () => ({
+ default: {
+ hasAPIKey: vi.fn(() => true),
+ getClients: vi.fn(),
+ getClient: vi.fn(),
+ getRouting: vi.fn(),
+ getProfiles: vi.fn(),
+ setClientBinding: vi.fn(),
+ bulkAssignClients: vi.fn(),
+ getConnectStatus: vi.fn(),
+ getConnectClientStatus: vi.fn(),
+ getConnectPreview: vi.fn(),
+ connectClient: vi.fn(),
+ disconnectClient: vi.fn(),
+ getOnboardingState: vi.fn(),
+ },
+}))
+
+function deferred() {
+ let resolve!: (value: T) => void
+ const promise = new Promise(r => { resolve = r })
+ return { promise, resolve }
+}
+const ok = (clients: any[]) => ({ success: true, data: { clients, warnings: [] } })
+const live = (id: string) => makeClient(id, { active_sessions: 1 })
+const idle = (id: string) => makeClient(id, { active_sessions: 0 })
+
+beforeEach(() => {
+ setActivePinia(createPinia())
+ for (const fn of Object.values(api) as any[]) fn.mockReset?.()
+ ;(api.hasAPIKey as any).mockReturnValue(true)
+ ;(api.getRouting as any).mockResolvedValue({ success: true, data: null })
+})
+
+describe('clients store (1446-4, 1446-6, 1446-3)', () => {
+ it('an older load() cannot overwrite a newer refreshPresence()', async () => {
+ const store = useClientsStore()
+ const slow = deferred()
+ ;(api.getClients as any).mockReturnValueOnce(slow.promise)
+ const loading = store.load()
+ ;(api.getClients as any).mockResolvedValue(ok([live('new')]))
+ await store.refreshPresence()
+ expect(store.clients.map(c => c.id)).toEqual(['new'])
+ slow.resolve(ok([idle('old')]))
+ await loading
+ expect(store.clients.map(c => c.id)).toEqual(['new'])
+ expect(store.loading).toBe(false)
+ })
+
+ it('a load() superseded by refreshPresence() still applies its routing', async () => {
+ const store = useClientsStore()
+ const slow = deferred()
+ ;(api.getClients as any).mockReturnValueOnce(slow.promise)
+ ;(api.getRouting as any).mockResolvedValue({ success: true, data: { marker: 'routing' } })
+ const loading = store.load()
+ ;(api.getClients as any).mockResolvedValue(ok([live('new')]))
+ await store.refreshPresence()
+ slow.resolve(ok([idle('old')]))
+ await loading
+ expect((store.routing as any)?.marker).toBe('routing')
+ })
+
+ it('a failed unscoped fetch does not clear the stale flag', async () => {
+ const store = useClientsStore()
+ ;(api.getClients as any).mockResolvedValue(ok([live('a')]))
+ await store.load({ profile: 'work-ro' })
+ store.clearScope()
+ expect(store.stale).toBe(true)
+ ;(api.getClients as any).mockResolvedValue({ success: false, error: 'boom' })
+ await store.refreshPresence()
+ expect(store.stale).toBe(true)
+ })
+
+ it('an older refreshPresence() cannot overwrite a newer load()', async () => {
+ const store = useClientsStore()
+ const slow = deferred()
+ ;(api.getClients as any).mockReturnValueOnce(slow.promise)
+ const polling = store.refreshPresence()
+ ;(api.getClients as any).mockResolvedValue(ok([live('new')]))
+ await store.load()
+ slow.resolve(ok([idle('old')]))
+ await polling
+ expect(store.clients.map(c => c.id)).toEqual(['new'])
+ })
+
+ it('liveCount counts the unscoped roster, so a scoped page load does not shrink it', async () => {
+ const store = useClientsStore()
+ ;(api.getClients as any).mockImplementation(async (scope: any = {}) => ok(scope?.client ? [live('cursor')] : [live('cursor'), live('codex'), idle('zed')]))
+ await store.load({ client: 'cursor' })
+ expect(store.clients.map(c => c.id)).toEqual(['cursor'])
+ expect(store.liveCount).toBe(2)
+ await store.refreshPresence()
+ expect(store.clients).toHaveLength(1)
+ expect(store.liveCount).toBe(2)
+ })
+
+ it('clearScope() marks the unscoped roster for refetch until the next refresh', async () => {
+ const store = useClientsStore()
+ ;(api.getClients as any).mockResolvedValue(ok([live('cursor')]))
+ await store.load({ client: 'cursor' })
+ store.clearScope()
+ expect(store.stale).toBe(true)
+ await store.refreshPresence()
+ expect(store.stale).toBe(false)
+ })
+})
+
+describe('AssignClientDialog (1446-3)', () => {
+ it('lists eligible clients from the unscoped roster and refreshes it on open even when scoped rows exist', async () => {
+ const store = useClientsStore()
+ const bound = (id: string) => makeClient(id, { credential_state: 'client' })
+ ;(api.getClients as any).mockImplementation(async (scope: any = {}) => ok(scope?.client ? [bound('cursor')] : [bound('cursor'), bound('codex')]))
+ await store.load({ client: 'cursor' })
+ vi.mocked(api.getClients).mockClear()
+ HTMLDialogElement.prototype.showModal = vi.fn()
+ const wrapper = mount(AssignClientDialog, { props: { open: false, profileName: 'work-ro' } })
+ await wrapper.setProps({ open: true })
+ await flushPromises()
+ expect(api.getClients).toHaveBeenCalled()
+ const options = wrapper.findAll('option').map(o => o.attributes('value'))
+ expect(options).toContain('codex')
+ expect(options).toContain('cursor')
+ })
+})
+
+describe('BulkMoveDialog (1446-13, 1446-14)', () => {
+ function mountDialog() {
+ HTMLDialogElement.prototype.showModal = vi.fn()
+ const profiles = useProfilesStore()
+ profiles.profiles = [WORK_RO, WORK_FULL] as any
+ return mount(BulkMoveDialog, { props: { open: false, clients: [makeClient('scoped', { profile: 'work-ro' })], initialFrom: 'work-ro' } })
+ }
+
+ it('shows counting and disables Move while the unscoped list is pending, never the filtered rows', async () => {
+ const wrapper = mountDialog()
+ const pending = deferred()
+ ;(api.getClients as any).mockReturnValue(pending.promise)
+ await wrapper.setProps({ open: true })
+ await flushPromises()
+ expect(wrapper.get('[data-test="bulk-preview-line"]').text()).toMatch(/Counting/i)
+ await wrapper.get('[data-test="bulk-to"]').setValue('work-full')
+ expect(wrapper.get('[data-test="bulk-submit"]').attributes('disabled')).toBeDefined()
+ pending.resolve(ok([makeClient('a', { profile: 'work-ro' }), makeClient('b', { profile: 'work-ro' })]))
+ await flushPromises()
+ expect(wrapper.get('[data-test="bulk-preview-line"]').text()).toMatch(/^2 clients use /)
+ expect(wrapper.get('[data-test="bulk-submit"]').attributes('disabled')).toBeUndefined()
+ })
+
+ it('a failed load keeps Move disabled and offers a retry', async () => {
+ const wrapper = mountDialog()
+ ;(api.getClients as any).mockRejectedValueOnce(new Error('offline'))
+ await wrapper.setProps({ open: true })
+ await flushPromises()
+ await wrapper.get('[data-test="bulk-to"]').setValue('work-full')
+ expect(wrapper.find('[data-test="bulk-count-failed"]').exists()).toBe(true)
+ expect(wrapper.get('[data-test="bulk-submit"]').attributes('disabled')).toBeDefined()
+ ;(api.getClients as any).mockResolvedValue(ok([makeClient('a', { profile: 'work-ro' })]))
+ await wrapper.get('[data-test="bulk-count-retry"]').trigger('click')
+ await flushPromises()
+ expect(wrapper.find('[data-test="bulk-count-failed"]').exists()).toBe(false)
+ expect(wrapper.get('[data-test="bulk-submit"]').attributes('disabled')).toBeUndefined()
+ })
+
+ it('a slow response of an earlier open is ignored', async () => {
+ const wrapper = mountDialog()
+ const first = deferred()
+ ;(api.getClients as any).mockReturnValueOnce(first.promise)
+ await wrapper.setProps({ open: true })
+ await wrapper.setProps({ open: false })
+ ;(api.getClients as any).mockResolvedValueOnce(ok([makeClient('fresh', { profile: 'work-ro' })]))
+ await wrapper.setProps({ open: true })
+ await flushPromises()
+ first.resolve(ok([makeClient('x', { profile: 'work-ro' }), makeClient('y', { profile: 'work-ro' }), makeClient('z', { profile: 'work-ro' })]))
+ await flushPromises()
+ expect(wrapper.get('[data-test="bulk-preview-line"]').text()).toMatch(/^1 client uses /)
+ })
+})
+
+describe('ClientConnectList (1446-7, 1446-11, 1446-12)', () => {
+ const guard = Object.assign(new Error('refused'), { name: 'ApiError', error: 'GUARD-TEXT', code: 'binding_bypassable_without_auth', status: 409, fixes: [{ kind: 'require_mcp_auth' }] })
+ function previewOf(token: string) {
+ return {
+ success: true,
+ data: { client: 'cursor', config_path: '/x/mcp.json', display_path: '~/x/mcp.json', format: 'json', server_key: 'mcpServers', server_name: 'mcpproxy', entry_text: '{}', entry_exists: false, contains_api_key: false, access_state: 'accessible', precondition_token: token },
+ }
+ }
+ async function open() {
+ ;(api.getConnectStatus as any).mockResolvedValue({ success: true, data: [{ id: 'cursor', name: 'Cursor', config_path: '/x/mcp.json', exists: true, connected: false, supported: true, icon: 'cursor' }] })
+ ;(api.getOnboardingState as any).mockResolvedValue({ success: true, data: null })
+ ;(api.getClients as any).mockResolvedValue(ok([makeClient('cursor')]))
+ useProfilesStore().profiles = [WORK_RO] as any
+ HTMLDialogElement.prototype.showModal = vi.fn()
+ const wrapper = mount(ClientConnectList, { props: { show: false } })
+ await wrapper.setProps({ show: true })
+ await flushPromises()
+ return wrapper
+ }
+
+ it('disables Connect while a changed intent refreshes the preview, and ignores a slower older preview', async () => {
+ ;(api.getConnectPreview as any).mockResolvedValueOnce(previewOf('tok-1'))
+ const wrapper = await open()
+ await wrapper.get('[data-test="connect-cursor"]').trigger('click')
+ await flushPromises()
+ const confirm = () => wrapper.get('[data-test="client-preview-confirm-cursor"]')
+ expect(confirm().attributes('disabled')).toBeUndefined()
+
+ const slow = deferred()
+ ;(api.getConnectPreview as any).mockReturnValueOnce(slow.promise)
+ await wrapper.get('[data-test="connect-profile-select-cursor"]').setValue('work-ro')
+ await flushPromises()
+ expect(confirm().attributes('disabled')).toBeDefined()
+
+ const fast = deferred()
+ ;(api.getConnectPreview as any).mockReturnValueOnce(fast.promise)
+ await wrapper.get('[data-test="connect-profile-select-cursor"]').setValue('')
+ fast.resolve(previewOf('tok-new'))
+ await flushPromises()
+ slow.resolve(previewOf('tok-old'))
+ await flushPromises()
+ expect(confirm().attributes('disabled')).toBeUndefined()
+ ;(api.connectClient as any).mockResolvedValue({ success: true, data: { success: true, message: 'ok', config_path: '/x', backup_path: '' } })
+ await confirm().trigger('click')
+ await flushPromises()
+ expect((api.connectClient as any).mock.calls[0][3].precondition_token).toBe('tok-new')
+ })
+
+ it('Cancel clears a guard refusal of that client', async () => {
+ ;(api.getConnectPreview as any).mockResolvedValueOnce(previewOf('tok-1'))
+ const wrapper = await open()
+ await wrapper.get('[data-test="connect-cursor"]').trigger('click')
+ await flushPromises()
+ ;(api.connectClient as any).mockRejectedValue(guard)
+ await wrapper.get('[data-test="client-preview-confirm-cursor"]').trigger('click')
+ await flushPromises()
+ const vm: any = wrapper.vm
+ expect(wrapper.find('[data-test="guard-refusal"]').exists()).toBe(true)
+ await wrapper.get('[data-test="client-preview-cancel-cursor"]').trigger('click')
+ await flushPromises()
+ expect(vm).toBeTruthy()
+ expect(wrapper.find('[data-test="connect-bulk-refusals"]').exists()).toBe(false)
+ // Re-open: no stale refusal is rendered next to the new preview.
+ ;(api.getConnectPreview as any).mockResolvedValueOnce(previewOf('tok-2'))
+ await wrapper.get('[data-test="connect-cursor"]').trigger('click')
+ await flushPromises()
+ expect(wrapper.find('[data-test="guard-refusal"]').exists()).toBe(false)
+ })
+
+ it('keeps Connect disabled when the preview re-fetch fails', async () => {
+ ;(api.getConnectPreview as any).mockResolvedValueOnce(previewOf('tok-1'))
+ const wrapper = await open()
+ await wrapper.get('[data-test="connect-cursor"]').trigger('click')
+ await flushPromises()
+ const confirm = () => wrapper.get('[data-test="client-preview-confirm-cursor"]')
+ ;(api.getConnectPreview as any).mockResolvedValueOnce({ success: false, error: 'nope' })
+ await wrapper.get('[data-test="connect-profile-select-cursor"]').setValue('work-ro')
+ await flushPromises()
+ expect(confirm().attributes('disabled')).toBeDefined()
+ ;(api.getConnectPreview as any).mockRejectedValueOnce(new Error('net'))
+ await wrapper.get('[data-test="connect-profile-select-cursor"]').setValue('')
+ await flushPromises()
+ expect(confirm().attributes('disabled')).toBeDefined()
+ })
+
+ it('shows why Connect is disabled after a failed re-fetch and drops the error on Cancel', async () => {
+ ;(api.getConnectPreview as any).mockResolvedValueOnce(previewOf('tok-1'))
+ const wrapper = await open()
+ await wrapper.get('[data-test="connect-cursor"]').trigger('click')
+ await flushPromises()
+ ;(api.getConnectPreview as any).mockResolvedValueOnce({ success: false, error: 'nope' })
+ await wrapper.get('[data-test="connect-profile-select-cursor"]').setValue('work-ro')
+ await flushPromises()
+ expect(wrapper.get('[data-test="client-preview-confirm-cursor"]').attributes('disabled')).toBeDefined()
+ expect(wrapper.get('[data-test="connect-preview-error-cursor"]').text()).toContain('nope')
+ await wrapper.get('[data-test="client-preview-cancel-cursor"]').trigger('click')
+ await flushPromises()
+ expect(wrapper.find('[data-test="connect-preview-error-cursor"]').exists()).toBe(false)
+ })
+
+ it('shows the error when the preview re-fetch throws', async () => {
+ ;(api.getConnectPreview as any).mockResolvedValueOnce(previewOf('tok-1'))
+ const wrapper = await open()
+ await wrapper.get('[data-test="connect-cursor"]').trigger('click')
+ await flushPromises()
+ ;(api.getConnectPreview as any).mockRejectedValueOnce(new Error('net down'))
+ await wrapper.get('[data-test="connect-profile-select-cursor"]').setValue('work-ro')
+ await flushPromises()
+ expect(wrapper.get('[data-test="client-preview-confirm-cursor"]').attributes('disabled')).toBeDefined()
+ expect(wrapper.get('[data-test="connect-preview-error-cursor"]').text()).toContain('net down')
+ })
+
+ it('a late startConnect failure does not stale a newer successful refresh', async () => {
+ ;(api.getConnectPreview as any).mockResolvedValueOnce(previewOf('tok-1'))
+ const wrapper = await open()
+ await wrapper.get('[data-test="connect-cursor"]').trigger('click')
+ await flushPromises()
+ const late = deferred()
+ ;(api.getConnectPreview as any).mockReturnValueOnce(late.promise)
+ await wrapper.get('[data-test="connect-cursor"]').trigger('click')
+ ;(api.getConnectPreview as any).mockResolvedValueOnce(previewOf('tok-new'))
+ await wrapper.get('[data-test="connect-profile-select-cursor"]').setValue('work-ro')
+ await flushPromises()
+ late.resolve({ success: false, error: 'late failure' })
+ await flushPromises()
+ expect(wrapper.get('[data-test="client-preview-confirm-cursor"]').attributes('disabled')).toBeUndefined()
+ expect(wrapper.find('[data-test="connect-preview-error-cursor"]').exists()).toBe(false)
+ })
+
+ it('keeps Connect disabled when the explicit re-open fetch also fails', async () => {
+ ;(api.getConnectPreview as any).mockResolvedValueOnce(previewOf('tok-1'))
+ const wrapper = await open()
+ await wrapper.get('[data-test="connect-cursor"]').trigger('click')
+ await flushPromises()
+ ;(api.getConnectPreview as any).mockResolvedValueOnce({ success: false, error: 'nope' })
+ await wrapper.get('[data-test="connect-profile-select-cursor"]').setValue('work-ro')
+ await flushPromises()
+ ;(api.getConnectPreview as any).mockResolvedValueOnce({ success: false, error: 'still nope' })
+ await wrapper.get('[data-test="connect-cursor"]').trigger('click')
+ await flushPromises()
+ expect(wrapper.get('[data-test="client-preview-confirm-cursor"]').attributes('disabled')).toBeDefined()
+ })
+
+ it('an in-flight refresh does not disable Connect after close and reopen', async () => {
+ ;(api.getConnectPreview as any).mockResolvedValueOnce(previewOf('tok-1'))
+ const wrapper = await open()
+ await wrapper.get('[data-test="connect-cursor"]').trigger('click')
+ await flushPromises()
+ const slow = deferred()
+ ;(api.getConnectPreview as any).mockReturnValueOnce(slow.promise)
+ await wrapper.get('[data-test="connect-profile-select-cursor"]').setValue('work-ro')
+ await wrapper.setProps({ show: false })
+ await wrapper.setProps({ show: true })
+ await flushPromises()
+ ;(api.getConnectPreview as any).mockResolvedValueOnce(previewOf('tok-2'))
+ await wrapper.get('[data-test="connect-cursor"]').trigger('click')
+ await flushPromises()
+ expect(wrapper.get('[data-test="client-preview-confirm-cursor"]').attributes('disabled')).toBeUndefined()
+ })
+})
diff --git a/frontend/tests/unit/profiles-sse-refresh.spec.ts b/frontend/tests/unit/profiles-sse-refresh.spec.ts
index 0baeeab85..43bc22cbe 100644
--- a/frontend/tests/unit/profiles-sse-refresh.spec.ts
+++ b/frontend/tests/unit/profiles-sse-refresh.spec.ts
@@ -55,12 +55,14 @@ describe('profiles and client rows refresh on SSE events (Spec 108-i T102)', ()
it('refetches the client rows on client.binding_changed, keeping the scope and the warnings', async () => {
const store = useClientsStore()
await store.load({ profile: 'work' })
- expect(api.getClients).toHaveBeenLastCalledWith({ profile: 'work' })
+ // A scoped load also reads the unscoped roster (sidebar badge, assign dialog).
+ expect(api.getClients).toHaveBeenNthCalledWith(1, { profile: 'work' })
+ expect(api.getClients).toHaveBeenNthCalledWith(2, {})
;(api.getClients as any).mockResolvedValue({ success: true, data: { clients: [makeClient('cursor', { profile: 'work' })], warnings: [{ code: 'client_rotation_pending', severity: 'info', message: 'pending' }] } })
window.dispatchEvent(new CustomEvent('mcpproxy:client.binding_changed', { detail: { client_id: 'cursor' } }))
await vi.advanceTimersByTimeAsync(0)
- expect(api.getClients).toHaveBeenCalledTimes(2)
- expect(api.getClients).toHaveBeenLastCalledWith({ profile: 'work' })
+ expect(api.getClients).toHaveBeenCalledTimes(4)
+ expect(api.getClients).toHaveBeenNthCalledWith(3, { profile: 'work' })
expect(store.clients[0].profile).toBe('work')
expect(store.warnings.map(warning => warning.code)).toEqual(['client_rotation_pending'])
})
|