# Conflicts: # .agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml # .agents/notes/implemented/architecture/2026-07-30-web-config-plane.md # .agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md # packages/client/ui-settings/README.i18n.yaml # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/README.zh.md
1083 lines
49 KiB
TypeScript
1083 lines
49 KiB
TypeScript
// @vitest-environment jsdom
|
|
/** Section, setup-card, and hand-written editor behavior over a scripted wire face. */
|
|
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
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, needsSetup, removeProviderProfile } from '../src/client/ModelsSection.tsx'
|
|
import type { ModelsSectionInjected, ModelsSectionProps } from '../src/client/ModelsSection.tsx'
|
|
import { pathOps } from '../src/client/ProviderEditor.tsx'
|
|
import {
|
|
DeepSeekModelsEditor, formatCapacity, modelDrafts, parseCapacity, validateDeepSeekModels,
|
|
} from '../src/client/DeepSeekModelsEditor.tsx'
|
|
import { deriveKeyRef, ModelsSettingsStore } from '../src/client/store.ts'
|
|
import type { ProviderRow } from '../src/client/store.ts'
|
|
import { en } from '../src/client/locales.ts'
|
|
|
|
afterEach(cleanup)
|
|
|
|
const t: ModelsSectionInjected['t'] = key => en[key]
|
|
|
|
/** Open one row's capacity disclosure (1-based, as the labels read). */
|
|
function expandRow(position: number): void {
|
|
fireEvent.click(screen.getByLabelText(`${en.modelAdvanced} ${String(position)}`))
|
|
}
|
|
|
|
/** The capacity inputs of every open row, in row order. */
|
|
function capacityInputs(label: string): HTMLInputElement[] {
|
|
return screen.getAllByLabelText<HTMLInputElement>(new RegExp(label))
|
|
}
|
|
|
|
const PiAiConfig = Schema.object({
|
|
token: Schema.string().role('secret'),
|
|
providers: Schema.dict(Schema.object({
|
|
apiKey: Schema.string().role('secret'),
|
|
apiKeyEnv: Schema.string().role('credential-ref'),
|
|
baseURL: Schema.string(),
|
|
reasoning: Schema.union(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']),
|
|
headers: Schema.dict(Schema.string()),
|
|
})),
|
|
})
|
|
|
|
const DeepSeekConfig = Schema.object({
|
|
apiKey: Schema.string().role('secret'),
|
|
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),
|
|
// The adapter declares its catalog as a schema default rather than a
|
|
// composition entry, which is what the restore-defaults path has to read.
|
|
})).default([
|
|
{
|
|
id: 'deepseek-v4-flash',
|
|
name: 'DeepSeek-V4-Flash',
|
|
description: '',
|
|
contextWindow: 1_000_000,
|
|
},
|
|
{
|
|
id: 'deepseek-v4-pro',
|
|
name: 'DeepSeek-V4-Pro',
|
|
description: '',
|
|
contextWindow: 1_000_000,
|
|
},
|
|
]),
|
|
})
|
|
|
|
const DEFAULT_DEEPSEEK_MODELS = [
|
|
{
|
|
id: 'deepseek-v4-flash',
|
|
name: 'DeepSeek-V4-Flash',
|
|
description: 'Preserved hidden detail',
|
|
contextWindow: 1_000_000,
|
|
},
|
|
{ id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', contextWindow: 1_000_000 },
|
|
]
|
|
|
|
function wireNamespaces(): SettingsNamespaceView[] {
|
|
return [
|
|
{
|
|
ns: 'llm-deepseek',
|
|
schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as unknown,
|
|
value: {
|
|
apiKeyEnv: 'DEEPSEEK_API_KEY',
|
|
baseURL: 'https://base',
|
|
reasoningEffort: 'high',
|
|
defaultContextWindow: 1_000_000,
|
|
maxTokens: 256_000,
|
|
models: DEFAULT_DEEPSEEK_MODELS,
|
|
},
|
|
base: { defaultContextWindow: 1_000_000, maxTokens: 256_000, models: DEFAULT_DEEPSEEK_MODELS },
|
|
user: { reasoningEffort: 'high' },
|
|
applies: 'live',
|
|
secrets: [{ path: ['apiKey'], set: false }],
|
|
revision: 0,
|
|
},
|
|
{
|
|
ns: 'llm-plain',
|
|
schema: JSON.parse(JSON.stringify(Schema.object({
|
|
profiles: Schema.dict(Schema.object({ note: Schema.string() })),
|
|
}).toJSON())) as unknown,
|
|
value: {},
|
|
applies: 'live',
|
|
secrets: [],
|
|
revision: 0,
|
|
},
|
|
{
|
|
ns: 'llm-pi-ai',
|
|
schema: JSON.parse(JSON.stringify(PiAiConfig.toJSON())) as unknown,
|
|
value: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://proxy', headers: { 'X-Team': 'a' } }, zombie: {} } },
|
|
user: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://proxy', headers: { 'X-Team': 'a' } }, zombie: {} } },
|
|
applies: 'live',
|
|
secrets: [{ path: ['token'], set: false }, { path: ['providers', 'openai', 'apiKey'], set: false }],
|
|
revision: 0,
|
|
},
|
|
]
|
|
}
|
|
|
|
let nextRpc = 0
|
|
function ok<T>(value: T): RpcResponse<T> {
|
|
return { rpcId: `r-${nextRpc++}` as never, result: { ok: true, value } }
|
|
}
|
|
function fail<T>(message: string, code = 'settings-rejected'): RpcResponse<T> {
|
|
return {
|
|
rpcId: `r-${nextRpc++}` as never,
|
|
result: { ok: false, error: { code, message, details: { ns: 'x' } } as never },
|
|
}
|
|
}
|
|
|
|
function scriptedFace(overrides: {
|
|
update?: ReturnType<typeof vi.fn>
|
|
replace?: ReturnType<typeof vi.fn>
|
|
mutate?: ReturnType<typeof vi.fn>
|
|
set?: ReturnType<typeof vi.fn>
|
|
} = {}) {
|
|
const update = overrides.update ?? vi.fn(() => Promise.resolve(ok(wireNamespaces()[2])))
|
|
const replace = overrides.replace ?? vi.fn(() => Promise.resolve(ok(wireNamespaces()[2])))
|
|
const mutate = overrides.mutate ?? vi.fn(() => Promise.resolve(ok(wireNamespaces()[2])))
|
|
const set = overrides.set ?? vi.fn(() => Promise.resolve(ok({})))
|
|
const face = {
|
|
llm: {
|
|
providers: vi.fn(() => Promise.resolve(ok({
|
|
providers: [
|
|
{ provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true },
|
|
{ provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: true },
|
|
{ provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false },
|
|
{ provider: 'zombie', displayName: 'zombie', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'zombie'], active: false },
|
|
{ provider: 'broken', displayName: 'broken', settingsNs: 'llm-pi-ai', settingsPath: ['nope', 'x'], active: false },
|
|
{ provider: 'plain', displayName: 'plain', settingsNs: 'llm-plain', settingsPath: ['profiles', 'plain'], active: false },
|
|
],
|
|
}))),
|
|
models: vi.fn(() => Promise.resolve(ok({ groups: [], failures: [] }))),
|
|
},
|
|
settings: {
|
|
describe: vi.fn(() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: wireNamespaces() }))),
|
|
update,
|
|
replace,
|
|
mutate,
|
|
},
|
|
credentials: {
|
|
describe: vi.fn((payload: { refs: string[] }) => Promise.resolve(ok({
|
|
credentials: Object.fromEntries(payload.refs.map(ref => [ref, {
|
|
configured: ref === 'OPENAI_API_KEY',
|
|
...ref === 'OPENAI_API_KEY' ? { source: 'file' } : {},
|
|
writable: true,
|
|
}])),
|
|
}))),
|
|
set,
|
|
unset: vi.fn(() => Promise.resolve(ok({}))),
|
|
},
|
|
}
|
|
return { face, update, replace, mutate, set }
|
|
}
|
|
|
|
type WireFace = ConstructorParameters<typeof ModelsSettingsStore>[0]
|
|
|
|
async function mountSection(overrides: Parameters<typeof scriptedFace>[0] = {}) {
|
|
const { face, update, replace, mutate, set } = scriptedFace(overrides)
|
|
const controller = new ModelsSettingsStore(face as unknown as WireFace)
|
|
await controller.load()
|
|
const injected: ModelsSectionInjected = {
|
|
controller,
|
|
useSnapshot: bindSnapshotSelector(controller.store),
|
|
api: face as never,
|
|
t,
|
|
}
|
|
const view = render(<ModelsSection {...injected} />)
|
|
return { view, face, update, replace, mutate, set, controller }
|
|
}
|
|
|
|
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 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.
|
|
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('turns the setup card into a row once the credential reports configured', async () => {
|
|
const { face } = await mountSection()
|
|
face.credentials.describe.mockImplementation((payload: { refs: string[] }) => Promise.resolve(ok({
|
|
credentials: Object.fromEntries(payload.refs.map(ref => [ref, { configured: true, writable: true }])),
|
|
})))
|
|
const controller = new ModelsSettingsStore(face as unknown as WireFace)
|
|
await controller.load()
|
|
cleanup()
|
|
render(<ModelsSection
|
|
controller={controller}
|
|
useSnapshot={bindSnapshotSelector(controller.store)}
|
|
api={face as never}
|
|
t={t}
|
|
/>)
|
|
// Now a row with an Edit button, not an open card.
|
|
expect(screen.getAllByText(en.edit).length).toBeGreaterThan(1)
|
|
expect(screen.queryByLabelText(en.keyInput)).toBeNull()
|
|
})
|
|
|
|
it('decides setup need from the joined credential state and literal-key sidecar', () => {
|
|
const entry = { provider: 'p', displayName: 'p', settingsNs: 'llm-deepseek', settingsPath: [], active: true }
|
|
const row = (
|
|
credential: ProviderRow['credential'],
|
|
literalApiKeyConfigured = false,
|
|
): ProviderRow => ({
|
|
entry,
|
|
configured: true,
|
|
removable: false,
|
|
apiKeyEnv: 'X',
|
|
credential,
|
|
literalApiKeyConfigured,
|
|
})
|
|
expect(needsSetup(row(undefined))).toBe(true)
|
|
expect(needsSetup(row({ configured: true, writable: true }))).toBe(false)
|
|
expect(needsSetup(row(undefined, true))).toBe(false)
|
|
const nested = { ...row(undefined), entry: { ...entry, settingsPath: ['providers', 'x'] } }
|
|
expect(needsSetup(nested)).toBe(false)
|
|
})
|
|
|
|
it('derives conventional credential references from route ids', () => {
|
|
expect(deriveKeyRef('anthropic')).toBe('ANTHROPIC_API_KEY')
|
|
expect(deriveKeyRef('minimax-cn')).toBe('MINIMAX_CN_API_KEY')
|
|
})
|
|
|
|
it('names only the fields the card can see, so an unseen secret survives', () => {
|
|
// `before` is the REDACTED subtree: a stored literal apiKey is in neither
|
|
// side, so no op mentions it and the seam leaves it alone.
|
|
expect(pathOps(['providers', 'openai'], { baseURL: 'https://old', reasoning: 'high' }, { reasoning: 'high' }))
|
|
.toEqual([{ op: 'unset', path: ['providers', 'openai', 'baseURL'] }])
|
|
expect(pathOps([], { b: 1 }, { b: 2, d: 3 }))
|
|
.toEqual([{ op: 'set', path: ['b'], value: 2 }, { op: 'set', path: ['d'], value: 3 }])
|
|
expect(pathOps([], undefined, {})).toEqual([])
|
|
expect(pathOps([], { a: 1 }, { a: 1 })).toEqual([])
|
|
})
|
|
|
|
it('stores a typed key write-only from the setup card without touching settings', async () => {
|
|
const { set, update, face } = await mountSection()
|
|
const key = screen.getByLabelText<HTMLInputElement>(en.keyInput)
|
|
fireEvent.change(key, { target: { value: 'sk-live' } })
|
|
fireEvent.click(screen.getByText(en.apply))
|
|
await waitFor(() => { expect(set).toHaveBeenCalledWith({ ref: 'DEEPSEEK_API_KEY', value: 'sk-live' }) })
|
|
expect(update).not.toHaveBeenCalled()
|
|
await waitFor(() => { expect(face.settings.describe.mock.calls.length).toBeGreaterThan(1) })
|
|
})
|
|
|
|
it('applies customized deepseek fields as path ops', async () => {
|
|
const { mutate } = await mountSection({
|
|
mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
|
|
})
|
|
fireEvent.click(screen.getByText(en.customized))
|
|
const baseURL = screen.getByLabelText<HTMLInputElement>(en.baseUrl)
|
|
// The deepseek placeholder is pinned to the public endpoint, not the
|
|
// effective value (which may reflect a launch-environment override).
|
|
expect(baseURL.placeholder).toBe('https://api.deepseek.com')
|
|
fireEvent.change(baseURL, { target: { value: 'https://next2' } })
|
|
fireEvent.click(screen.getByText(en.apply))
|
|
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
|
|
// Only the field that actually changed: reasoningEffort was already
|
|
// 'high' in the loaded profile, so it produces no op.
|
|
expect(mutate.mock.calls[0]?.[0]).toEqual({
|
|
ns: 'llm-deepseek',
|
|
ops: [{ op: 'set', path: ['baseURL'], value: 'https://next2' }],
|
|
expectedRevision: 0,
|
|
})
|
|
})
|
|
|
|
it('materializes inherited models and adds an arbitrary DeepSeek id', async () => {
|
|
const { mutate } = await mountSection({
|
|
mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
|
|
})
|
|
fireEvent.click(screen.getByText(en.customized))
|
|
expect(screen.getByText(en.modelsInherited)).toBeTruthy()
|
|
expect(screen.getAllByLabelText(new RegExp(en.modelId)).map(input => (input as HTMLInputElement).value))
|
|
.toEqual(['deepseek-v4-flash', 'deepseek-v4-pro'])
|
|
|
|
fireEvent.click(screen.getByText(en.addModel))
|
|
const ids = screen.getAllByLabelText(new RegExp(en.modelId))
|
|
const names = screen.getAllByLabelText(new RegExp(en.modelName))
|
|
expandRow(3)
|
|
fireEvent.change(ids[2] as HTMLInputElement, { target: { value: 'private-preview' } })
|
|
fireEvent.change(names[2] as HTMLInputElement, { target: { value: 'Private Preview' } })
|
|
// Only row 3 is open, so its capacity is addressed by its own label.
|
|
fireEvent.change(screen.getByLabelText(`${en.contextWindow} 3`), { target: { value: '131072' } })
|
|
fireEvent.click(screen.getByText(en.apply))
|
|
|
|
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
|
|
expect(mutate.mock.calls[0]?.[0]).toEqual({
|
|
ns: 'llm-deepseek',
|
|
ops: [{
|
|
op: 'set',
|
|
path: ['models'],
|
|
value: [
|
|
...DEFAULT_DEEPSEEK_MODELS,
|
|
{ id: 'private-preview', name: 'Private Preview', contextWindow: 131_072 },
|
|
],
|
|
}],
|
|
expectedRevision: 0,
|
|
})
|
|
})
|
|
|
|
it('rejects duplicate DeepSeek model ids before writing', async () => {
|
|
const { mutate } = await mountSection()
|
|
fireEvent.click(screen.getByText(en.customized))
|
|
fireEvent.click(screen.getByText(en.addModel))
|
|
const ids = screen.getAllByLabelText(new RegExp(en.modelId))
|
|
fireEvent.change(ids[2] as HTMLInputElement, { target: { value: 'deepseek-v4-flash' } })
|
|
fireEvent.click(screen.getByText(en.apply))
|
|
|
|
await screen.findByText(`Model 3: ${en.modelIdDuplicate}`)
|
|
expect(mutate).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('validates every adapter-owned model catalog invariant', () => {
|
|
expect(modelDrafts(undefined)).toEqual([])
|
|
expect(modelDrafts([null, 'bad', { id: 'ok' }])).toEqual([{}, {}, { id: 'ok' }])
|
|
expect(validateDeepSeekModels([{}])).toEqual({ index: 0, key: 'modelIdRequired' })
|
|
expect(validateDeepSeekModels([{ id: 'same' }, { id: 'same' }]))
|
|
.toEqual({ index: 1, key: 'modelIdDuplicate' })
|
|
expect(validateDeepSeekModels([{ id: 'model', name: '' }]))
|
|
.toEqual({ index: 0, key: 'modelNameInvalid' })
|
|
expect(validateDeepSeekModels([{ id: 'model', contextWindow: null }]))
|
|
.toEqual({ index: 0, key: 'modelContextInvalid' })
|
|
expect(validateDeepSeekModels([{ id: 'model', contextWindow: 1.5 }]))
|
|
.toEqual({ index: 0, key: 'modelContextInvalid' })
|
|
expect(validateDeepSeekModels([{ id: 'model', contextWindow: 0 }]))
|
|
.toEqual({ index: 0, key: 'modelContextInvalid' })
|
|
expect(validateDeepSeekModels([{ id: 'model', contextWindow: 1 }])).toBeUndefined()
|
|
expect(validateDeepSeekModels([{ id: 'model', maxTokens: null }]))
|
|
.toEqual({ index: 0, key: 'modelMaxTokensInvalid' })
|
|
expect(validateDeepSeekModels([{ id: 'model', maxTokens: 1.5 }]))
|
|
.toEqual({ index: 0, key: 'modelMaxTokensInvalid' })
|
|
expect(validateDeepSeekModels([{ id: 'model', maxTokens: 0 }]))
|
|
.toEqual({ index: 0, key: 'modelMaxTokensInvalid' })
|
|
expect(validateDeepSeekModels([{ id: 'model', maxTokens: 8192 }])).toBeUndefined()
|
|
})
|
|
|
|
it('reads context windows written as counts, thousands, or millions', () => {
|
|
expect(parseCapacity('')).toBeUndefined()
|
|
expect(parseCapacity(' ')).toBeUndefined()
|
|
expect(parseCapacity('131072')).toBe(131_072)
|
|
expect(parseCapacity(' 256K ')).toBe(256_000)
|
|
expect(parseCapacity('256k')).toBe(256_000)
|
|
expect(parseCapacity('1M')).toBe(1_000_000)
|
|
expect(parseCapacity('1m')).toBe(1_000_000)
|
|
// 1M is 1000K, not 1024K: capacities are quoted in decimal.
|
|
expect(parseCapacity('1M')).toBe(parseCapacity('1000K'))
|
|
// 2.3 * 1e6 is a few ULPs high in binary floating point; an integral
|
|
// intent must not become a fractional count the validator rejects.
|
|
expect(parseCapacity('2.3M')).toBe(2_300_000)
|
|
expect(Number.isInteger(parseCapacity('1.5M'))).toBe(true)
|
|
// A genuinely fractional count survives as one, for the validator to reject.
|
|
expect(parseCapacity('0.0001K')).toBeCloseTo(0.1)
|
|
expect(parseCapacity('abc')).toBeNaN()
|
|
expect(parseCapacity('1G')).toBeNaN()
|
|
expect(parseCapacity('1M1')).toBeNaN()
|
|
})
|
|
|
|
it('spells a stored count in the shortest form that round-trips', () => {
|
|
expect(formatCapacity(1_000_000)).toBe('1M')
|
|
expect(formatCapacity(256_000)).toBe('256K')
|
|
expect(formatCapacity(1_500_000)).toBe('1500K')
|
|
expect(formatCapacity(131_072)).toBe('131072')
|
|
// Values the validator will reject are shown as-is rather than dressed up.
|
|
expect(formatCapacity(Number.NaN)).toBe('NaN')
|
|
expect(formatCapacity(0)).toBe('0')
|
|
for (const text of ['1M', '256K', '131072', '1500K']) {
|
|
expect(formatCapacity(parseCapacity(text) as number)).toBe(text)
|
|
}
|
|
})
|
|
|
|
it('accepts a suffixed context window and stores the plain count', async () => {
|
|
const { mutate } = await mountSection({
|
|
mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
|
|
})
|
|
fireEvent.click(screen.getByText(en.customized))
|
|
expandRow(1)
|
|
expandRow(2)
|
|
const windows = capacityInputs(en.contextWindow)
|
|
// The inherited 1000000 reads back short.
|
|
expect((windows[0] as HTMLInputElement).value).toBe('1M')
|
|
|
|
// Keystrokes stay verbatim while the row has focus, so typing `1000` does
|
|
// not rewrite itself to `1K` mid-word.
|
|
fireEvent.change(windows[0] as HTMLInputElement, { target: { value: '1000' } })
|
|
expect((windows[0] as HTMLInputElement).value).toBe('1000')
|
|
fireEvent.change(windows[0] as HTMLInputElement, { target: { value: '1000K' } })
|
|
expect((windows[0] as HTMLInputElement).value).toBe('1000K')
|
|
// Blur settles the row to the canonical spelling of the same count.
|
|
fireEvent.blur(windows[0] as HTMLInputElement)
|
|
expect((windows[0] as HTMLInputElement).value).toBe('1M')
|
|
|
|
fireEvent.change(windows[1] as HTMLInputElement, { target: { value: '256K' } })
|
|
fireEvent.blur(windows[1] as HTMLInputElement)
|
|
fireEvent.click(screen.getByText(en.apply))
|
|
|
|
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
|
|
expect(mutate.mock.calls[0]?.[0]).toEqual({
|
|
ns: 'llm-deepseek',
|
|
ops: [{
|
|
op: 'set',
|
|
path: ['models'],
|
|
value: [
|
|
{ ...DEFAULT_DEEPSEEK_MODELS[0], contextWindow: 1_000_000 },
|
|
{ ...DEFAULT_DEEPSEEK_MODELS[1], contextWindow: 256_000 },
|
|
],
|
|
}],
|
|
expectedRevision: 0,
|
|
})
|
|
})
|
|
|
|
it('keeps unreadable context-window text on screen and refuses the write', async () => {
|
|
const { mutate } = await mountSection()
|
|
fireEvent.click(screen.getByText(en.customized))
|
|
expandRow(1)
|
|
expandRow(2)
|
|
const windows = capacityInputs(en.contextWindow)
|
|
fireEvent.change(windows[0] as HTMLInputElement, { target: { value: '1 gazillion' } })
|
|
// Blurring a row that is not the edited one leaves the buffer alone.
|
|
fireEvent.blur(windows[1] as HTMLInputElement)
|
|
fireEvent.blur(windows[0] as HTMLInputElement)
|
|
// The text the user typed is still there to correct.
|
|
expect((windows[0] as HTMLInputElement).value).toBe('1 gazillion')
|
|
|
|
fireEvent.click(screen.getByText(en.apply))
|
|
await screen.findByText(`Model 1: ${en.modelContextInvalid}`)
|
|
expect(mutate).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it.each([
|
|
['the schema default', undefined],
|
|
['the composition entry', { models: [{ id: 'pinned-by-deployment' }] }],
|
|
])('restores %s the moment the override is dropped, not after a reload', async (_label, base) => {
|
|
// The regression: reset read the EFFECTIVE value, which still carries the
|
|
// stored override until the unset is applied — so the rows did not change
|
|
// and the catalog only looked restored after reopening the card.
|
|
const { face } = scriptedFace()
|
|
const stored = { models: [{ id: 'user-only-model', name: 'User Only' }] }
|
|
const overridden: SettingsNamespaceView = {
|
|
ns: 'llm-deepseek',
|
|
schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as unknown,
|
|
value: { ...stored, defaultContextWindow: 1_000_000 },
|
|
...base === undefined ? {} : { base },
|
|
user: stored,
|
|
applies: 'live',
|
|
secrets: [],
|
|
revision: 0,
|
|
}
|
|
const { ProviderEditor } = await import('../src/client/ProviderEditor.tsx')
|
|
render(<ProviderEditor
|
|
provider="deepseek-official"
|
|
displayName="DeepSeek"
|
|
namespace={overridden}
|
|
settingsPath={[]}
|
|
api={face as never}
|
|
t={t}
|
|
readOnly={false}
|
|
onClose={() => {}}
|
|
/>)
|
|
fireEvent.click(screen.getByText(en.customized))
|
|
expect(screen.getByText(en.modelsCustomized)).toBeTruthy()
|
|
expect(screen.getAllByLabelText(new RegExp(en.modelId)).map(input => (input as HTMLInputElement).value))
|
|
.toEqual(['user-only-model'])
|
|
|
|
fireEvent.click(screen.getByText(en.resetModels))
|
|
|
|
expect(screen.getByText(en.modelsInherited)).toBeTruthy()
|
|
expect(screen.getAllByLabelText(new RegExp(en.modelId)).map(input => (input as HTMLInputElement).value))
|
|
.toEqual(base === undefined ? ['deepseek-v4-flash', 'deepseek-v4-pro'] : ['pinned-by-deployment'])
|
|
})
|
|
|
|
it('keeps every row\'s unreadable text, not just the last one edited', async () => {
|
|
// 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()
|
|
fireEvent.click(screen.getByText(en.customized))
|
|
expandRow(1)
|
|
expandRow(2)
|
|
const windows = capacityInputs(en.contextWindow)
|
|
fireEvent.change(windows[0] as HTMLInputElement, { target: { value: 'not a number' } })
|
|
fireEvent.blur(windows[0] as HTMLInputElement)
|
|
fireEvent.change(windows[1] as HTMLInputElement, { target: { value: '2M' } })
|
|
|
|
expect((windows[0] as HTMLInputElement).value).toBe('not a number')
|
|
expect((windows[1] as HTMLInputElement).value).toBe('2M')
|
|
})
|
|
|
|
it('re-keys the typed text around a removed row', async () => {
|
|
await mountSection()
|
|
fireEvent.click(screen.getByText(en.customized))
|
|
const windows = (): HTMLInputElement[] => capacityInputs(en.contextWindow)
|
|
const removeRow = (at: number): void => {
|
|
fireEvent.click(screen.getAllByLabelText(new RegExp(en.removeModel))[at] as HTMLElement)
|
|
}
|
|
// Three rows, with text parked on the outer two.
|
|
fireEvent.click(screen.getByText(en.addModel))
|
|
expandRow(1)
|
|
expandRow(2)
|
|
expandRow(3)
|
|
fireEvent.change(windows()[0] as HTMLInputElement, { target: { value: 'top text' } })
|
|
fireEvent.blur(windows()[0] as HTMLInputElement)
|
|
fireEvent.change(windows()[2] as HTMLInputElement, { target: { value: 'bottom text' } })
|
|
fireEvent.blur(windows()[2] as HTMLInputElement)
|
|
|
|
// Dropping the middle row leaves the row above untouched and carries the
|
|
// row below down with its own text, rather than stranding it.
|
|
removeRow(1)
|
|
expect(windows()).toHaveLength(2)
|
|
expect((windows()[0] as HTMLInputElement).value).toBe('top text')
|
|
expect((windows()[1] as HTMLInputElement).value).toBe('bottom text')
|
|
|
|
// Dropping a row that holds text takes that text with it; the survivor
|
|
// keeps its own rather than inheriting the deleted row's.
|
|
removeRow(0)
|
|
expect(windows()).toHaveLength(1)
|
|
expect((windows()[0] as HTMLInputElement).value).toBe('bottom text')
|
|
})
|
|
|
|
it('drops the typed text when reset replaces the rows it annotated', async () => {
|
|
// 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({
|
|
mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
|
|
})
|
|
fireEvent.click(screen.getByText(en.customized))
|
|
expandRow(1)
|
|
const windows = capacityInputs(en.contextWindow)
|
|
fireEvent.change(windows[0] as HTMLInputElement, { target: { value: 'garbage' } })
|
|
fireEvent.blur(windows[0] as HTMLInputElement)
|
|
fireEvent.click(screen.getByText(en.resetModels))
|
|
|
|
// Reset collapses every row, so the restored capacity needs opening again.
|
|
expandRow(1)
|
|
const restored = capacityInputs(en.contextWindow)
|
|
expect((restored[0] as HTMLInputElement).value).toBe('1M')
|
|
|
|
// 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() })
|
|
expect(mutate).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('edits an output cap per model and carries its text across a removal', async () => {
|
|
const { mutate } = await mountSection({
|
|
mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
|
|
})
|
|
fireEvent.click(screen.getByText(en.customized))
|
|
expandRow(1)
|
|
expandRow(2)
|
|
// The profile's own cap is the placeholder both rows inherit.
|
|
expect(capacityInputs(en.maxTokens).map(input => input.placeholder)).toEqual(['256K', '256K'])
|
|
|
|
fireEvent.change(screen.getByLabelText(`${en.maxTokens} 2`), { target: { value: '64K' } })
|
|
fireEvent.blur(screen.getByLabelText(`${en.maxTokens} 2`))
|
|
expect(screen.getByLabelText<HTMLInputElement>(`${en.maxTokens} 2`).value).toBe('64K')
|
|
|
|
// Dropping the row above carries the cap text down with its own row.
|
|
fireEvent.click(screen.getAllByLabelText(new RegExp(en.removeModel))[0] as HTMLElement)
|
|
expect(screen.getByLabelText<HTMLInputElement>(`${en.maxTokens} 1`).value).toBe('64K')
|
|
// The disclosure closes on a second press.
|
|
expandRow(1)
|
|
expect(screen.queryByLabelText(`${en.maxTokens} 1`)).toBeNull()
|
|
|
|
fireEvent.click(screen.getByText(en.apply))
|
|
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
|
|
expect(mutate.mock.calls[0]?.[0]).toEqual({
|
|
ns: 'llm-deepseek',
|
|
ops: [{
|
|
op: 'set',
|
|
path: ['models'],
|
|
value: [{ ...DEFAULT_DEEPSEEK_MODELS[1], maxTokens: 64_000 }],
|
|
}],
|
|
expectedRevision: 0,
|
|
})
|
|
})
|
|
|
|
it('settles a pasted id and refuses whitespace that would never match', async () => {
|
|
await mountSection()
|
|
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 ' } })
|
|
fireEvent.blur(ids[0] as HTMLInputElement)
|
|
expect((ids[0] as HTMLInputElement).value).toBe('deepseek-v4-flash')
|
|
// A settled id needs no second trim.
|
|
fireEvent.blur(ids[0] as HTMLInputElement)
|
|
expect((ids[0] as HTMLInputElement).value).toBe('deepseek-v4-flash')
|
|
|
|
// An id that is only whitespace is as absent as an empty one, and a padded
|
|
// id no longer slips past the duplicate check against its own twin.
|
|
expect(validateDeepSeekModels([{ id: ' ' }])).toEqual({ index: 0, key: 'modelIdRequired' })
|
|
expect(validateDeepSeekModels([{ id: 'model' }, { id: 'model ' }]))
|
|
.toEqual({ index: 1, key: 'modelIdDuplicate' })
|
|
})
|
|
|
|
it('renders malformed draft fallbacks without inventing catalog values', () => {
|
|
render(<DeepSeekModelsEditor
|
|
models={[{}]}
|
|
overridden={false}
|
|
defaultContextWindow={undefined}
|
|
defaultMaxTokens={undefined}
|
|
t={t}
|
|
disabled={true}
|
|
onChange={vi.fn()}
|
|
onReset={vi.fn()}
|
|
/>)
|
|
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelId} 1`).value).toBe('')
|
|
expandRow(1)
|
|
expect(screen.getByLabelText<HTMLInputElement>(`${en.contextWindow} 1`).placeholder)
|
|
.toBe(en.contextWindowPlaceholder)
|
|
expect(screen.getByLabelText<HTMLInputElement>(`${en.maxTokens} 1`).placeholder)
|
|
.toBe(en.maxTokensPlaceholder)
|
|
})
|
|
|
|
it('can empty and reset the model override, then clear optional fields without dropping hidden data', async () => {
|
|
const { mutate } = await mountSection({
|
|
mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
|
|
})
|
|
fireEvent.click(screen.getByText(en.customized))
|
|
fireEvent.click(screen.getAllByLabelText(new RegExp(en.removeModel))[0] as HTMLElement)
|
|
fireEvent.click(screen.getByLabelText(new RegExp(en.removeModel)))
|
|
expect(screen.getByText(en.modelsEmpty)).toBeTruthy()
|
|
fireEvent.click(screen.getByText(en.resetModels))
|
|
expect(screen.getByText(en.modelsInherited)).toBeTruthy()
|
|
|
|
const names = screen.getAllByLabelText(new RegExp(en.modelName))
|
|
expandRow(1)
|
|
const windows = capacityInputs(en.contextWindow)
|
|
fireEvent.change(names[0] as HTMLInputElement, { target: { value: '' } })
|
|
fireEvent.change(windows[0] as HTMLInputElement, { target: { value: '' } })
|
|
fireEvent.click(screen.getByText(en.apply))
|
|
|
|
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
|
|
expect(mutate.mock.calls[0]?.[0]).toEqual({
|
|
ns: 'llm-deepseek',
|
|
ops: [{
|
|
op: 'set',
|
|
path: ['models'],
|
|
value: [
|
|
{ id: 'deepseek-v4-flash', description: 'Preserved hidden detail' },
|
|
DEFAULT_DEEPSEEK_MODELS[1],
|
|
],
|
|
}],
|
|
expectedRevision: 0,
|
|
})
|
|
})
|
|
|
|
it('clears an inherited override with an unset op, never a whole-section replace', async () => {
|
|
// The data-loss shape: the old path rebuilt the section from the REDACTED
|
|
// user layer and replaced it wholesale, deleting any stored literal key.
|
|
const { replace, update, mutate } = await mountSection()
|
|
fireEvent.click(screen.getByText(en.customized))
|
|
const effort = screen.getByLabelText<HTMLSelectElement>(en.effort)
|
|
expect(effort.value).toBe('high')
|
|
fireEvent.change(effort, { target: { value: '' } })
|
|
fireEvent.click(screen.getByText(en.apply))
|
|
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
|
|
expect(replace).not.toHaveBeenCalled()
|
|
expect(update).not.toHaveBeenCalled()
|
|
expect(mutate.mock.calls[0]?.[0]).toEqual({
|
|
ns: 'llm-deepseek',
|
|
ops: [{ op: 'unset', path: ['reasoningEffort'] }],
|
|
expectedRevision: 0,
|
|
})
|
|
})
|
|
|
|
it('pins the deepseek placeholder and clears typed input back to inherited', async () => {
|
|
const { face } = scriptedFace()
|
|
const bare: SettingsNamespaceView = {
|
|
ns: 'llm-deepseek',
|
|
schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as unknown,
|
|
value: {},
|
|
applies: 'live',
|
|
secrets: [],
|
|
revision: 0,
|
|
}
|
|
const { ProviderEditor } = await import('../src/client/ProviderEditor.tsx')
|
|
render(<ProviderEditor
|
|
provider="deepseek-official"
|
|
displayName="DeepSeek"
|
|
namespace={bare}
|
|
settingsPath={[]}
|
|
api={face as never}
|
|
t={t}
|
|
readOnly={false}
|
|
onClose={() => {}}
|
|
/>)
|
|
fireEvent.click(screen.getByText(en.customized))
|
|
const baseURL = screen.getByLabelText<HTMLInputElement>(en.baseUrl)
|
|
expect(baseURL.placeholder).toBe('https://api.deepseek.com')
|
|
fireEvent.change(baseURL, { target: { value: 'https://x' } })
|
|
expect(baseURL.value).toBe('https://x')
|
|
fireEvent.change(baseURL, { target: { value: '' } })
|
|
expect(baseURL.value).toBe('')
|
|
})
|
|
|
|
it('rejects an invalid draft before writing', async () => {
|
|
const { update } = await mountSection()
|
|
fireEvent.click(screen.getByText(en.customized))
|
|
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'not-a-url' } })
|
|
fireEvent.click(screen.getByText(en.apply))
|
|
await screen.findByText(/baseURL/)
|
|
expect(update).not.toHaveBeenCalled()
|
|
})
|
|
|
|
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)
|
|
// The configured credential shows as the stored placeholder.
|
|
const keys = await screen.findAllByLabelText<HTMLInputElement>(en.keyInput)
|
|
const editorKey = keys[keys.length - 1] as HTMLInputElement
|
|
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')
|
|
const effort = screen.getAllByLabelText<HTMLSelectElement>(en.effort)
|
|
fireEvent.change(effort[effort.length - 1] as HTMLSelectElement, { target: { value: 'xhigh' } })
|
|
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
|
|
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
|
|
// Only the edited field travels: apiKeyEnv, baseURL and headers were
|
|
// already stored with these values, so no op restates them — and the
|
|
// profile's stored literal apiKey, absent from the redacted view the card
|
|
// read, is named by nothing at all.
|
|
expect(mutate.mock.calls[0]?.[0]).toEqual({
|
|
ns: 'llm-pi-ai',
|
|
ops: [{ op: 'set', path: ['providers', 'openai', 'reasoning'], value: 'xhigh' }],
|
|
expectedRevision: 0,
|
|
})
|
|
})
|
|
|
|
it('adds a dormant provider with a derived reference and stores its key', async () => {
|
|
const { mutate, set } = await mountSection()
|
|
fireEvent.click(screen.getByText(en.add))
|
|
const pick = await screen.findByLabelText<HTMLSelectElement>(en.provider)
|
|
expect([...pick.options].map(option => option.value)).toEqual(['anthropic', 'broken', 'plain'])
|
|
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.change(addKey, { target: { value: 'sk-ant' } })
|
|
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
|
|
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
|
|
expect(mutate.mock.calls[0]?.[0]).toEqual({
|
|
ns: 'llm-pi-ai',
|
|
ops: [{ op: 'set', path: ['providers', 'anthropic', 'apiKeyEnv'], value: 'ANTHROPIC_API_KEY' }],
|
|
expectedRevision: 0,
|
|
})
|
|
await waitFor(() => { expect(set).toHaveBeenCalledWith({ ref: 'ANTHROPIC_API_KEY', value: 'sk-ant' }) })
|
|
})
|
|
|
|
it('switches the add card target and degrades unknown or broken targets loudly', async () => {
|
|
await mountSection()
|
|
fireEvent.click(screen.getByText(en.add))
|
|
const pick = await screen.findByLabelText<HTMLSelectElement>(en.provider)
|
|
fireEvent.change(pick, { target: { value: 'broken' } })
|
|
await screen.findByText(/unresolvable settings path/)
|
|
fireEvent.change(pick, { target: { value: 'plain' } })
|
|
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)
|
|
})
|
|
|
|
it('surfaces a rejected settings write and never stores the key after it', async () => {
|
|
const { set } = await mountSection({
|
|
mutate: vi.fn(() => Promise.resolve(fail('llm-pi-ai: unknown pi-ai provider "bogus"'))),
|
|
})
|
|
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)
|
|
await screen.findByText(/unknown pi-ai provider/)
|
|
expect(set).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('renders the card without the stored-key hint when the credential probe rejects', async () => {
|
|
// The probe is a placeholder hint, not a precondition: an escaping
|
|
// rejection would surface in the browser as an unhandled rejection.
|
|
const { face } = scriptedFace()
|
|
face.credentials.describe = vi.fn(() => Promise.reject(new Error('connection lost')))
|
|
const unhandled = vi.fn()
|
|
process.on('unhandledRejection', unhandled)
|
|
try {
|
|
const controller = new ModelsSettingsStore(face as unknown as WireFace)
|
|
await controller.load()
|
|
render(<ModelsSection
|
|
controller={controller}
|
|
useSnapshot={bindSnapshotSelector(controller.store)}
|
|
api={face as never}
|
|
t={t}
|
|
/>)
|
|
const key = await screen.findByLabelText<HTMLInputElement>(en.keyInput)
|
|
expect(key.placeholder).toBe(en.keyPlaceholder)
|
|
await new Promise(resolve => setTimeout(resolve, 10))
|
|
expect(unhandled).not.toHaveBeenCalled()
|
|
} finally {
|
|
process.off('unhandledRejection', unhandled)
|
|
}
|
|
})
|
|
|
|
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({
|
|
mutate: vi.fn(() => Promise.resolve(fail('changed since it was read', 'settings-conflict'))),
|
|
})
|
|
fireEvent.click(screen.getByText(en.customized))
|
|
fireEvent.change(screen.getByLabelText<HTMLInputElement>(en.baseUrl), { target: { value: 'https://mine' } })
|
|
fireEvent.click(screen.getByText(en.apply))
|
|
await screen.findByText(en.conflict)
|
|
expect(set).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('keeps the card usable when the write rejects instead of answering', async () => {
|
|
// 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'))) })
|
|
fireEvent.click(screen.getByText(en.customized))
|
|
fireEvent.change(screen.getByLabelText<HTMLInputElement>(en.baseUrl), { target: { value: 'https://next' } })
|
|
fireEvent.click(screen.getByText(en.apply))
|
|
await screen.findByText('connection lost')
|
|
// Not stuck in `applying…`: the finally cleared busy, so Apply is live again.
|
|
expect(screen.getByText(en.apply)).toBeTruthy()
|
|
})
|
|
|
|
it('surfaces a shadowed credential write on the card', async () => {
|
|
await mountSection({
|
|
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)
|
|
fireEvent.change(key, { target: { value: 'sk-live' } })
|
|
fireEvent.click(screen.getByText(en.apply))
|
|
await screen.findByText(/shadowed by the read-only environment/)
|
|
})
|
|
|
|
it('locks the key input when the launch environment provides the credential', async () => {
|
|
const { face } = await mountSection()
|
|
face.credentials.describe.mockImplementation((payload: { refs: string[] }) => Promise.resolve(ok({
|
|
credentials: Object.fromEntries(payload.refs.map(ref => [ref, {
|
|
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
|
|
await waitFor(() => { expect(editorKey.placeholder).toBe(en.keyEnvLocked) })
|
|
expect(editorKey.disabled).toBe(true)
|
|
})
|
|
|
|
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
|
|
expect(editorKey.placeholder).toBe(en.keyPlaceholder)
|
|
fireEvent.change(editorKey, { target: { value: 'sk-live' } })
|
|
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
|
|
await waitFor(() => { expect(set).toHaveBeenCalledTimes(1) })
|
|
})
|
|
|
|
it('requires confirmation before removing a user-added provider', async () => {
|
|
const { replace, mutate } = await mountSection()
|
|
fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
|
|
const dialog = screen.getByRole('dialog', { name: en.deleteTitle })
|
|
expect(dialog.textContent).toContain(en.deleteDescription)
|
|
expect(document.activeElement).toBe(within(dialog).getByRole('button', { name: en.cancel }))
|
|
expect(mutate).not.toHaveBeenCalled()
|
|
fireEvent.click(within(dialog).getByRole('button', { name: en.cancel }))
|
|
expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull()
|
|
expect(mutate).not.toHaveBeenCalled()
|
|
|
|
fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
|
|
fireEvent.click(within(screen.getByRole('dialog', { name: en.deleteTitle }))
|
|
.getByRole('button', { name: en.close }))
|
|
expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull()
|
|
expect(mutate).not.toHaveBeenCalled()
|
|
|
|
fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
|
|
fireEvent.click(within(screen.getByRole('dialog', { name: en.deleteTitle }))
|
|
.getByRole('button', { name: en.deleteConfirm }))
|
|
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
|
|
expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull()
|
|
expect(replace).not.toHaveBeenCalled()
|
|
expect(mutate.mock.calls[0]?.[0]).toEqual({
|
|
ns: 'llm-pi-ai',
|
|
ops: [{ op: 'unset', path: ['providers', 'openai'] }],
|
|
})
|
|
})
|
|
|
|
it('blocks duplicate deletion while the confirmed removal is pending', async () => {
|
|
let resolveRemoval!: (response: RpcResponse<SettingsNamespaceView>) => void
|
|
const mutate = vi.fn(() => new Promise<RpcResponse<SettingsNamespaceView>>((resolve) => {
|
|
resolveRemoval = resolve
|
|
}))
|
|
await mountSection({ mutate })
|
|
fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
|
|
const dialog = screen.getByRole('dialog', { name: en.deleteTitle })
|
|
const confirm = within(dialog).getByRole<HTMLButtonElement>('button', { name: en.deleteConfirm })
|
|
fireEvent.click(confirm)
|
|
fireEvent.click(confirm)
|
|
expect(mutate).toHaveBeenCalledOnce()
|
|
expect(confirm.disabled).toBe(true)
|
|
expect(within(dialog).getByRole<HTMLButtonElement>('button', { name: en.cancel }).disabled).toBe(true)
|
|
expect(within(dialog).getByRole('button', { name: en.deleting })).toBe(confirm)
|
|
fireEvent.click(within(dialog).getByRole('button', { name: en.close }))
|
|
expect(screen.getByRole('dialog', { name: en.deleteTitle })).toBe(dialog)
|
|
expect(mutate).toHaveBeenCalledOnce()
|
|
await act(async () => { resolveRemoval(ok(wireNamespaces()[2]!)) })
|
|
await waitFor(() => { expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull() })
|
|
})
|
|
|
|
it('renders the load failure with a retry control', async () => {
|
|
const face = scriptedFace()
|
|
face.face.llm.providers = vi.fn(() => Promise.resolve(fail('directory down', 'internal'))) as never
|
|
const controller = new ModelsSettingsStore(face.face as unknown as WireFace)
|
|
await controller.load()
|
|
render(<ModelsSection
|
|
controller={controller}
|
|
useSnapshot={bindSnapshotSelector(controller.store)}
|
|
api={face.face as never}
|
|
t={t}
|
|
/>)
|
|
expect(screen.getByText(/directory down/)).toBeTruthy()
|
|
fireEvent.click(screen.getByText(en.retry))
|
|
await waitFor(() => { expect(screen.queryByText(/directory down/)).toBeNull() })
|
|
})
|
|
|
|
it('shows the read-only notice and disables mutations for a read-only provider', async () => {
|
|
const { face } = await mountSection()
|
|
face.settings.describe.mockImplementation(() => Promise.resolve(ok({
|
|
writable: false,
|
|
hasDocument: false,
|
|
namespaces: wireNamespaces(),
|
|
})))
|
|
const controller = new ModelsSettingsStore(face as unknown as WireFace)
|
|
await controller.load()
|
|
cleanup()
|
|
render(<ModelsSection
|
|
controller={controller}
|
|
useSnapshot={bindSnapshotSelector(controller.store)}
|
|
api={face as never}
|
|
t={t}
|
|
/>)
|
|
expect(screen.getByText(en.readOnly)).toBeTruthy()
|
|
expect(screen.getAllByText<HTMLButtonElement>(en.remove).every(button => button.disabled)).toBe(true)
|
|
expect(screen.getByText<HTMLButtonElement>(en.add).disabled).toBe(true)
|
|
})
|
|
|
|
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
|
|
fireEvent.click(edit)
|
|
await waitFor(() => { expect(screen.getAllByLabelText(en.keyInput).length).toBe(2) })
|
|
fireEvent.click(edit)
|
|
expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1)
|
|
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)
|
|
expect(update).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('cancels the add card back to the add button', async () => {
|
|
await mountSection()
|
|
fireEvent.click(screen.getByText(en.add))
|
|
await screen.findByLabelText(en.provider)
|
|
fireEvent.click(screen.getAllByText(en.cancel)[1] as HTMLElement)
|
|
await screen.findByText(en.add)
|
|
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)
|
|
render(<ModelsSection
|
|
controller={controller}
|
|
useSnapshot={bindSnapshotSelector(controller.store)}
|
|
api={face as never}
|
|
t={t}
|
|
/>)
|
|
await screen.findByText('DeepSeek')
|
|
})
|
|
|
|
it('removes by unsetting the profile path, never by rebuilding the section', async () => {
|
|
// The section rebuild is what dropped stored literal secrets: this page
|
|
// only ever holds the redacted descriptor, so the removal names the path.
|
|
const { face, mutate, replace, controller } = await mountSection()
|
|
await removeProviderProfile(
|
|
face as unknown as Parameters<typeof removeProviderProfile>[0],
|
|
controller,
|
|
{ settingsNs: 'llm-plain', settingsPath: ['ghost-profile'] },
|
|
)
|
|
expect(mutate.mock.calls[0]?.[0]).toEqual({
|
|
ns: 'llm-plain',
|
|
ops: [{ op: 'unset', path: ['ghost-profile'] }],
|
|
})
|
|
expect(replace).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('keeps the snapshot untouched and reports the message when a removal write is refused', async () => {
|
|
const { face, controller } = await mountSection({
|
|
mutate: vi.fn(() => Promise.resolve(fail('read-only'))),
|
|
})
|
|
const before = controller.store.getSnapshot().rows
|
|
const failure = await removeProviderProfile(
|
|
face as unknown as Parameters<typeof removeProviderProfile>[0],
|
|
controller,
|
|
{ settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'] },
|
|
)
|
|
expect(failure).toBe('read-only')
|
|
expect(controller.store.getSnapshot().rows).toBe(before)
|
|
})
|
|
|
|
it('shows a failed removal on the page banner, including a non-Error rejection', async () => {
|
|
// The whole click path: the row's Remove button, the transport rejecting
|
|
// with a non-Error value, and the store surfacing it where a load failure
|
|
// would appear — rather than the row silently staying put.
|
|
await mountSection({ mutate: vi.fn(() => Promise.reject(new Error('the host refused'))) })
|
|
fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
|
|
fireEvent.click(within(screen.getByRole('dialog', { name: en.deleteTitle }))
|
|
.getByRole('button', { name: en.deleteConfirm }))
|
|
await screen.findByText(`${en.loadFailed}: the host refused`)
|
|
})
|
|
|
|
it('reports a transport rejection instead of failing the removal silently', async () => {
|
|
const { face, controller } = await mountSection({
|
|
mutate: vi.fn(() => Promise.reject(new Error('connection lost'))),
|
|
})
|
|
const failure = await removeProviderProfile(
|
|
face as unknown as Parameters<typeof removeProviderProfile>[0],
|
|
controller,
|
|
{ settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'] },
|
|
)
|
|
expect(failure).toBe('connection lost')
|
|
})
|
|
})
|