mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
llm.discoverModels was reachable from any declared trusted host. The method takes a caller-supplied baseURL and makes the host issue a GET to it, then reports the status or the parsed body — so on a LAN deployment an anonymous caller had a probe for whatever the host can reach and the browser cannot, plus a path that carries a draft credential. The PRIVILEGED_METHODS doc already states the rule this broke: trustedHosts is a DNS-rebinding fence, not authentication, so the configuration plane stays loopback-same-origin. It is in that set now, asserted both against the hand-built fence and over real HTTP beside the catalog reads that deliberately stay reachable. supportsDiscovery and listModelDiscoveryNamespaces are gone. The field was required on the wire and read by nobody: its own contract said a surface should offer the action "instead of naming an adapter family it would have to hardcode", while the surface hardcodes llm-pi-ai in two places and gates the button on whether there is anything to probe. Its shape did not fit the second caller either — the create card has no row to read a per-row field from. Keeping a required field alive for a consumer that may never arrive costs every producer and fixture a value nobody consults, which is exactly how the fixtures drifted. The registry that fed it had no other production consumer, so registration and disposal are now observed through the offer itself. The Agent Note claimed the key is never logged, which the wire schema beside it already contradicts, and predated both the provider field and the catalog-answer path. The two new public types pointed at core.md without a type-equiv block or manifest entry, so the generated service catalog named documentation that did not exist.
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')
|
|
})
|
|
})
|