feat(web): route onboarding to Models settings

This commit is contained in:
Yichen Jiang
2026-07-30 13:11:07 +08:00
parent 7fc1b5a777
commit 42d0f3c7ba
16 changed files with 83 additions and 349 deletions

View File

@@ -1,5 +1,5 @@
// @vitest-environment jsdom
/** First-run DeepSeek dialog behavior over the shared Models join. */
/** 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 type { RpcResponse } from '@deepseek-ai/dsh-client-connection/client'
@@ -28,14 +28,9 @@ function harness(options: {
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({
@@ -76,7 +71,6 @@ function harness(options: {
},
}))
: Promise.resolve(fail(options.describeFailure)),
set,
},
}
const controller = new ModelsSettingsStore(face as never)
@@ -89,145 +83,53 @@ function harness(options: {
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 } }
return { controller, openSection, props, configure: () => { fileConfigured = true } }
}
describe('DeepSeekOnboardingDialog', () => {
it('loads on first entry and presents an accessible write-only key form', async () => {
it('loads on first entry and presents one accessible route to Models', 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')
expect(await screen.findByRole('dialog', { name: en.onboardingTitle })).toBeTruthy()
expect(screen.getByText(en.onboardingDescription)).toBeTruthy()
expect(screen.getByRole('button', { name: en.onboardingGoToSettings })).toBeTruthy()
expect(screen.queryByRole('textbox')).toBeNull()
})
it('stores through credentials.set, verifies through describe, clears the draft, and closes', async () => {
it('opens the Models section and dismisses the prompt', 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)
await screen.findByRole('dialog')
fireEvent.click(screen.getByRole('button', { name: en.onboardingGoToSettings }))
expect(h.openSection).toHaveBeenCalledWith('models')
expect(screen.queryByRole('dialog', { name: en.onboardingTitle })).toBeNull()
})
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}`)),
})
it('allows configure-later dismissal without opening settings', async () => {
const h = harness()
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()
expect(h.openSection).not.toHaveBeenCalled()
})
it('shows an actionable deployment diagnostic when credentials are unavailable', async () => {
it('routes an unavailable credential deployment to Models with a diagnostic', 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)
})
fireEvent.click(screen.getByRole('button', { name: en.onboardingGoToSettings }))
expect(h.openSection).toHaveBeenCalledWith('models')
})
it('uses the deployment diagnostic for a missing read-only credential', async () => {
it('uses the general 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 () => {
@@ -252,14 +154,12 @@ describe('DeepSeekOnboardingDialog', () => {
await waitFor(() => { expect(screen.queryByRole('dialog')).toBeNull() })
})
it('clears a typed draft when the onboarding owner becomes inactive', async () => {
it('stays hidden while the onboarding owner is 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} />)
const view = render(<DeepSeekOnboardingDialog {...h.props} active={false} />)
await act(async () => { await h.controller.load() })
expect(screen.queryByRole('dialog')).toBeNull()
view.rerender(<DeepSeekOnboardingDialog {...h.props} active />)
expect((await screen.findByLabelText<HTMLInputElement>(en.onboardingKey)).value).toBe('')
expect(await screen.findByRole('dialog', { name: en.onboardingTitle })).toBeTruthy()
})
})

View File

@@ -43,12 +43,8 @@ describe('deepSeekReadiness', () => {
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('reports a missing writable effective credential', () => {
expect(deepSeekReadiness(state())).toEqual({ kind: 'credential-missing' })
})
it('accepts file and process-environment credentials without prompting', () => {