feat(web): route onboarding to Models settings
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user