fix(web): end first-run onboarding on any usable provider
The step and the Models page both asked one question of a join that describes every provider: is deepseek-official's credential stored? A user who configured some other route was taken over on every blank session, and the DeepSeek setup card opened over them on every visit to Models with a Cancel that could not close it — while clearing the add card's draft, because it shared the row-editor close handler. providerUsable(row) now answers what both surfaces need: the route is registered and whatever credential its profile names is stored. Readiness (renamed onboardingReadiness) ends on any usable row, needsSetup takes the same fact, and each card kind owns its own close handler. Fixes #2325
This commit is contained in:
@@ -23,6 +23,8 @@ afterEach(cleanup)
|
||||
const t: ModelsSectionInjected['t'] = key => en[key]
|
||||
const OPENAI_TARGET = { provider: 'openai', displayName: 'openai' }
|
||||
const openaiCopy = (template: string): string => providerCopy(template, OPENAI_TARGET)
|
||||
const DEEPSEEK_TARGET = { provider: 'deepseek-official', displayName: 'DeepSeek' }
|
||||
const deepSeekCopy = (template: string): string => providerCopy(template, DEEPSEEK_TARGET)
|
||||
|
||||
/** Open one row's capacity disclosure (1-based, as the labels read). */
|
||||
function expandRow(position: number): void {
|
||||
@@ -181,8 +183,8 @@ function scriptedFace(overrides: {
|
||||
|
||||
type WireFace = ConstructorParameters<typeof ModelsSettingsStore>[0]
|
||||
|
||||
async function mountSection(overrides: Parameters<typeof scriptedFace>[0] = {}) {
|
||||
const { face, update, replace, mutate, set, unset } = scriptedFace(overrides)
|
||||
async function mountFace(scripted: ReturnType<typeof scriptedFace>) {
|
||||
const { face, update, replace, mutate, set, unset } = scripted
|
||||
const controller = new ModelsSettingsStore(face as unknown as WireFace)
|
||||
await controller.load()
|
||||
const injected: ModelsSectionInjected = {
|
||||
@@ -195,6 +197,34 @@ async function mountSection(overrides: Parameters<typeof scriptedFace>[0] = {})
|
||||
return { view, face, update, replace, mutate, set, unset, controller }
|
||||
}
|
||||
|
||||
async function mountSection(overrides: Parameters<typeof scriptedFace>[0] = {}) {
|
||||
return mountFace(scriptedFace(overrides))
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount for a user who cannot reach any provider yet: no credential is stored
|
||||
* anywhere, so the whole-section DeepSeek route owns the first-run setup card.
|
||||
*/
|
||||
async function mountFirstRun(overrides: Parameters<typeof scriptedFace>[0] = {}) {
|
||||
const scripted = scriptedFace(overrides)
|
||||
scripted.face.credentials.describe.mockImplementation((payload: { refs: string[] }) =>
|
||||
Promise.resolve(ok({
|
||||
credentials: Object.fromEntries(payload.refs.map(ref => [ref, { configured: false, writable: true }])),
|
||||
})))
|
||||
return mountFace(scripted)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount and open the DeepSeek editor. The shared fixture already has a usable
|
||||
* openai route, so DeepSeek is an ordinary row whose card opens through Edit
|
||||
* rather than by itself.
|
||||
*/
|
||||
async function mountDeepSeekCard(overrides: Parameters<typeof scriptedFace>[0] = {}) {
|
||||
const mounted = await mountSection(overrides)
|
||||
fireEvent.click(screen.getByRole('button', { name: deepSeekCopy(en.editProvider) }))
|
||||
return mounted
|
||||
}
|
||||
|
||||
describe('ModelsSection', () => {
|
||||
it('renders nothing before the slot injects its dependencies', () => {
|
||||
const uninjected = {} as ModelsSectionProps
|
||||
@@ -202,20 +232,32 @@ describe('ModelsSection', () => {
|
||||
expect(document.body.textContent).toBe('')
|
||||
})
|
||||
|
||||
it('renders the unkeyed whole-section provider as an open setup card beside the rows', async () => {
|
||||
await mountSection()
|
||||
// DeepSeek has no configured credential and no stored apiKey → setup card.
|
||||
it('renders the unkeyed whole-section provider as an open setup card in the first-run posture', async () => {
|
||||
await mountFirstRun()
|
||||
// Nothing is reachable yet, and DeepSeek has no configured credential and
|
||||
// no stored apiKey → setup card.
|
||||
expect(screen.getByText('DeepSeek')).toBeTruthy()
|
||||
expect(screen.getByLabelText(en.keyInput)).toBeTruthy()
|
||||
expect(screen.getByText('openai')).toBeTruthy()
|
||||
expect(screen.queryByText('Active')).toBeNull()
|
||||
expect(screen.queryByText('Inactive')).toBeNull()
|
||||
expect(screen.getByText(en.add)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('leaves the unkeyed provider a plain row once another provider is usable', async () => {
|
||||
await mountSection()
|
||||
// openai's key is stored, so the user is not blocked and nothing on the
|
||||
// page opens itself over them.
|
||||
expect(screen.queryByLabelText(en.keyInput)).toBeNull()
|
||||
const configured = screen.getByRole('img', { name: en.credentialConfigured })
|
||||
expect(configured.getAttribute('title')).toBe(en.credentialConfigured)
|
||||
expect(configured.className).toContain('credentialDotConfigured')
|
||||
expect(configured.closest('li')?.textContent).toContain('openai')
|
||||
expect(screen.queryByRole('img', { name: en.credentialMissing })).toBeNull()
|
||||
expect(screen.getByText(en.add)).toBeTruthy()
|
||||
const missing = screen.getByRole('img', { name: en.credentialMissing })
|
||||
expect(missing.closest('li')?.textContent).toContain('DeepSeek')
|
||||
// The card is still one click away.
|
||||
fireEvent.click(screen.getByRole('button', { name: deepSeekCopy(en.editProvider) }))
|
||||
expect(screen.getByLabelText(en.keyInput)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('marks only a confirmed missing reference and leaves native or unavailable state unmarked', async () => {
|
||||
@@ -241,7 +283,7 @@ describe('ModelsSection', () => {
|
||||
})
|
||||
|
||||
it('turns the setup card into a row once the credential reports configured', async () => {
|
||||
const { face } = await mountSection()
|
||||
const { face } = await mountFirstRun()
|
||||
face.credentials.describe.mockImplementation((payload: { refs: string[] }) => Promise.resolve(ok({
|
||||
credentials: Object.fromEntries(payload.refs.map(ref => [ref, { configured: true, writable: true }])),
|
||||
})))
|
||||
@@ -259,7 +301,7 @@ describe('ModelsSection', () => {
|
||||
expect(screen.queryByLabelText(en.keyInput)).toBeNull()
|
||||
})
|
||||
|
||||
it('decides setup need from the joined credential state', () => {
|
||||
it('decides setup need from the joined credential state and the first-run posture', () => {
|
||||
const entry = { provider: 'p', displayName: 'p', settingsNs: 'llm-deepseek', settingsPath: [], active: true }
|
||||
const row = (credential: ProviderRow['credential']): ProviderRow => ({
|
||||
entry,
|
||||
@@ -268,10 +310,13 @@ describe('ModelsSection', () => {
|
||||
apiKeyEnv: 'X',
|
||||
credential,
|
||||
})
|
||||
expect(needsSetup(row(undefined))).toBe(true)
|
||||
expect(needsSetup(row({ configured: true, writable: true }))).toBe(false)
|
||||
expect(needsSetup(row(undefined), false)).toBe(true)
|
||||
expect(needsSetup(row({ configured: true, writable: true }), false)).toBe(false)
|
||||
const nested = { ...row(undefined), entry: { ...entry, settingsPath: ['providers', 'x'] } }
|
||||
expect(needsSetup(nested)).toBe(false)
|
||||
expect(needsSetup(nested, false)).toBe(false)
|
||||
// A user who can already reach some provider is not in the first-run
|
||||
// posture, so nothing on the page opens itself.
|
||||
expect(needsSetup(row(undefined), true)).toBe(false)
|
||||
})
|
||||
|
||||
it('derives conventional credential references from route ids', () => {
|
||||
@@ -296,7 +341,7 @@ describe('ModelsSection', () => {
|
||||
})
|
||||
|
||||
it('stores a typed key write-only from the setup card without touching settings', async () => {
|
||||
const { set, update, face } = await mountSection()
|
||||
const { set, update, face } = await mountFirstRun()
|
||||
const key = screen.getByLabelText<HTMLInputElement>(en.keyInput)
|
||||
fireEvent.change(key, { target: { value: ' sk-live ' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
@@ -311,7 +356,7 @@ describe('ModelsSection', () => {
|
||||
})
|
||||
|
||||
it('applies customized deepseek fields as path ops', async () => {
|
||||
const { mutate } = await mountSection({
|
||||
const { mutate } = await mountDeepSeekCard({
|
||||
mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
|
||||
})
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
@@ -332,7 +377,7 @@ describe('ModelsSection', () => {
|
||||
})
|
||||
|
||||
it('materializes inherited models and adds an arbitrary DeepSeek id', async () => {
|
||||
const { mutate } = await mountSection({
|
||||
const { mutate } = await mountDeepSeekCard({
|
||||
mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
|
||||
})
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
@@ -366,7 +411,7 @@ describe('ModelsSection', () => {
|
||||
})
|
||||
|
||||
it('rejects duplicate DeepSeek model ids before writing', async () => {
|
||||
const { mutate } = await mountSection()
|
||||
const { mutate } = await mountDeepSeekCard()
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
fireEvent.click(screen.getByText(en.addModel))
|
||||
const ids = screen.getAllByLabelText(new RegExp(en.modelId))
|
||||
@@ -436,7 +481,7 @@ describe('ModelsSection', () => {
|
||||
})
|
||||
|
||||
it('accepts a suffixed context window and stores the plain count', async () => {
|
||||
const { mutate } = await mountSection({
|
||||
const { mutate } = await mountDeepSeekCard({
|
||||
mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
|
||||
})
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
@@ -476,7 +521,7 @@ describe('ModelsSection', () => {
|
||||
})
|
||||
|
||||
it('keeps unreadable context-window text on screen and refuses the write', async () => {
|
||||
const { mutate } = await mountSection()
|
||||
const { mutate } = await mountDeepSeekCard()
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
expandRow(1)
|
||||
expandRow(2)
|
||||
@@ -539,7 +584,7 @@ describe('ModelsSection', () => {
|
||||
// The regression: one active buffer meant editing a second row displaced
|
||||
// the first, which then fell back to rendering its stored NaN as `NaN` —
|
||||
// losing the text the user was told they could still correct.
|
||||
await mountSection()
|
||||
await mountDeepSeekCard()
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
expandRow(1)
|
||||
expandRow(2)
|
||||
@@ -553,7 +598,7 @@ describe('ModelsSection', () => {
|
||||
})
|
||||
|
||||
it('re-keys the typed text around a removed row', async () => {
|
||||
await mountSection()
|
||||
await mountDeepSeekCard()
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
const windows = (): HTMLInputElement[] => capacityInputs(en.contextWindow)
|
||||
const removeRow = (at: number): void => {
|
||||
@@ -587,7 +632,7 @@ describe('ModelsSection', () => {
|
||||
// The regression: reset removed the override but left the buffer, so an
|
||||
// inherited row displayed text no settings layer stores — and because an
|
||||
// unreadable buffer never settles, it stayed there indefinitely.
|
||||
const { mutate } = await mountSection({
|
||||
const { mutate } = await mountDeepSeekCard({
|
||||
mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
|
||||
})
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
@@ -605,12 +650,12 @@ describe('ModelsSection', () => {
|
||||
// Reset put the draft back where it started, so Apply writes nothing at
|
||||
// all rather than persisting whatever the stale text had parsed to.
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
await waitFor(() => { expect(screen.getByText(en.apply)).toBeTruthy() })
|
||||
await waitFor(() => { expect(screen.queryByText(en.apply)).toBeNull() })
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('edits an output cap per model and carries its text across a removal', async () => {
|
||||
const { mutate } = await mountSection({
|
||||
const { mutate } = await mountDeepSeekCard({
|
||||
mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
|
||||
})
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
@@ -644,7 +689,7 @@ describe('ModelsSection', () => {
|
||||
})
|
||||
|
||||
it('settles a pasted id and refuses whitespace that would never match', async () => {
|
||||
await mountSection()
|
||||
await mountDeepSeekCard()
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
const ids = screen.getAllByLabelText<HTMLInputElement>(new RegExp(en.modelId))
|
||||
fireEvent.change(ids[0] as HTMLInputElement, { target: { value: ' deepseek-v4-flash ' } })
|
||||
@@ -681,7 +726,7 @@ describe('ModelsSection', () => {
|
||||
})
|
||||
|
||||
it('can empty and reset the model override, then clear optional fields without dropping hidden data', async () => {
|
||||
const { mutate } = await mountSection({
|
||||
const { mutate } = await mountDeepSeekCard({
|
||||
mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
|
||||
})
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
@@ -715,7 +760,7 @@ describe('ModelsSection', () => {
|
||||
|
||||
it('clears an inherited override with an unset op, never a whole-section replace', async () => {
|
||||
// A whole-section replace would clobber sibling overrides to clear one field.
|
||||
const { replace, update, mutate } = await mountSection()
|
||||
const { replace, update, mutate } = await mountDeepSeekCard()
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
const url = screen.getByLabelText<HTMLInputElement>(en.baseUrl)
|
||||
expect(url.value).toBe('https://base')
|
||||
@@ -762,7 +807,7 @@ describe('ModelsSection', () => {
|
||||
})
|
||||
|
||||
it('rejects an invalid draft before writing', async () => {
|
||||
const { update } = await mountSection()
|
||||
const { update } = await mountDeepSeekCard()
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'not-a-url' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
@@ -772,19 +817,17 @@ describe('ModelsSection', () => {
|
||||
|
||||
it('edits a pi-ai profile with the curated fields only', async () => {
|
||||
const { mutate } = await mountSection()
|
||||
fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement)
|
||||
fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) }))
|
||||
// The configured credential shows as the stored placeholder.
|
||||
const keys = await screen.findAllByLabelText<HTMLInputElement>(en.keyInput)
|
||||
const editorKey = keys[keys.length - 1] as HTMLInputElement
|
||||
const editorKey = await screen.findByLabelText<HTMLInputElement>(en.keyInput)
|
||||
await waitFor(() => { expect(editorKey.placeholder).toBe(en.keyStored) })
|
||||
// pi-ai carries Base URL too: the stored override shows as the value and
|
||||
// the effective profile endpoint as its placeholder source.
|
||||
fireEvent.click(screen.getAllByText(en.customized)[1] as HTMLElement)
|
||||
const urls = screen.getAllByLabelText<HTMLInputElement>(en.baseUrl)
|
||||
expect(urls).toHaveLength(2)
|
||||
expect((urls[1] as HTMLInputElement).value).toBe('https://proxy')
|
||||
fireEvent.change(urls[1] as HTMLInputElement, { target: { value: 'https://proxy/v2' } })
|
||||
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
const url = screen.getByLabelText<HTMLInputElement>(en.baseUrl)
|
||||
expect(url.value).toBe('https://proxy')
|
||||
fireEvent.change(url, { target: { value: 'https://proxy/v2' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
|
||||
// Only the edited field travels: apiKeyEnv and headers were already stored
|
||||
// with these values, so no op restates them.
|
||||
@@ -803,14 +846,12 @@ describe('ModelsSection', () => {
|
||||
expect(pick.value).toBe('anthropic')
|
||||
// A dormant profile has no endpoint anywhere: the pi-ai placeholder
|
||||
// falls back to the provider-default wording.
|
||||
fireEvent.click(screen.getAllByText(en.customized)[1] as HTMLElement)
|
||||
const urls = screen.getAllByLabelText<HTMLInputElement>(en.baseUrl)
|
||||
expect((urls[1] as HTMLInputElement).placeholder).toBe(en.baseUrlDefault)
|
||||
const keys = screen.getAllByLabelText<HTMLInputElement>(en.keyInput)
|
||||
const addKey = keys[keys.length - 1] as HTMLInputElement
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
expect(screen.getByLabelText<HTMLInputElement>(en.baseUrl).placeholder).toBe(en.baseUrlDefault)
|
||||
const addKey = screen.getByLabelText<HTMLInputElement>(en.keyInput)
|
||||
expect(addKey.placeholder).toBe(en.keyPlaceholderNative)
|
||||
fireEvent.change(addKey, { target: { value: 'sk-ant' } })
|
||||
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
|
||||
expect(mutate.mock.calls[0]?.[0]).toEqual({
|
||||
ns: 'llm-pi-ai',
|
||||
@@ -824,7 +865,7 @@ describe('ModelsSection', () => {
|
||||
const { mutate, set } = await mountSection()
|
||||
fireEvent.click(screen.getByText(en.add))
|
||||
await screen.findByLabelText(en.provider)
|
||||
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
|
||||
expect(mutate.mock.calls[0]?.[0]).toEqual({
|
||||
ns: 'llm-pi-ai',
|
||||
@@ -855,9 +896,8 @@ describe('ModelsSection', () => {
|
||||
const { face, controller } = await mountSection({ mutate, set })
|
||||
fireEvent.click(screen.getByText(en.add))
|
||||
await screen.findByLabelText(en.provider)
|
||||
const keys = screen.getAllByLabelText<HTMLInputElement>(en.keyInput)
|
||||
fireEvent.change(keys[keys.length - 1] as HTMLInputElement, { target: { value: 'sk-ant' } })
|
||||
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
|
||||
fireEvent.change(screen.getByLabelText<HTMLInputElement>(en.keyInput), { target: { value: 'sk-ant' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
await screen.findByText('credential store unavailable')
|
||||
expect(mutate).toHaveBeenCalledOnce()
|
||||
face.settings.describe.mockResolvedValue(ok({
|
||||
@@ -867,7 +907,7 @@ describe('ModelsSection', () => {
|
||||
}))
|
||||
await act(async () => { await controller.load() })
|
||||
expect(controller.store.getSnapshot().namespaces.get('llm-pi-ai')?.revision).toBe(1)
|
||||
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
await waitFor(() => { expect(set).toHaveBeenCalledTimes(2) })
|
||||
expect(mutate).toHaveBeenCalledOnce()
|
||||
expect(set).toHaveBeenLastCalledWith({ ref: 'ANTHROPIC_API_KEY', value: 'sk-ant' })
|
||||
@@ -883,10 +923,9 @@ describe('ModelsSection', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText(content => content.includes(en.advancedHint)).length).toBeGreaterThan(0)
|
||||
})
|
||||
// The hint-only card cannot apply anything.
|
||||
const applies = screen.getAllByText<HTMLButtonElement>(en.apply)
|
||||
expect((applies[applies.length - 1] as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1)
|
||||
// The hint-only card cannot apply anything, and offers no key field.
|
||||
expect(screen.getByText<HTMLButtonElement>(en.apply).disabled).toBe(true)
|
||||
expect(screen.queryAllByLabelText(en.keyInput)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('surfaces a rejected settings write and never stores the key after it', async () => {
|
||||
@@ -895,9 +934,8 @@ describe('ModelsSection', () => {
|
||||
})
|
||||
fireEvent.click(screen.getByText(en.add))
|
||||
await screen.findByLabelText(en.provider)
|
||||
const keys = screen.getAllByLabelText<HTMLInputElement>(en.keyInput)
|
||||
fireEvent.change(keys[keys.length - 1] as HTMLInputElement, { target: { value: 'sk-x' } })
|
||||
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
|
||||
fireEvent.change(screen.getByLabelText<HTMLInputElement>(en.keyInput), { target: { value: 'sk-x' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
await screen.findByText(/unknown pi-ai provider/)
|
||||
expect(set).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -930,7 +968,7 @@ describe('ModelsSection', () => {
|
||||
it('tells the user to reopen when another writer moved the namespace first', async () => {
|
||||
// The stale-draft overwrite: two tabs open the same card, the other saves,
|
||||
// and this one must be refused rather than replay its opening snapshot.
|
||||
const { set } = await mountSection({
|
||||
const { set } = await mountDeepSeekCard({
|
||||
mutate: vi.fn(() => Promise.resolve(fail('changed since it was read', 'settings-conflict'))),
|
||||
})
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
@@ -944,7 +982,7 @@ describe('ModelsSection', () => {
|
||||
// A transport failure (disconnect, or the 403 a non-loopback browser now
|
||||
// gets on the whole configuration plane) rejects rather than returning a
|
||||
// failed envelope: without a catch the card would stay busy forever.
|
||||
await mountSection({ mutate: vi.fn(() => Promise.reject(new Error('connection lost'))) })
|
||||
await mountDeepSeekCard({ mutate: vi.fn(() => Promise.reject(new Error('connection lost'))) })
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
fireEvent.change(screen.getByLabelText<HTMLInputElement>(en.baseUrl), { target: { value: 'https://next' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
@@ -954,7 +992,7 @@ describe('ModelsSection', () => {
|
||||
})
|
||||
|
||||
it('surfaces a shadowed credential write on the card', async () => {
|
||||
await mountSection({
|
||||
await mountFirstRun({
|
||||
set: vi.fn(() => Promise.resolve(fail('credentials: DEEPSEEK_API_KEY is shadowed by the read-only environment', 'credential-rejected'))),
|
||||
})
|
||||
const key = screen.getByLabelText<HTMLInputElement>(en.keyInput)
|
||||
@@ -971,9 +1009,8 @@ describe('ModelsSection', () => {
|
||||
configured: ref === 'OPENAI_API_KEY', source: 'env', writable: false,
|
||||
}])),
|
||||
})))
|
||||
fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement)
|
||||
const keys = await screen.findAllByLabelText<HTMLInputElement>(en.keyInput)
|
||||
const editorKey = keys[keys.length - 1] as HTMLInputElement
|
||||
fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) }))
|
||||
const editorKey = await screen.findByLabelText<HTMLInputElement>(en.keyInput)
|
||||
await waitFor(() => { expect(editorKey.placeholder).toBe(en.keyEnvLocked) })
|
||||
expect(editorKey.disabled).toBe(true)
|
||||
})
|
||||
@@ -981,12 +1018,11 @@ describe('ModelsSection', () => {
|
||||
it('keeps a failed credential describe silent and the input usable', async () => {
|
||||
const { face, set } = await mountSection()
|
||||
face.credentials.describe.mockImplementation(() => Promise.resolve(fail('down', 'internal')) as never)
|
||||
fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement)
|
||||
const keys = await screen.findAllByLabelText<HTMLInputElement>(en.keyInput)
|
||||
const editorKey = keys[keys.length - 1] as HTMLInputElement
|
||||
fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) }))
|
||||
const editorKey = await screen.findByLabelText<HTMLInputElement>(en.keyInput)
|
||||
expect(editorKey.placeholder).toBe(en.keyPlaceholderNative)
|
||||
fireEvent.change(editorKey, { target: { value: 'sk-live' } })
|
||||
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
await waitFor(() => { expect(set).toHaveBeenCalledTimes(1) })
|
||||
})
|
||||
|
||||
@@ -1085,15 +1121,15 @@ describe('ModelsSection', () => {
|
||||
|
||||
it('toggles the row editor closed on a second edit click and on cancel', async () => {
|
||||
const { update } = await mountSection()
|
||||
const edit = screen.getAllByText(en.edit)[0] as HTMLElement
|
||||
const edit = screen.getByRole('button', { name: openaiCopy(en.editProvider) })
|
||||
fireEvent.click(edit)
|
||||
await waitFor(() => { expect(screen.getAllByLabelText(en.keyInput).length).toBe(2) })
|
||||
await waitFor(() => { expect(screen.queryAllByLabelText(en.keyInput).length).toBe(1) })
|
||||
fireEvent.click(edit)
|
||||
expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1)
|
||||
expect(screen.queryAllByLabelText(en.keyInput)).toHaveLength(0)
|
||||
fireEvent.click(edit)
|
||||
await waitFor(() => { expect(screen.getAllByLabelText(en.keyInput).length).toBe(2) })
|
||||
fireEvent.click(screen.getAllByText(en.cancel)[1] as HTMLElement)
|
||||
expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1)
|
||||
await waitFor(() => { expect(screen.queryAllByLabelText(en.keyInput).length).toBe(1) })
|
||||
fireEvent.click(screen.getByText(en.cancel))
|
||||
expect(screen.queryAllByLabelText(en.keyInput)).toHaveLength(0)
|
||||
expect(update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -1101,11 +1137,34 @@ describe('ModelsSection', () => {
|
||||
await mountSection()
|
||||
fireEvent.click(screen.getByText(en.add))
|
||||
await screen.findByLabelText(en.provider)
|
||||
fireEvent.click(screen.getAllByText(en.cancel)[1] as HTMLElement)
|
||||
fireEvent.click(screen.getByText(en.cancel))
|
||||
await screen.findByText(en.add)
|
||||
expect(screen.queryByLabelText(en.provider)).toBeNull()
|
||||
})
|
||||
|
||||
it('collapses the setup card on cancel without disturbing another open card', async () => {
|
||||
// The regression: the setup card shared the row/add/declare close handler,
|
||||
// so cancelling it discarded the add card's draft while staying open itself.
|
||||
await mountFirstRun()
|
||||
expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1)
|
||||
fireEvent.click(screen.getByText(en.add))
|
||||
await screen.findByLabelText(en.provider)
|
||||
expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(2)
|
||||
|
||||
// The setup card is the first one on the page, above the add block.
|
||||
fireEvent.click(screen.getAllByText(en.cancel)[0] as HTMLElement)
|
||||
// The add card kept its draft…
|
||||
expect(screen.getByLabelText(en.provider)).toBeTruthy()
|
||||
// …and DeepSeek collapsed to an ordinary row carrying the missing-key dot.
|
||||
expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1)
|
||||
expect(screen.getAllByRole('img', { name: en.credentialMissing })
|
||||
.some(dot => dot.closest('li')?.textContent?.includes('DeepSeek') === true)).toBe(true)
|
||||
// Its card reopens through Edit, which closes the add card as any row does.
|
||||
fireEvent.click(screen.getByRole('button', { name: deepSeekCopy(en.editProvider) }))
|
||||
expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1)
|
||||
expect(screen.queryByLabelText(en.provider)).toBeNull()
|
||||
})
|
||||
|
||||
it('loads on first render of an idle controller', async () => {
|
||||
const { face } = scriptedFace()
|
||||
const controller = new ModelsSettingsStore(face as unknown as WireFace)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/** Pure official-DeepSeek readiness projection over the shared Models join. */
|
||||
/** Pure first-run readiness projection over the shared Models join. */
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { CredentialView } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { ModelsSettingsState, ProviderRow } from '../src/client/store.ts'
|
||||
import { deepSeekReadiness } from '../src/client/store.ts'
|
||||
import { onboardingReadiness, providerUsable } from '../src/client/store.ts'
|
||||
|
||||
const missingCredential: CredentialView = { configured: false, writable: true }
|
||||
|
||||
@@ -23,6 +23,24 @@ function row(overrides: Partial<ProviderRow> = {}): ProviderRow {
|
||||
}
|
||||
}
|
||||
|
||||
/** A second provider the user configured themselves. */
|
||||
function otherRow(overrides: Partial<ProviderRow> = {}): ProviderRow {
|
||||
return {
|
||||
entry: {
|
||||
provider: 'hfai',
|
||||
displayName: 'HFAI',
|
||||
settingsNs: 'llm-pi-ai',
|
||||
settingsPath: ['providers', 'hfai'],
|
||||
active: true,
|
||||
},
|
||||
configured: true,
|
||||
removable: true,
|
||||
apiKeyEnv: 'HFAI_API_KEY',
|
||||
credential: { configured: true, source: 'file', writable: true },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function state(overrides: Partial<ModelsSettingsState> = {}): ModelsSettingsState {
|
||||
return {
|
||||
status: 'ready',
|
||||
@@ -35,12 +53,25 @@ function state(overrides: Partial<ModelsSettingsState> = {}): ModelsSettingsStat
|
||||
}
|
||||
}
|
||||
|
||||
describe('deepSeekReadiness', () => {
|
||||
describe('providerUsable', () => {
|
||||
it('requires a registered route and a stored key for every named reference', () => {
|
||||
expect(providerUsable(otherRow())).toBe(true)
|
||||
expect(providerUsable(otherRow({ entry: { ...otherRow().entry, active: false } }))).toBe(false)
|
||||
expect(providerUsable(otherRow({ credential: missingCredential }))).toBe(false)
|
||||
expect(providerUsable(otherRow({ credential: undefined }))).toBe(false)
|
||||
})
|
||||
|
||||
it('treats a reference-free registered route as provider-native authentication', () => {
|
||||
expect(providerUsable(otherRow({ apiKeyEnv: undefined, credential: undefined }))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('onboardingReadiness', () => {
|
||||
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' })
|
||||
expect(deepSeekReadiness(state({
|
||||
expect(onboardingReadiness(state({ status: 'idle', rows: [] }))).toEqual({ kind: 'loading' })
|
||||
expect(onboardingReadiness(state({ status: 'loading', rows: [] }))).toEqual({ kind: 'loading' })
|
||||
expect(onboardingReadiness(state({ rows: [] }))).toEqual({ kind: 'adapter-absent' })
|
||||
expect(onboardingReadiness(state({
|
||||
rows: [row({
|
||||
entry: {
|
||||
...row().entry,
|
||||
@@ -51,45 +82,47 @@ describe('deepSeekReadiness', () => {
|
||||
})
|
||||
|
||||
it('reports a missing writable effective credential', () => {
|
||||
expect(deepSeekReadiness(state())).toEqual({ kind: 'credential-missing' })
|
||||
expect(onboardingReadiness(state())).toEqual({ kind: 'credential-missing' })
|
||||
})
|
||||
|
||||
it('ends onboarding once any other registered provider can serve requests', () => {
|
||||
expect(onboardingReadiness(state({ rows: [row(), otherRow()] }))).toEqual({ kind: 'provider-ready' })
|
||||
// A provider the user cannot reach yet leaves the prompt in place.
|
||||
expect(onboardingReadiness(state({
|
||||
rows: [row(), otherRow({ credential: missingCredential })],
|
||||
}))).toEqual({ kind: 'credential-missing' })
|
||||
})
|
||||
|
||||
it('accepts file and process-environment credentials without prompting', () => {
|
||||
expect(deepSeekReadiness(state({
|
||||
expect(onboardingReadiness(state({
|
||||
rows: [row({ credential: { configured: true, source: 'file', writable: true } })],
|
||||
}))).toEqual({ kind: 'configured' })
|
||||
expect(deepSeekReadiness(state({
|
||||
}))).toEqual({ kind: 'provider-ready' })
|
||||
expect(onboardingReadiness(state({
|
||||
rows: [row({ credential: { configured: true, source: 'env', writable: false } })],
|
||||
}))).toEqual({ kind: 'configured' })
|
||||
}))).toEqual({ kind: 'provider-ready' })
|
||||
})
|
||||
|
||||
it('turns missing capabilities and inconsistent descriptors into diagnostics', () => {
|
||||
expect(deepSeekReadiness(state({ status: 'error', error: 'settings down' }))).toEqual({
|
||||
it('turns missing capabilities into diagnostics that never block the product', () => {
|
||||
expect(onboardingReadiness(state({ status: 'error', error: 'settings down' }))).toEqual({
|
||||
kind: 'unavailable',
|
||||
reason: 'load-failed',
|
||||
})
|
||||
expect(deepSeekReadiness(state({
|
||||
expect(onboardingReadiness(state({
|
||||
rows: [row({ entry: { ...row().entry, active: false } })],
|
||||
}))).toEqual({ kind: 'unavailable', reason: 'provider-inactive' })
|
||||
expect(deepSeekReadiness(state({
|
||||
rows: [row({ configured: false })],
|
||||
}))).toEqual({ kind: 'unavailable', reason: 'settings-unavailable' })
|
||||
expect(deepSeekReadiness(state({
|
||||
rows: [row({ apiKeyEnv: undefined })],
|
||||
}))).toEqual({ kind: 'unavailable', reason: 'credential-ref-unavailable' })
|
||||
expect(deepSeekReadiness(state({
|
||||
expect(onboardingReadiness(state({
|
||||
credentialError: 'credentials service is absent',
|
||||
}))).toEqual({
|
||||
kind: 'unavailable',
|
||||
reason: 'credentials-unavailable',
|
||||
})
|
||||
expect(deepSeekReadiness(state({
|
||||
expect(onboardingReadiness(state({
|
||||
rows: [row({ credential: undefined })],
|
||||
}))).toEqual({ kind: 'unavailable', reason: 'credentials-unavailable' })
|
||||
expect(deepSeekReadiness(state({
|
||||
expect(onboardingReadiness(state({
|
||||
rows: [row({ credential: { configured: false, writable: false } })],
|
||||
}))).toEqual({ kind: 'unavailable', reason: 'credential-read-only' })
|
||||
expect(deepSeekReadiness(state({ writable: false }))).toEqual({
|
||||
expect(onboardingReadiness(state({ writable: false }))).toEqual({
|
||||
kind: 'unavailable',
|
||||
reason: 'settings-read-only',
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user