openpencil/tests/engine/app/settings/mcp-workflow.test.ts
Danila Poyarkov b0dfde74f5 fix(settings): standardize save feedback and deletion guards
Share persistent alerts, distinguish partial persistence from validation errors, preserve model identity on retries, and apply the store's deletion eligibility before clearing credentials. Document toast, field-error, alert, and Storybook ownership.
2026-09-15 17:53:30 +03:00

437 lines
13 KiB
TypeScript

import { beforeEach, afterEach, expect, test } from 'bun:test'
import { effectScope, ref } from 'vue'
import {
createMCPConnectionDraft,
mcpConnectionSettings,
type MCPConnection
} from '@/app/integrations/mcp'
import { useMCPConnectionSettings } from '@/app/integrations/mcp/settings/use'
const connection: MCPConnection = {
id: 'mcp-test',
name: 'Test',
enabled: true,
transport: { type: 'streamable-http', url: 'https://example.com/mcp' },
authentication: { type: 'none' }
}
let previousConnections = mcpConnectionSettings.value.connections
beforeEach(() => {
previousConnections = mcpConnectionSettings.value.connections
mcpConnectionSettings.value.connections = [structuredClone(connection)]
})
afterEach(() => {
mcpConnectionSettings.value.connections = previousConnections
})
test('MCP save commits staged clearing without changing a new draft', async () => {
const scope = effectScope()
let finish: () => void = () => undefined
const pending = new Promise<void>((resolve) => {
finish = resolve
})
const saved: boolean[] = []
const token = ref('')
try {
const state = scope.run(() =>
useMCPConnectionSettings(
ref(createMCPConnectionDraft()),
token,
ref({ bearerTokenRequired: 'Required' }),
{
status: async () => 'configured',
setCredential: async () => pending,
save: (draft) => {
saved.push(draft.enabled)
return connection
},
remove: async () => undefined
}
)
)
if (!state) throw new Error('Missing scope')
state.draft.value.id = connection.id
state.clearCredential()
expect(saved).toEqual([])
const clearing = state.save()
state.startAdd()
state.draft.value.enabled = true
token.value = 'new-key'
finish()
await clearing
expect(saved).toEqual([false, false])
expect(state.credentialCleared.value).toBe(false)
expect(state.draft.value.enabled).toBe(true)
expect(token.value).toBe('new-key')
} finally {
scope.stop()
}
})
test('MCP edit ignores credential status after another draft opens', async () => {
const previous = mcpConnectionSettings.value.connections
mcpConnectionSettings.value.connections = [connection]
const scope = effectScope()
let finish: () => void = () => undefined
const pending = new Promise<void>((resolve) => {
finish = resolve
})
try {
const state = scope.run(() =>
useMCPConnectionSettings(
ref(createMCPConnectionDraft()),
ref(''),
ref({ bearerTokenRequired: 'Required' }),
{
status: async () => {
await pending
return 'configured'
},
setCredential: async () => undefined,
save: () => connection,
remove: async () => undefined
}
)
)
if (!state) throw new Error('Missing scope')
const editing = state.startEdit(connection.id)
state.startAdd()
finish()
expect(await editing).toBe(false)
expect(state.tokenStatus.value).toBe('missing')
} finally {
scope.stop()
mcpConnectionSettings.value.connections = previous
}
})
test('MCP failed writes preserve replacements and staged removals', async () => {
const scope = effectScope()
const token = ref('replacement')
try {
const state = scope.run(() =>
useMCPConnectionSettings(
ref(createMCPConnectionDraft()),
token,
ref({ bearerTokenRequired: 'Required' }),
{
status: async () => 'configured',
setCredential: async () => {
throw new Error('Offline')
},
save: () => connection,
remove: async () => undefined
}
)
)
if (!state) throw new Error('Missing scope')
state.draft.value.id = connection.id
expect(await state.save()).toBe('partial')
expect(state.error.value).toBe('Offline')
expect(token.value).toBe('replacement')
state.clearCredential()
expect(state.error.value).toBe('')
expect(token.value).toBe('')
expect(state.credentialCleared.value).toBe(true)
expect(await state.save()).toBe('partial')
expect(state.error.value).toBe('Offline')
expect(state.credentialCleared.value).toBe(true)
} finally {
scope.stop()
}
})
test('failed bearer write never enables the saved connection', async () => {
const scope = effectScope()
const saved: boolean[] = []
try {
const state = scope.run(() =>
useMCPConnectionSettings(
ref(createMCPConnectionDraft()),
ref('token'),
ref({ bearerTokenRequired: 'Required' }),
{
status: async () => 'configured',
setCredential: async () => {
throw new Error('Write failed')
},
save: (draft) => {
saved.push(draft.enabled)
return connection
},
remove: async () => undefined
}
)
)
if (!state) throw new Error('Missing scope')
state.draft.value.authenticationType = 'bearer'
state.draft.value.enabled = true
expect(await state.save()).toBe('partial')
expect(saved).toEqual([false])
} finally {
scope.stop()
}
})
test('clear never persists unsaved fields and cancel discards removal', async () => {
const scope = effectScope()
const names: string[] = []
try {
const state = scope.run(() =>
useMCPConnectionSettings(
ref(createMCPConnectionDraft()),
ref(''),
ref({ bearerTokenRequired: 'Required' }),
{
status: async () => 'configured',
setCredential: async () => undefined,
save: (draft) => {
names.push(draft.name)
expect(draft.url).toBe(connection.transport.url)
return connection
},
remove: async () => undefined
}
)
)
if (!state) throw new Error('Missing scope')
state.draft.value.id = connection.id
state.draft.value.name = ''
state.draft.value.url = 'invalid'
state.clearCredential()
expect(names).toEqual([])
expect(state.draft.value.url).toBe('invalid')
expect(state.credentialCleared.value).toBe(true)
state.cancel()
expect(state.credentialCleared.value).toBe(false)
} finally {
scope.stop()
}
})
test('status lookup failure opens the editor with an error', async () => {
const scope = effectScope()
try {
const state = scope.run(() =>
useMCPConnectionSettings(
ref(createMCPConnectionDraft()),
ref(''),
ref({ bearerTokenRequired: 'Required' }),
{
status: async () => {
throw new Error('Locked')
},
setCredential: async () => undefined,
save: () => connection,
remove: async () => undefined
}
)
)
if (!state) throw new Error('Missing scope')
expect(await state.startEdit(connection.id)).toBe(true)
expect(state.error.value).toBe('Locked')
} finally {
scope.stop()
}
})
test('serializes overlapping credential replacements', async () => {
const scope = effectScope()
const writes: string[] = []
let finish: () => void = () => undefined
const blocked = new Promise<void>((resolve) => {
finish = resolve
})
const token = ref('first')
try {
const state = scope.run(() =>
useMCPConnectionSettings(
ref(createMCPConnectionDraft()),
token,
ref({ bearerTokenRequired: 'Required' }),
{
status: async () => 'configured',
setCredential: async (_id, value) => {
writes.push(value)
if (value === 'first') await blocked
},
save: () => connection,
remove: async () => undefined
}
)
)
if (!state) throw new Error('Missing scope')
state.draft.value.id = connection.id
state.draft.value.authenticationType = 'bearer'
const first = state.save()
await Promise.resolve()
token.value = 'second'
const second = state.save()
await Promise.resolve()
expect(writes).toEqual(['first'])
finish()
await Promise.all([first, second])
expect(writes).toEqual(['first', 'second'])
} finally {
finish()
scope.stop()
}
})
test('reopened editors serialize writes to the same connection while other connections proceed', async () => {
const firstScope = effectScope()
const secondScope = effectScope()
let finish: () => void = () => undefined
const blocked = new Promise<void>((resolve) => {
finish = resolve
})
const writes: string[] = []
const services = {
status: async () => 'configured' as const,
setCredential: async (_id: string, value: string) => {
writes.push(value)
if (value === 'first') await blocked
},
save: (draft: { id: `mcp-${string}` | null }) => ({
...connection,
id: draft.id ?? connection.id
}),
remove: async () => undefined
}
try {
const first = firstScope.run(() =>
useMCPConnectionSettings(
ref(createMCPConnectionDraft()),
ref('first'),
ref({ bearerTokenRequired: 'Required' }),
services
)
)
const second = secondScope.run(() =>
useMCPConnectionSettings(
ref(createMCPConnectionDraft()),
ref('second'),
ref({ bearerTokenRequired: 'Required' }),
services
)
)
const other = secondScope.run(() =>
useMCPConnectionSettings(
ref(createMCPConnectionDraft()),
ref('other'),
ref({ bearerTokenRequired: 'Required' }),
services
)
)
if (!first || !second || !other) throw new Error('Missing scope')
for (const editor of [first, second, other]) {
editor.draft.value.id = connection.id
editor.draft.value.authenticationType = 'bearer'
}
other.draft.value.id = 'mcp-other'
const savingFirst = first.save()
await Promise.resolve()
firstScope.stop()
const savingSecond = second.save()
await other.save()
expect(writes).toEqual(['first', 'other'])
finish()
await Promise.all([savingFirst, savingSecond])
expect(writes).toEqual(['first', 'other', 'second'])
} finally {
finish()
firstScope.stop()
secondScope.stop()
}
})
test('blank-token save rechecks status after a different editor clears the credential', async () => {
const scope = effectScope()
let configured = true
const enabled: boolean[] = []
const services = {
status: async () => (configured ? ('configured' as const) : ('missing' as const)),
setCredential: async (_id: string, value: string) => {
configured = Boolean(value)
},
save: (draft: { enabled: boolean }) => {
enabled.push(draft.enabled)
return connection
},
remove: async () => undefined
}
try {
const first = scope.run(() =>
useMCPConnectionSettings(
ref(createMCPConnectionDraft()),
ref(''),
ref({ bearerTokenRequired: 'Required' }),
services
)
)
const second = scope.run(() =>
useMCPConnectionSettings(
ref(createMCPConnectionDraft()),
ref(''),
ref({ bearerTokenRequired: 'Required' }),
services
)
)
if (!first || !second) throw new Error('Missing scope')
await first.startEdit(connection.id)
await second.startEdit(connection.id)
second.draft.value.authenticationType = 'bearer'
second.draft.value.enabled = true
first.clearCredential()
expect(configured).toBe(true)
const clearing = first.save()
const saving = second.save()
await clearing
expect(await saving).toBe('partial')
expect(second.error.value).toBe('Required')
expect(enabled).toEqual([false, false, false])
} finally {
scope.stop()
}
})
test('cancel clears secrets and ignores a pending credential lookup', async () => {
const scope = effectScope()
const token = ref('')
const lookup = Promise.withResolvers<'configured'>()
try {
const state = scope.run(() =>
useMCPConnectionSettings(
ref(createMCPConnectionDraft()),
token,
ref({ bearerTokenRequired: 'Required' }),
{
status: () => lookup.promise,
save: () => connection,
setCredential: async () => undefined,
remove: async () => undefined
}
)
)
if (!state) throw new Error('Missing scope')
const editing = state.startEdit(connection.id)
expect(state.busy.value).toBe(true)
token.value = 'discard-me'
state.cancel()
expect(token.value).toBe('')
expect(state.draft.value.id).toBeNull()
lookup.resolve('configured')
expect(await editing).toBe(false)
expect(state.busy.value).toBe(false)
expect(state.tokenStatus.value).toBe('missing')
token.value = 'discard-on-close'
scope.stop()
expect(token.value).toBe('')
} finally {
lookup.resolve('configured')
scope.stop()
}
})