268 lines
10 KiB
TypeScript
268 lines
10 KiB
TypeScript
// @vitest-environment jsdom
|
|
/** First-run DeepSeek prompt 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 Schema from '@deepseek-ai/schemastery'
|
|
import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/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()
|
|
document.getElementById('root')?.remove()
|
|
})
|
|
|
|
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: {} } },
|
|
}
|
|
}
|
|
|
|
const DeepSeekConfig = Schema.object({
|
|
apiKeyEnv: Schema.string().role('credential-ref'),
|
|
baseURL: Schema.string().pattern(/^https:\/\//),
|
|
reasoningEffort: Schema.union(['off', 'high', 'max']),
|
|
defaultContextWindow: Schema.number().step(1).min(1),
|
|
models: Schema.array(Schema.object({
|
|
id: Schema.string().required(),
|
|
name: Schema.string(),
|
|
description: Schema.string(),
|
|
contextWindow: Schema.number().step(1).min(1),
|
|
})),
|
|
})
|
|
|
|
function deepSeekNamespace(apiKeyEnv: string | null): SettingsNamespaceView {
|
|
const value = apiKeyEnv === null ? {} : { apiKeyEnv }
|
|
return {
|
|
ns: 'llm-deepseek',
|
|
schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as unknown,
|
|
value,
|
|
base: value,
|
|
user: {},
|
|
applies: 'live',
|
|
secrets: [],
|
|
revision: 0,
|
|
}
|
|
}
|
|
|
|
function harness(options: {
|
|
provider?: boolean
|
|
providerSettingsNs?: string
|
|
providerActive?: boolean
|
|
settingsNamespace?: boolean
|
|
apiKeyEnv?: string | null
|
|
configured?: () => boolean
|
|
credential?: { source?: string; writable: boolean }
|
|
describeFailure?: string
|
|
settingsWritable?: boolean
|
|
providersReject?: boolean
|
|
setFailure?: string
|
|
setReject?: string
|
|
} = {}) {
|
|
if (document.getElementById('root') === null) {
|
|
const appRoot = document.createElement('div')
|
|
appRoot.id = 'root'
|
|
document.body.append(appRoot)
|
|
}
|
|
let fileConfigured = false
|
|
const configured = options.configured ?? (() => fileConfigured)
|
|
const apiKeyEnv = options.apiKeyEnv === undefined ? 'DEEPSEEK_API_KEY' : options.apiKeyEnv
|
|
const mutate = vi.fn(() => Promise.resolve(ok(deepSeekNamespace(apiKeyEnv))))
|
|
const set = vi.fn((_payload: { ref: string; value: string }) => {
|
|
if (options.setReject !== undefined) return Promise.reject(new Error(options.setReject))
|
|
if (options.setFailure !== undefined) return Promise.resolve(fail(options.setFailure))
|
|
fileConfigured = true
|
|
return Promise.resolve(ok({}))
|
|
})
|
|
const face = {
|
|
llm: {
|
|
providers: () => {
|
|
if (options.providersReject === true) return Promise.reject(new Error('provider transport unavailable'))
|
|
return Promise.resolve(ok({
|
|
providers: options.provider === false
|
|
? []
|
|
: [{
|
|
provider: 'deepseek-official',
|
|
displayName: 'DeepSeek',
|
|
settingsNs: options.providerSettingsNs ?? 'llm-deepseek',
|
|
settingsPath: [],
|
|
active: options.providerActive ?? true,
|
|
}],
|
|
}))
|
|
},
|
|
},
|
|
settings: {
|
|
describe: () => Promise.resolve(ok({
|
|
writable: options.settingsWritable ?? true,
|
|
hasDocument: false,
|
|
namespaces: options.settingsNamespace === false ? [] : [deepSeekNamespace(apiKeyEnv)],
|
|
})),
|
|
mutate,
|
|
},
|
|
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 complete = vi.fn()
|
|
const unusedHook = (() => { throw new Error('unused standard hook') }) as never
|
|
const props: DeepSeekOnboardingDialogProps = {
|
|
stepId: 'deepseek-official',
|
|
complete,
|
|
openSection,
|
|
useSessions: unusedHook,
|
|
useWorkspaces: unusedHook,
|
|
controller,
|
|
useModels: bindSnapshotSelector(controller.store),
|
|
api: face as never,
|
|
t: key => en[key],
|
|
}
|
|
return {
|
|
controller, complete, openSection, props, mutate, set,
|
|
configure: () => { fileConfigured = true },
|
|
}
|
|
}
|
|
|
|
describe('DeepSeekOnboardingDialog', () => {
|
|
it('renders when the shell root is absent', async () => {
|
|
const h = harness()
|
|
document.getElementById('root')!.remove()
|
|
render(<DeepSeekOnboardingDialog {...h.props} />)
|
|
expect(await screen.findByRole('dialog', { name: en.onboardingTitle })).toBeTruthy()
|
|
})
|
|
|
|
it('loads a credential-only modal, inerts the product, and focuses the key', async () => {
|
|
const h = harness()
|
|
render(<DeepSeekOnboardingDialog {...h.props} />)
|
|
expect(await screen.findByRole('dialog', { name: en.onboardingTitle })).toBeTruthy()
|
|
expect(document.getElementById('root')?.inert).toBe(true)
|
|
expect(screen.getByText(en.onboardingDescription)).toBeTruthy()
|
|
const key = screen.getByLabelText<HTMLInputElement>(en.keyInput)
|
|
await waitFor(() => { expect(document.activeElement).toBe(key) })
|
|
expect(screen.queryByText(en.customized)).toBeNull()
|
|
})
|
|
|
|
it('cannot be dismissed implicitly and restores the previous inert state', async () => {
|
|
const h = harness()
|
|
const appRoot = document.getElementById('root')!
|
|
appRoot.inert = true
|
|
const view = render(<DeepSeekOnboardingDialog {...h.props} />)
|
|
await screen.findByRole('dialog')
|
|
|
|
fireEvent.keyDown(document, { key: 'Escape' })
|
|
fireEvent.click(document.querySelector('[class*="mask"]')!)
|
|
expect(screen.getByRole('dialog')).toBeTruthy()
|
|
expect(h.complete).not.toHaveBeenCalled()
|
|
|
|
view.unmount()
|
|
expect(appRoot.inert).toBe(true)
|
|
})
|
|
|
|
it('requires a non-blank key before Save and continue is available', async () => {
|
|
const h = harness()
|
|
render(<DeepSeekOnboardingDialog {...h.props} />)
|
|
await screen.findByRole('dialog')
|
|
const save = screen.getByRole<HTMLButtonElement>('button', { name: en.onboardingSave })
|
|
expect(save.disabled).toBe(true)
|
|
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: ' ' } })
|
|
expect(save.disabled).toBe(true)
|
|
expect(screen.getByText(en.keyRequired)).toBeTruthy()
|
|
expect(h.set).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('keeps the modal open and reports rejected and failed credential writes', async () => {
|
|
for (const [options, message] of [
|
|
[{ setFailure: 'credential was rejected' }, 'credential was rejected'],
|
|
[{ setReject: 'connection lost' }, 'connection lost'],
|
|
] as const) {
|
|
const h = harness(options)
|
|
const view = render(<DeepSeekOnboardingDialog {...h.props} />)
|
|
await screen.findByRole('dialog')
|
|
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'sk-live' } })
|
|
fireEvent.click(screen.getByRole('button', { name: en.onboardingSave }))
|
|
expect(await screen.findByText(message)).toBeTruthy()
|
|
expect(screen.getByRole('dialog')).toBeTruthy()
|
|
expect(screen.getByRole<HTMLButtonElement>('button', { name: en.onboardingSave }).disabled).toBe(false)
|
|
expect(h.complete).not.toHaveBeenCalled()
|
|
expect(h.mutate).not.toHaveBeenCalled()
|
|
view.unmount()
|
|
}
|
|
})
|
|
|
|
it('allows configure-later dismissal without opening settings', async () => {
|
|
const h = harness()
|
|
render(<DeepSeekOnboardingDialog {...h.props} />)
|
|
await screen.findByRole('dialog')
|
|
fireEvent.click(screen.getByRole('button', { name: en.onboardingLater }))
|
|
expect(h.complete).toHaveBeenCalledOnce()
|
|
expect(h.openSection).not.toHaveBeenCalled()
|
|
expect(h.set).not.toHaveBeenCalled()
|
|
expect(h.mutate).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('does not block the product when DeepSeek setup is unavailable', async () => {
|
|
for (const h of [
|
|
harness({ describeFailure: 'credentials service is absent' }),
|
|
harness({ credential: { writable: false } }),
|
|
harness({ settingsWritable: false }),
|
|
harness({ providersReject: true }),
|
|
harness({ providerActive: false }),
|
|
harness({ settingsNamespace: false }),
|
|
harness({ apiKeyEnv: null }),
|
|
]) {
|
|
const view = render(<DeepSeekOnboardingDialog {...h.props} />)
|
|
await act(async () => { await h.controller.load() })
|
|
expect(screen.queryByRole('dialog')).toBeNull()
|
|
await waitFor(() => { expect(h.complete).toHaveBeenCalledOnce() })
|
|
expect(h.openSection).not.toHaveBeenCalled()
|
|
view.unmount()
|
|
}
|
|
})
|
|
|
|
it('skips an absent adapter and an already-configured environment credential', async () => {
|
|
for (const h of [
|
|
harness({ provider: false }),
|
|
harness({ providerSettingsNs: '' }),
|
|
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()
|
|
await waitFor(() => { expect(h.complete).toHaveBeenCalledOnce() })
|
|
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() })
|
|
expect(h.complete).toHaveBeenCalledOnce()
|
|
})
|
|
})
|