feat(web): configure DeepSeek during onboarding
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
/** Models section registration: declaration-aware deferral, locale re-registration, and HMR recovery. */
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { apply, inject, refreshIfLoaded } from '@deepseek-ai/dsh-client-ui-models/client'
|
||||
import { ModelsSection } from '../src/client/ModelsSection.tsx'
|
||||
import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx'
|
||||
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
@@ -19,7 +20,13 @@ async function bench() {
|
||||
|
||||
function declare(slots: SlotsService): () => void {
|
||||
return slots.register(
|
||||
{ name: 'root', children: { 'settings.section': { kind: 'list', scope: 'root' } } } as never,
|
||||
{
|
||||
name: 'root',
|
||||
children: {
|
||||
'settings.section': { kind: 'list', scope: 'root' },
|
||||
'settings.onboarding': { kind: 'list', scope: 'root' },
|
||||
},
|
||||
} as never,
|
||||
() => null,
|
||||
)
|
||||
}
|
||||
@@ -41,13 +48,18 @@ describe('ui-models apply', () => {
|
||||
expect(typeof injected.controller.load).toBe('function')
|
||||
expect(typeof injected.useSnapshot).toBe('function')
|
||||
expect(injected.api).toBeDefined()
|
||||
const onboarding = before.slots.entries('settings.onboarding')[0]!
|
||||
expect(onboarding.component).toBe(DeepSeekOnboardingDialog)
|
||||
expect(onboarding.options).toMatchObject({ id: 'deepseek-official', order: 0 })
|
||||
|
||||
const after = await bench()
|
||||
await after.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
expect(after.slots.entries('settings.section')).toHaveLength(0)
|
||||
expect(after.slots.entries('settings.onboarding')).toHaveLength(0)
|
||||
declare(after.slots)
|
||||
await Promise.resolve()
|
||||
expect(after.slots.entries('settings.section')[0]!.component).toBe(ModelsSection)
|
||||
expect(after.slots.entries('settings.onboarding')[0]!.component).toBe(DeepSeekOnboardingDialog)
|
||||
// The self-inflicted ledger notifications hit the duplicate guard.
|
||||
expect(after.slots.entries('settings.section')).toHaveLength(1)
|
||||
})
|
||||
@@ -79,9 +91,11 @@ describe('ui-models apply', () => {
|
||||
// disposer variable goes stale.
|
||||
redeclare()
|
||||
expect(b.slots.entries('settings.section')).toHaveLength(0)
|
||||
expect(b.slots.entries('settings.onboarding')).toHaveLength(0)
|
||||
declare(b.slots)
|
||||
await Promise.resolve()
|
||||
expect(b.slots.entries('settings.section')[0]!.component).toBe(ModelsSection)
|
||||
expect(b.slots.entries('settings.onboarding')[0]!.component).toBe(DeepSeekOnboardingDialog)
|
||||
// The locale path also recovers through the same ledger re-check.
|
||||
b.locale.setLocale('en')
|
||||
expect(b.slots.entries('settings.section')[0]!.options.label).toBe('Models')
|
||||
@@ -96,6 +110,7 @@ describe('ui-models apply', () => {
|
||||
expect(b.locale.bind('settings.models')('nav')).toBe('模型')
|
||||
await fiber.dispose()
|
||||
expect(b.slots.entries('settings.section')).toHaveLength(0)
|
||||
expect(b.slots.entries('settings.onboarding')).toHaveLength(0)
|
||||
// The (ns, locale) seats are free again — the dictionary disposers ran.
|
||||
expect(() => b.locale.register('settings.models', 'zh', {})).not.toThrow()
|
||||
expect(() => b.locale.register('settings.models', 'en', {})).not.toThrow()
|
||||
@@ -129,4 +144,18 @@ describe('pushed invalidations', () => {
|
||||
refreshIfLoaded(idle as unknown as import('../src/client/store.ts').ModelsSettingsStore)
|
||||
expect(loads).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('routes pushed credential invalidation into the shared onboarding join', async () => {
|
||||
const b = await bench()
|
||||
declare(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const injected = (
|
||||
b.slots.entries('settings.onboarding')[0]!.inject as unknown as
|
||||
() => import('../src/client/DeepSeekOnboardingDialog.tsx').DeepSeekOnboardingInjected
|
||||
)()
|
||||
injected.controller.store.update((state) => { state.status = 'ready' })
|
||||
const load = vi.spyOn(injected.controller, 'load').mockResolvedValue()
|
||||
b.ctx.emit('credentials/changed', 'DEEPSEEK_API_KEY')
|
||||
expect(load).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@ import Schema from 'schemastery'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { ModelsSection, removeProviderProfile } from '../src/client/ModelsSection.tsx'
|
||||
import type { ModelsSectionInjected } from '../src/client/ModelsSection.tsx'
|
||||
import type { ModelsSectionInjected, ModelsSectionProps } from '../src/client/ModelsSection.tsx'
|
||||
import { ModelsSettingsStore } from '../src/client/store.ts'
|
||||
import { en } from '../src/client/locales.ts'
|
||||
|
||||
@@ -121,6 +121,12 @@ async function mountSection(overrides: Parameters<typeof scriptedFace>[0] = {})
|
||||
}
|
||||
|
||||
describe('ModelsSection', () => {
|
||||
it('renders nothing before the slot injects its dependencies', () => {
|
||||
const uninjected = {} as ModelsSectionProps
|
||||
render(<ModelsSection {...uninjected} />)
|
||||
expect(document.body.textContent).toBe('')
|
||||
})
|
||||
|
||||
it('renders configured rows with status badges and the add vocabulary', async () => {
|
||||
await mountSection()
|
||||
expect(screen.getByText('DeepSeek')).toBeTruthy()
|
||||
@@ -135,6 +141,16 @@ describe('ModelsSection', () => {
|
||||
expect(screen.getAllByText(en.remove)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('does not mark a provider with a configured literal key as missing', async () => {
|
||||
const { controller } = await mountSection()
|
||||
controller.store.update((state) => {
|
||||
state.rows = state.rows.map(row => row.entry.provider === 'deepseek-official'
|
||||
? { ...row, literalApiKeyConfigured: true }
|
||||
: row)
|
||||
})
|
||||
await waitFor(() => { expect(screen.queryByText(en.keyMissing)).toBeNull() })
|
||||
})
|
||||
|
||||
it('opens the editor, applies an edit as a merge patch, and reloads', async () => {
|
||||
const { update, face } = await mountSection()
|
||||
fireEvent.click(screen.getAllByText(en.edit)[1] as HTMLElement)
|
||||
|
||||
265
packages/client/ui-models/tests/onboarding-dialog.spec.tsx
Normal file
265
packages/client/ui-models/tests/onboarding-dialog.spec.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
// @vitest-environment jsdom
|
||||
/** First-run DeepSeek dialog behavior over the shared Models join. */
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcResponse } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx'
|
||||
import type { DeepSeekOnboardingDialogProps } from '../src/client/DeepSeekOnboardingDialog.tsx'
|
||||
import { ModelsSettingsStore } from '../src/client/store.ts'
|
||||
import { en } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
let nextRpc = 0
|
||||
function ok<T>(value: T): RpcResponse<T> {
|
||||
return { rpcId: `onboarding-${nextRpc++}` as never, result: { ok: true, value } }
|
||||
}
|
||||
function fail<T>(message: string): RpcResponse<T> {
|
||||
return {
|
||||
rpcId: `onboarding-${nextRpc++}` as never,
|
||||
result: { ok: false, error: { code: 'internal', message, details: {} } },
|
||||
}
|
||||
}
|
||||
|
||||
function harness(options: {
|
||||
provider?: boolean
|
||||
literal?: boolean
|
||||
configured?: () => boolean
|
||||
credential?: { source?: string; writable: boolean }
|
||||
describeFailure?: string
|
||||
set?: (payload: { ref: string; value: string }) => Promise<RpcResponse<{}>>
|
||||
} = {}) {
|
||||
let fileConfigured = false
|
||||
const configured = options.configured ?? (() => fileConfigured)
|
||||
const set = vi.fn(options.set ?? ((payload: { ref: string; value: string }) => {
|
||||
fileConfigured = payload.value.length > 0
|
||||
return Promise.resolve(ok({}))
|
||||
}))
|
||||
const face = {
|
||||
llm: {
|
||||
providers: () => Promise.resolve(ok({
|
||||
providers: options.provider === false
|
||||
? []
|
||||
: [{
|
||||
provider: 'deepseek-official',
|
||||
displayName: 'DeepSeek',
|
||||
settingsNs: 'llm-deepseek',
|
||||
settingsPath: [],
|
||||
active: true,
|
||||
}],
|
||||
})),
|
||||
},
|
||||
settings: {
|
||||
describe: () => Promise.resolve(ok({
|
||||
writable: true,
|
||||
namespaces: [{
|
||||
ns: 'llm-deepseek',
|
||||
schema: {},
|
||||
value: { apiKeyEnv: 'DEEPSEEK_API_KEY' },
|
||||
applies: 'live' as const,
|
||||
secrets: [{ path: ['apiKey'], set: options.literal === true }],
|
||||
}],
|
||||
})),
|
||||
},
|
||||
credentials: {
|
||||
describe: () => options.describeFailure === undefined
|
||||
? Promise.resolve(ok({
|
||||
credentials: {
|
||||
DEEPSEEK_API_KEY: {
|
||||
configured: configured(),
|
||||
...configured() && options.credential?.source !== undefined
|
||||
? { source: options.credential.source }
|
||||
: {},
|
||||
writable: options.credential?.writable ?? true,
|
||||
},
|
||||
},
|
||||
}))
|
||||
: Promise.resolve(fail(options.describeFailure)),
|
||||
set,
|
||||
},
|
||||
}
|
||||
const controller = new ModelsSettingsStore(face as never)
|
||||
const openSection = vi.fn()
|
||||
const unusedHook = (() => { throw new Error('unused standard hook') }) as never
|
||||
const props: DeepSeekOnboardingDialogProps = {
|
||||
active: true,
|
||||
openSection,
|
||||
useSessions: unusedHook,
|
||||
useWorkspaces: unusedHook,
|
||||
controller,
|
||||
useSnapshot: bindSnapshotSelector(controller.store),
|
||||
credentials: face.credentials as never,
|
||||
t: key => en[key],
|
||||
}
|
||||
return { controller, face, openSection, props, set, configure: () => { fileConfigured = true } }
|
||||
}
|
||||
|
||||
describe('DeepSeekOnboardingDialog', () => {
|
||||
it('loads on first entry and presents an accessible write-only key form', async () => {
|
||||
const h = harness()
|
||||
render(<DeepSeekOnboardingDialog {...h.props} />)
|
||||
const dialog = await screen.findByRole('dialog', { name: en.onboardingTitle })
|
||||
expect(dialog).toBeTruthy()
|
||||
expect(screen.getByLabelText<HTMLInputElement>(en.provider).value).toBe('DeepSeek')
|
||||
const key = screen.getByLabelText<HTMLInputElement>(en.onboardingKey)
|
||||
expect(key.type).toBe('password')
|
||||
expect(key.autocomplete).toBe('off')
|
||||
expect(key.getAttribute('spellcheck')).toBe('false')
|
||||
})
|
||||
|
||||
it('stores through credentials.set, verifies through describe, clears the draft, and closes', async () => {
|
||||
const h = harness()
|
||||
render(<DeepSeekOnboardingDialog {...h.props} />)
|
||||
const key = await screen.findByLabelText<HTMLInputElement>(en.onboardingKey)
|
||||
const secret = 'test-onboarding-secret'
|
||||
fireEvent.change(key, { target: { value: secret } })
|
||||
fireEvent.click(screen.getByRole('button', { name: en.onboardingSave }))
|
||||
await waitFor(() => { expect(screen.queryByRole('dialog')).toBeNull() })
|
||||
expect(h.set).toHaveBeenCalledWith({ ref: 'DEEPSEEK_API_KEY', value: secret })
|
||||
expect(document.body.textContent).not.toContain(secret)
|
||||
expect(document.documentElement.outerHTML).not.toContain(secret)
|
||||
})
|
||||
|
||||
it('keeps a business failure open without echoing the secret', async () => {
|
||||
const secret = 'business-secret'
|
||||
const h = harness({
|
||||
set: payload => Promise.resolve(fail(`refused ${payload.value}`)),
|
||||
})
|
||||
render(<DeepSeekOnboardingDialog {...h.props} />)
|
||||
const key = await screen.findByLabelText<HTMLInputElement>(en.onboardingKey)
|
||||
fireEvent.change(key, { target: { value: secret } })
|
||||
fireEvent.click(screen.getByRole('button', { name: en.onboardingSave }))
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert.textContent).toContain('[redacted]')
|
||||
expect(alert.textContent).not.toContain(secret)
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: en.onboardingSave }).disabled).toBe(false)
|
||||
expect(screen.getByRole('dialog')).toBeTruthy()
|
||||
fireEvent.change(key, { target: { value: 'replacement' } })
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows saving state and reports a failed configured-state verification', async () => {
|
||||
let settle: (() => void) | undefined
|
||||
const pending = new Promise<void>((resolve) => { settle = resolve })
|
||||
const h = harness({
|
||||
set: async () => {
|
||||
await pending
|
||||
return ok({})
|
||||
},
|
||||
})
|
||||
render(<DeepSeekOnboardingDialog {...h.props} />)
|
||||
fireEvent.change(await screen.findByLabelText(en.onboardingKey), { target: { value: 'verify-secret' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: en.onboardingSave }))
|
||||
expect(screen.getByRole('button', { name: en.onboardingSaving })).toBeTruthy()
|
||||
settle?.()
|
||||
expect((await screen.findByRole('alert')).textContent).toBe(en.onboardingVerifyFailed)
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: en.onboardingSave }).disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('recovers busy state after a transport rejection without an unhandled rejection', async () => {
|
||||
const secret = 'transport-secret'
|
||||
const h = harness({
|
||||
set: () => Promise.reject(new Error(`transport rejected ${secret}`)),
|
||||
})
|
||||
const unhandled = vi.fn()
|
||||
window.addEventListener('unhandledrejection', unhandled)
|
||||
try {
|
||||
render(<DeepSeekOnboardingDialog {...h.props} />)
|
||||
const key = await screen.findByLabelText<HTMLInputElement>(en.onboardingKey)
|
||||
fireEvent.change(key, { target: { value: secret } })
|
||||
fireEvent.click(screen.getByRole('button', { name: en.onboardingSave }))
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert.textContent).not.toContain(secret)
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: en.onboardingSave }).disabled).toBe(false)
|
||||
expect(unhandled).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
window.removeEventListener('unhandledrejection', unhandled)
|
||||
}
|
||||
})
|
||||
|
||||
it('stringifies a non-Error transport rejection without exposing its secret', async () => {
|
||||
const secret = 'plain-rejection-secret'
|
||||
const h = harness({
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
|
||||
set: () => Promise.reject(`transport refused ${secret}`),
|
||||
})
|
||||
render(<DeepSeekOnboardingDialog {...h.props} />)
|
||||
fireEvent.change(await screen.findByLabelText(en.onboardingKey), { target: { value: secret } })
|
||||
fireEvent.click(screen.getByRole('button', { name: en.onboardingSave }))
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert.textContent).toContain('[redacted]')
|
||||
expect(alert.textContent).not.toContain(secret)
|
||||
})
|
||||
|
||||
it('cancels without writing and opens the Models section through the owner callback', async () => {
|
||||
const cancelled = harness()
|
||||
const first = render(<DeepSeekOnboardingDialog {...cancelled.props} />)
|
||||
await screen.findByRole('dialog')
|
||||
fireEvent.click(screen.getByRole('button', { name: en.onboardingLater }))
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect(cancelled.set).not.toHaveBeenCalled()
|
||||
first.unmount()
|
||||
|
||||
const advanced = harness()
|
||||
render(<DeepSeekOnboardingDialog {...advanced.props} />)
|
||||
await screen.findByRole('dialog')
|
||||
fireEvent.click(screen.getByRole('button', { name: en.onboardingAdvanced }))
|
||||
expect(advanced.openSection).toHaveBeenCalledWith('models')
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect(advanced.set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows an actionable deployment diagnostic when credentials are unavailable', async () => {
|
||||
const h = harness({ describeFailure: 'credentials service is absent' })
|
||||
render(<DeepSeekOnboardingDialog {...h.props} />)
|
||||
await screen.findByRole('dialog', { name: en.onboardingUnavailableTitle })
|
||||
expect(screen.getByText(en.onboardingCredentialsUnavailable)).toBeTruthy()
|
||||
expect(screen.queryByLabelText(en.onboardingKey)).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: en.retry }))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: en.retry }).disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the deployment diagnostic for a missing read-only credential', async () => {
|
||||
const h = harness({ credential: { writable: false } })
|
||||
render(<DeepSeekOnboardingDialog {...h.props} />)
|
||||
await screen.findByRole('dialog', { name: en.onboardingUnavailableTitle })
|
||||
expect(screen.getByText(en.onboardingConfigurationUnavailable)).toBeTruthy()
|
||||
expect(screen.queryByLabelText(en.onboardingKey)).toBeNull()
|
||||
})
|
||||
|
||||
it('skips an absent adapter and already-configured literal or environment credentials', async () => {
|
||||
for (const h of [
|
||||
harness({ provider: false }),
|
||||
harness({ literal: true, describeFailure: 'credential seam absent' }),
|
||||
harness({ configured: () => true, credential: { source: 'env', writable: false } }),
|
||||
]) {
|
||||
const view = render(<DeepSeekOnboardingDialog {...h.props} />)
|
||||
await act(async () => { await h.controller.load() })
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
view.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('closes when an external credential invalidation refreshes the shared join', async () => {
|
||||
const h = harness()
|
||||
render(<DeepSeekOnboardingDialog {...h.props} />)
|
||||
await screen.findByRole('dialog')
|
||||
h.configure()
|
||||
await act(async () => { await h.controller.load() })
|
||||
await waitFor(() => { expect(screen.queryByRole('dialog')).toBeNull() })
|
||||
})
|
||||
|
||||
it('clears a typed draft when the onboarding owner becomes inactive', async () => {
|
||||
const h = harness()
|
||||
const view = render(<DeepSeekOnboardingDialog {...h.props} />)
|
||||
const key = await screen.findByLabelText<HTMLInputElement>(en.onboardingKey)
|
||||
fireEvent.change(key, { target: { value: 'ephemeral' } })
|
||||
view.rerender(<DeepSeekOnboardingDialog {...h.props} active={false} />)
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
view.rerender(<DeepSeekOnboardingDialog {...h.props} active />)
|
||||
expect((await screen.findByLabelText<HTMLInputElement>(en.onboardingKey)).value).toBe('')
|
||||
})
|
||||
})
|
||||
112
packages/client/ui-models/tests/readiness.spec.ts
Normal file
112
packages/client/ui-models/tests/readiness.spec.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/** Pure official-DeepSeek readiness projection over the shared Models join. */
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { CredentialView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ModelsSettingsState, ProviderRow } from '../src/client/store.ts'
|
||||
import { deepSeekReadiness } from '../src/client/store.ts'
|
||||
|
||||
const missingCredential: CredentialView = { configured: false, writable: true }
|
||||
|
||||
function row(overrides: Partial<ProviderRow> = {}): ProviderRow {
|
||||
return {
|
||||
entry: {
|
||||
provider: 'deepseek-official',
|
||||
displayName: 'DeepSeek',
|
||||
settingsNs: 'llm-deepseek',
|
||||
settingsPath: [],
|
||||
active: true,
|
||||
},
|
||||
configured: true,
|
||||
removable: false,
|
||||
apiKeyEnv: 'DEEPSEEK_API_KEY',
|
||||
credential: missingCredential,
|
||||
literalApiKeyConfigured: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function state(overrides: Partial<ModelsSettingsState> = {}): ModelsSettingsState {
|
||||
return {
|
||||
status: 'ready',
|
||||
error: null,
|
||||
credentialError: null,
|
||||
writable: true,
|
||||
rows: [row()],
|
||||
namespaces: new Map(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('deepSeekReadiness', () => {
|
||||
it('waits for the first join and skips onboarding when the adapter directory entry is absent', () => {
|
||||
expect(deepSeekReadiness(state({ status: 'idle', rows: [] }))).toEqual({ kind: 'loading' })
|
||||
expect(deepSeekReadiness(state({ status: 'loading', rows: [] }))).toEqual({ kind: 'loading' })
|
||||
expect(deepSeekReadiness(state({ rows: [] }))).toEqual({ kind: 'adapter-absent' })
|
||||
})
|
||||
|
||||
it('addresses the effective credential reference when it is missing and writable', () => {
|
||||
expect(deepSeekReadiness(state())).toEqual({
|
||||
kind: 'credential-missing',
|
||||
displayName: 'DeepSeek',
|
||||
ref: 'DEEPSEEK_API_KEY',
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts file and process-environment credentials without prompting', () => {
|
||||
expect(deepSeekReadiness(state({
|
||||
rows: [row({ credential: { configured: true, source: 'file', writable: true } })],
|
||||
}))).toMatchObject({
|
||||
kind: 'configured',
|
||||
source: 'credential',
|
||||
ref: 'DEEPSEEK_API_KEY',
|
||||
credential: { source: 'file', writable: true },
|
||||
})
|
||||
expect(deepSeekReadiness(state({
|
||||
rows: [row({ credential: { configured: true, source: 'env', writable: false } })],
|
||||
}))).toMatchObject({
|
||||
kind: 'configured',
|
||||
source: 'credential',
|
||||
credential: { source: 'env', writable: false },
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts the redacted literal-key sidecar before judging the credential domain', () => {
|
||||
expect(deepSeekReadiness(state({
|
||||
credentialError: 'credentials service absent',
|
||||
rows: [row({ literalApiKeyConfigured: true, credential: undefined })],
|
||||
}))).toEqual({ kind: 'configured', source: 'literal' })
|
||||
})
|
||||
|
||||
it('turns missing capabilities and inconsistent descriptors into diagnostics', () => {
|
||||
expect(deepSeekReadiness(state({ status: 'error', error: 'settings down' }))).toEqual({
|
||||
kind: 'unavailable',
|
||||
reason: 'settings-unavailable',
|
||||
message: 'settings down',
|
||||
})
|
||||
expect(deepSeekReadiness(state({ status: 'error', error: null }))).toMatchObject({
|
||||
kind: 'unavailable',
|
||||
reason: 'settings-unavailable',
|
||||
})
|
||||
expect(deepSeekReadiness(state({
|
||||
rows: [row({ entry: { ...row().entry, active: false } })],
|
||||
}))).toMatchObject({ kind: 'unavailable', reason: 'provider-inactive' })
|
||||
expect(deepSeekReadiness(state({
|
||||
rows: [row({ configured: false })],
|
||||
}))).toMatchObject({ kind: 'unavailable', reason: 'settings-unavailable' })
|
||||
expect(deepSeekReadiness(state({
|
||||
rows: [row({ apiKeyEnv: undefined })],
|
||||
}))).toMatchObject({ kind: 'unavailable', reason: 'credential-ref-unavailable' })
|
||||
expect(deepSeekReadiness(state({
|
||||
credentialError: 'credentials service is absent',
|
||||
}))).toMatchObject({
|
||||
kind: 'unavailable',
|
||||
reason: 'credentials-unavailable',
|
||||
message: 'credentials service is absent',
|
||||
})
|
||||
expect(deepSeekReadiness(state({
|
||||
rows: [row({ credential: undefined })],
|
||||
}))).toMatchObject({ kind: 'unavailable', reason: 'credentials-unavailable' })
|
||||
expect(deepSeekReadiness(state({
|
||||
rows: [row({ credential: { configured: false, writable: false } })],
|
||||
}))).toMatchObject({ kind: 'unavailable', reason: 'credential-read-only' })
|
||||
})
|
||||
})
|
||||
@@ -75,6 +75,7 @@ describe('ModelsSettingsStore', () => {
|
||||
const state = store.store.getSnapshot()
|
||||
expect(state.status).toBe('ready')
|
||||
expect(state.writable).toBe(true)
|
||||
expect(state.credentialError).toBeNull()
|
||||
expect(seenRefs).toEqual([['DEEPSEEK_API_KEY', 'OPENAI_API_KEY']])
|
||||
const byProvider = new Map(state.rows.map(row => [row.entry.provider, row]))
|
||||
expect(byProvider.get('deepseek-official')).toMatchObject({
|
||||
@@ -82,6 +83,7 @@ describe('ModelsSettingsStore', () => {
|
||||
removable: false,
|
||||
apiKeyEnv: 'DEEPSEEK_API_KEY',
|
||||
credential: { configured: false, writable: true },
|
||||
literalApiKeyConfigured: false,
|
||||
})
|
||||
expect(byProvider.get('openai')).toMatchObject({
|
||||
configured: true,
|
||||
@@ -101,9 +103,55 @@ describe('ModelsSettingsStore', () => {
|
||||
await store.load()
|
||||
const state = store.store.getSnapshot()
|
||||
expect(state.status).toBe('ready')
|
||||
expect(state.credentialError).toBe('no provider')
|
||||
expect(state.rows.every(row => row.credential === undefined)).toBe(true)
|
||||
})
|
||||
|
||||
it('settles a credential transport rejection without leaving the store loading', async () => {
|
||||
const { face } = api({
|
||||
describeCredentials: () => Promise.reject(new Error('credential transport down')),
|
||||
})
|
||||
const store = new ModelsSettingsStore(face)
|
||||
await expect(store.load()).resolves.toBeUndefined()
|
||||
expect(store.store.getSnapshot()).toMatchObject({
|
||||
status: 'ready',
|
||||
credentialError: 'credential transport down',
|
||||
})
|
||||
})
|
||||
|
||||
it('stringifies a non-Error credential transport rejection', async () => {
|
||||
const { face } = api({
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
|
||||
describeCredentials: () => Promise.reject('credential transport refusal'),
|
||||
})
|
||||
const store = new ModelsSettingsStore(face)
|
||||
await expect(store.load()).resolves.toBeUndefined()
|
||||
expect(store.store.getSnapshot().credentialError).toBe('credential transport refusal')
|
||||
})
|
||||
|
||||
it('joins a configured literal key from the redacted secret sidecar', async () => {
|
||||
const { face } = api({
|
||||
describeSettings: () => Promise.resolve(ok({
|
||||
writable: true,
|
||||
namespaces: [{
|
||||
...NAMESPACES[0],
|
||||
secrets: [
|
||||
{ path: ['apiKey', 'nested'], set: true },
|
||||
{ path: ['different'], set: true },
|
||||
{ path: ['apiKey'], set: true },
|
||||
],
|
||||
}] as never,
|
||||
})),
|
||||
providers: () => Promise.resolve(ok({ providers: [DIRECTORY[0]] as never })),
|
||||
})
|
||||
const store = new ModelsSettingsStore(face)
|
||||
await store.load()
|
||||
expect(store.store.getSnapshot().rows[0]).toMatchObject({
|
||||
literalApiKeyConfigured: true,
|
||||
apiKeyEnv: 'DEEPSEEK_API_KEY',
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces a directory failure and keeps the last good rows', async () => {
|
||||
const { face } = api()
|
||||
const store = new ModelsSettingsStore(face)
|
||||
|
||||
Reference in New Issue
Block a user