feat(web): declare a provider and its models from the Models page
The Models page could name a provider's key and little else. Adding an OpenAI-compatible gateway meant opening $DSH_HOME/settings.yaml and knowing the profile shape; correcting a stale context window meant the same. This layer puts both on the page: a card that declares a route pi-ai does not ship — id, endpoint, protocol, key, models — and a model list on the pi-ai editor that can ask the provider what it serves and adopt the answer. It follows the DeepSeek catalog editor that landed in #1050 rather than inventing a second look for the same job. Both editors now share the section shell and heading, the danger-tinted delete, the add-model button, the empty state, the per-row validator that names a bad row by its position, and one K/M capacity vocabulary — 256K and 1M are read and spelled back, while settings.yaml still stores plain token counts. The row type is structurally open like that editor's, so a profile field this card does not edit survives an edit here. Three of that editor's decisions replaced weaker ones this branch had made. Inheritance now reads the composition base rather than the effective value, which would echo an override back the moment a reset dropped it. Validation names the offending row instead of stating a blanket problem. And emptying the list is no longer conflated with handing the catalog back to the adapter — those are separate acts, with separate affordances. The create write carries the revision the card opened at, so a route another tab declared meanwhile is a conflict rather than a silent overwrite of its profile.
This commit is contained in:
@@ -0,0 +1,846 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Model-list editing, endpoint interrogation, and hand-declared provider creation. */
|
||||
import { cleanup, fireEvent, render, screen, waitFor } 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 } from '../src/client/ModelsSection.tsx'
|
||||
import type { ModelsSectionInjected } from '../src/client/ModelsSection.tsx'
|
||||
import { CustomProviderCard } from '../src/client/CustomProviderCard.tsx'
|
||||
import { formatCapacity, parseCapacity } from '../src/client/DeepSeekModelsEditor.tsx'
|
||||
import { ModelsSettingsStore, protocolChoices } from '../src/client/store.ts'
|
||||
import { en } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const t: ModelsSectionInjected['t'] = key => en[key]
|
||||
|
||||
const PROTOCOLS = ['openai-completions', 'openai-responses', 'anthropic-messages']
|
||||
|
||||
/** The pi-ai profile shape as the host serializes it, including the layer-1 fields. */
|
||||
const PiAiConfig = Schema.object({
|
||||
providers: Schema.dict(Schema.object({
|
||||
apiKey: Schema.string().role('secret'),
|
||||
apiKeyEnv: Schema.string().role('credential-ref'),
|
||||
displayName: Schema.string(),
|
||||
api: Schema.union(PROTOCOLS),
|
||||
baseURL: Schema.string(),
|
||||
models: Schema.array(Schema.object({
|
||||
id: Schema.string().required(),
|
||||
name: Schema.string(),
|
||||
contextWindow: Schema.number(),
|
||||
maxTokens: Schema.number(),
|
||||
})),
|
||||
reasoning: Schema.union(['off', 'high']),
|
||||
})),
|
||||
})
|
||||
|
||||
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: string): RpcResponse<T> {
|
||||
return { rpcId: `r-${nextRpc++}` as never, result: { ok: false, error: { code, message, details: {} } as never } }
|
||||
}
|
||||
|
||||
function piAiNamespace(
|
||||
providers: Record<string, unknown>,
|
||||
userProviders: Record<string, unknown> = providers,
|
||||
): SettingsNamespaceView {
|
||||
return {
|
||||
ns: 'llm-pi-ai',
|
||||
schema: JSON.parse(JSON.stringify(PiAiConfig.toJSON())) as unknown,
|
||||
// `value` is the effective section; `user` is only the layer this page
|
||||
// writes. They differ whenever a composition `base` supplies something.
|
||||
value: { providers },
|
||||
base: {},
|
||||
user: { providers: userProviders },
|
||||
applies: 'live',
|
||||
secrets: [],
|
||||
revision: 3,
|
||||
}
|
||||
}
|
||||
|
||||
function scriptedFace(options: {
|
||||
providers?: Record<string, unknown>
|
||||
/** User layer, when it differs from the effective section. */
|
||||
userProviders?: Record<string, unknown>
|
||||
discover?: ReturnType<typeof vi.fn>
|
||||
mutate?: ReturnType<typeof vi.fn>
|
||||
set?: ReturnType<typeof vi.fn>
|
||||
} = {}) {
|
||||
const providers = options.providers ?? {
|
||||
openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://proxy.example/v1' },
|
||||
}
|
||||
const namespace = piAiNamespace(providers, options.userProviders ?? providers)
|
||||
const discover = options.discover ?? vi.fn(() => Promise.resolve(ok({ models: [] })))
|
||||
const mutate = options.mutate ?? vi.fn(() => Promise.resolve(ok(namespace)))
|
||||
const set = options.set ?? vi.fn(() => Promise.resolve(ok({})))
|
||||
const face = {
|
||||
llm: {
|
||||
providers: vi.fn(() => Promise.resolve(ok({
|
||||
providers: Object.keys(providers).map(provider => ({
|
||||
provider,
|
||||
displayName: provider,
|
||||
settingsNs: 'llm-pi-ai',
|
||||
settingsPath: ['providers', provider],
|
||||
active: true,
|
||||
})),
|
||||
}))),
|
||||
models: vi.fn(() => Promise.resolve(ok({ groups: [], failures: [] }))),
|
||||
discoverModels: discover,
|
||||
},
|
||||
settings: {
|
||||
describe: vi.fn(() => Promise.resolve(ok({ writable: true, namespaces: [namespace] }))),
|
||||
update: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
mutate,
|
||||
},
|
||||
credentials: {
|
||||
describe: vi.fn((payload: { refs: string[] }) => Promise.resolve(ok({
|
||||
credentials: Object.fromEntries(payload.refs.map(ref => [ref, { configured: false, writable: true }])),
|
||||
}))),
|
||||
set,
|
||||
unset: vi.fn(),
|
||||
},
|
||||
}
|
||||
return { face, discover, mutate, set, namespace }
|
||||
}
|
||||
|
||||
type WireFace = ConstructorParameters<typeof ModelsSettingsStore>[0]
|
||||
|
||||
/** The settings write one card produced, as the scripted face recorded it. */
|
||||
interface MutateCall {
|
||||
ns: string
|
||||
expectedRevision?: number
|
||||
ops: { op: string; path: string[]; value?: unknown }[]
|
||||
}
|
||||
|
||||
/** The first interrogation payload; fails the case when nothing was asked. */
|
||||
function firstProbe(discover: ReturnType<typeof vi.fn>): unknown {
|
||||
const call = (discover.mock.calls as unknown as [unknown][])[0]?.[0]
|
||||
if (call === undefined) throw new Error('no interrogation was recorded')
|
||||
return call
|
||||
}
|
||||
|
||||
/** The first recorded settings write; fails the case when nothing was written. */
|
||||
function firstMutate(mutate: ReturnType<typeof vi.fn>): MutateCall {
|
||||
const call = mutate.mock.calls[0]?.[0] as MutateCall | undefined
|
||||
if (call === undefined) throw new Error('no settings write was recorded')
|
||||
return call
|
||||
}
|
||||
|
||||
async function mountSection(options: Parameters<typeof scriptedFace>[0] = {}) {
|
||||
const scripted = scriptedFace(options)
|
||||
const controller = new ModelsSettingsStore(scripted.face as unknown as WireFace)
|
||||
await controller.load()
|
||||
const injected: ModelsSectionInjected = {
|
||||
controller,
|
||||
useSnapshot: bindSnapshotSelector(controller.store),
|
||||
api: scripted.face as never,
|
||||
t,
|
||||
}
|
||||
render(<ModelsSection {...injected} />)
|
||||
return scripted
|
||||
}
|
||||
|
||||
/** Open the editor of one configured row and expand its customized fold. */
|
||||
function openEditor(provider: string): void {
|
||||
const row = screen.getByText(provider).closest('li')
|
||||
if (row === null) throw new Error(`no row for ${provider}`)
|
||||
fireEvent.click(within_(row, en.edit))
|
||||
const summary = document.querySelector('summary')
|
||||
if (summary === null) throw new Error('no customized fold')
|
||||
fireEvent.click(summary)
|
||||
}
|
||||
|
||||
/** Open one model row's advanced fold, where the capacities live. */
|
||||
function expandModel(index: number): void {
|
||||
fireEvent.click(screen.getByLabelText(`${en.modelAdvanced} ${index}`))
|
||||
}
|
||||
|
||||
/** The button carrying `label`, typed so its disabled/title state is readable. */
|
||||
function buttonNamed(label: string): HTMLButtonElement {
|
||||
const found = screen.getByText(label)
|
||||
if (!(found instanceof HTMLButtonElement)) throw new Error(`"${label}" is not a button`)
|
||||
return found
|
||||
}
|
||||
|
||||
/** Click the button with `label` inside `scope`. */
|
||||
function within_(scope: HTMLElement, label: string): HTMLElement {
|
||||
const found = [...scope.querySelectorAll('button')].find(button => button.textContent === label)
|
||||
if (found === undefined) throw new Error(`no "${label}" button`)
|
||||
return found
|
||||
}
|
||||
|
||||
describe('protocolChoices', () => {
|
||||
it('reads the protocols out of the namespace schema and nothing else', async () => {
|
||||
const { namespace } = scriptedFace()
|
||||
expect(protocolChoices(namespace)).toEqual(PROTOCOLS)
|
||||
expect(protocolChoices(undefined)).toEqual([])
|
||||
const plain = { ...namespace, schema: JSON.parse(JSON.stringify(Schema.object({}).toJSON())) as unknown }
|
||||
expect(protocolChoices(plain)).toEqual([])
|
||||
await Promise.resolve()
|
||||
})
|
||||
})
|
||||
|
||||
describe('model list editing', () => {
|
||||
it('adds, edits, and removes rows without storing emptied optional fields', async () => {
|
||||
const { mutate } = await mountSection()
|
||||
openEditor('openai')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
|
||||
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } })
|
||||
expandModel(1)
|
||||
fireEvent.change(screen.getByLabelText(`${en.modelContextWindow} 1`), { target: { value: '65536' } })
|
||||
fireEvent.change(screen.getByLabelText(`${en.modelName} 1`), { target: { value: 'Acme' } })
|
||||
// Clearing an optional field must drop it rather than store an empty value.
|
||||
fireEvent.change(screen.getByLabelText(`${en.modelName} 1`), { target: { value: '' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalled() })
|
||||
expect(firstMutate(mutate)).toMatchObject({
|
||||
ns: 'llm-pi-ai',
|
||||
expectedRevision: 3,
|
||||
ops: [{ op: 'set', path: ['providers', 'openai', 'models'], value: [{ id: 'acme-large', contextWindow: 65_536 }] }],
|
||||
})
|
||||
})
|
||||
|
||||
it('names a duplicate model id in the edit flow too', async () => {
|
||||
const { mutate } = await mountSection({
|
||||
providers: { openai: { baseURL: 'https://proxy.example/v1', models: [{ id: 'dup' }] } },
|
||||
})
|
||||
openEditor('openai')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
|
||||
fireEvent.change(screen.getByLabelText(`${en.modelId} 2`), { target: { value: 'dup' } })
|
||||
|
||||
// The create card refuses this in place; an edited route must not have to
|
||||
// learn it from the host's refusal instead.
|
||||
expect(screen.getByText(`${en.model} 2: ${en.modelIdDuplicate}`)).toBeTruthy()
|
||||
expect(buttonNamed(en.apply).disabled).toBe(true)
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reads K and M suffixes and keeps the text the user typed', async () => {
|
||||
const { mutate } = await mountSection()
|
||||
openEditor('openai')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
|
||||
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } })
|
||||
expandModel(1)
|
||||
fireEvent.change(screen.getByLabelText(`${en.modelContextWindow} 1`), { target: { value: '1M' } })
|
||||
fireEvent.change(screen.getByLabelText(`${en.modelMaxTokens} 1`), { target: { value: '32K' } })
|
||||
|
||||
// The field keeps the spelling rather than snapping to the expansion, and
|
||||
// a plain count is not rewritten into a suffix mid-word either.
|
||||
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelContextWindow} 1`).value).toBe('1M')
|
||||
fireEvent.change(screen.getByLabelText(`${en.modelMaxTokens} 1`), { target: { value: '1000' } })
|
||||
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelMaxTokens} 1`).value).toBe('1000')
|
||||
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalled() })
|
||||
// What lands in settings is always a plain token count.
|
||||
expect(firstMutate(mutate).ops[0]?.value)
|
||||
.toEqual([{ id: 'm', contextWindow: 1_000_000, maxTokens: 1000 }])
|
||||
})
|
||||
|
||||
it('refuses to apply while a capacity is unreadable', async () => {
|
||||
const { mutate } = await mountSection()
|
||||
openEditor('openai')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
|
||||
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } })
|
||||
expandModel(1)
|
||||
fireEvent.change(screen.getByLabelText(`${en.modelMaxTokens} 1`), { target: { value: 'abc' } })
|
||||
|
||||
// Silently dropping it would store a route sized differently from what the
|
||||
// field shows, so the text stays put and the write is refused instead.
|
||||
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelMaxTokens} 1`).value).toBe('abc')
|
||||
expect(screen.getByText(`${en.model} 1: ${en.modelMaxTokensInvalid}`)).toBeTruthy()
|
||||
expect(buttonNamed(en.apply).disabled).toBe(true)
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('edits one row of several and lets a cleared capacity leave the profile', async () => {
|
||||
const { mutate } = await mountSection({
|
||||
providers: { openai: { baseURL: 'https://proxy.example/v1', models: [{ id: 'first' }, { id: 'second' }] } },
|
||||
})
|
||||
openEditor('openai')
|
||||
|
||||
expandModel(2)
|
||||
fireEvent.change(screen.getByLabelText(`${en.modelMaxTokens} 2`), { target: { value: '2048' } })
|
||||
fireEvent.change(screen.getByLabelText(`${en.modelName} 2`), { target: { value: 'Second' } })
|
||||
fireEvent.change(screen.getByLabelText(`${en.modelContextWindow} 2`), { target: { value: '4096' } })
|
||||
// Clearing it back to empty must drop the field, not store a zero.
|
||||
fireEvent.change(screen.getByLabelText(`${en.modelContextWindow} 2`), { target: { value: '' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalled() })
|
||||
expect(firstMutate(mutate).ops[0]?.value).toEqual([
|
||||
{ id: 'first' },
|
||||
{ id: 'second', name: 'Second', maxTokens: 2048 },
|
||||
])
|
||||
})
|
||||
|
||||
it('shows the adapter defaults as inherited until an edit takes them over', async () => {
|
||||
await mountSection({ providers: { openai: { baseURL: 'https://proxy.example/v1' } } })
|
||||
openEditor('openai')
|
||||
|
||||
// The user layer names no models, so the list belongs to the adapter and
|
||||
// says so; taking it over is an explicit act, not a side effect of opening.
|
||||
expect(screen.getByText(en.modelsInherited)).toBeTruthy()
|
||||
expect(screen.queryByText(en.resetModels)).toBeNull()
|
||||
})
|
||||
|
||||
|
||||
it('keeps expansion on the row it belongs to after an earlier one is removed', async () => {
|
||||
await mountSection({
|
||||
providers: {
|
||||
openai: {
|
||||
baseURL: 'https://proxy.example/v1',
|
||||
models: [{ id: 'first' }, { id: 'second' }, { id: 'third' }],
|
||||
},
|
||||
},
|
||||
})
|
||||
openEditor('openai')
|
||||
|
||||
// Expansion is keyed by position, so removing an earlier row shifts the
|
||||
// rest down; without reindexing, row 3 would inherit row 2's open state.
|
||||
expandModel(2)
|
||||
fireEvent.click(screen.getByLabelText(`${en.removeModel} 1`))
|
||||
|
||||
// 'second' now sits at position 1 and keeps its capacities open; 'third'
|
||||
// moved to position 2 and stays folded.
|
||||
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelId} 1`).value).toBe('second')
|
||||
expect(screen.queryByLabelText(`${en.modelContextWindow} 1`)).not.toBeNull()
|
||||
expect(screen.queryByLabelText(`${en.modelContextWindow} 2`)).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves an earlier row expanded and forgets the removed row\u2019s own state', async () => {
|
||||
await mountSection({
|
||||
providers: {
|
||||
openai: {
|
||||
baseURL: 'https://proxy.example/v1',
|
||||
models: [{ id: 'first' }, { id: 'second' }, { id: 'third' }],
|
||||
},
|
||||
},
|
||||
})
|
||||
openEditor('openai')
|
||||
|
||||
// A row before the removal keeps its own position and stays open.
|
||||
expandModel(1)
|
||||
fireEvent.click(screen.getByLabelText(`${en.removeModel} 2`))
|
||||
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelId} 1`).value).toBe('first')
|
||||
expect(screen.queryByLabelText(`${en.modelContextWindow} 1`)).not.toBeNull()
|
||||
|
||||
// Removing the expanded row itself drops that state rather than handing it
|
||||
// to whichever row slides into the position.
|
||||
fireEvent.click(screen.getByLabelText(`${en.removeModel} 1`))
|
||||
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelId} 1`).value).toBe('third')
|
||||
expect(screen.queryByLabelText(`${en.modelContextWindow} 1`)).toBeNull()
|
||||
})
|
||||
|
||||
it('separates emptying the list from restoring the adapter defaults', async () => {
|
||||
const { mutate } = await mountSection({
|
||||
providers: { openai: { baseURL: 'https://proxy.example/v1', models: [{ id: 'kept' }] } },
|
||||
})
|
||||
openEditor('openai')
|
||||
|
||||
// An empty override is a route that serves no models — a different intent
|
||||
// from handing the catalog back, which is what the reset affordance does.
|
||||
expect(screen.getByText(en.modelsCustomized)).toBeTruthy()
|
||||
fireEvent.click(screen.getByText(en.resetModels))
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalled() })
|
||||
expect(firstMutate(mutate).ops)
|
||||
.toContainEqual({ op: 'unset', path: ['providers', 'openai', 'models'] })
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('capacity spellings', () => {
|
||||
it.each([
|
||||
['', undefined],
|
||||
['65536', 65_536],
|
||||
['256K', 256_000],
|
||||
['1m', 1_000_000],
|
||||
// A decimal multiple is exact in intent but not in binary floating point,
|
||||
// so an integral result snaps back instead of landing a few ULPs high.
|
||||
['2.3M', 2_300_000],
|
||||
// Not an integral count: kept as written rather than silently rounded.
|
||||
['1.0005K', 1000.5],
|
||||
])('reads %j as %j', (text, expected) => {
|
||||
expect(parseCapacity(text)).toBe(expected)
|
||||
})
|
||||
|
||||
it.each(['abc', '12x', '1 000', '-5', ''])('refuses %j rather than guessing', (text) => {
|
||||
const parsed = parseCapacity(text)
|
||||
expect(parsed === undefined || Number.isNaN(parsed)).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[1_000_000, '1M'],
|
||||
[256_000, '256K'],
|
||||
[65_536, '65536'],
|
||||
// Never a spelling that would not survive being read back.
|
||||
[0, '0'],
|
||||
[1.5, '1.5'],
|
||||
])('spells %j as %j', (value, expected) => {
|
||||
expect(formatCapacity(value)).toBe(expected)
|
||||
})
|
||||
|
||||
it('round-trips every spelling it produces', () => {
|
||||
for (const value of [1_000_000, 256_000, 65_536, 4096, 1000]) {
|
||||
expect(parseCapacity(formatCapacity(value))).toBe(value)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('endpoint interrogation', () => {
|
||||
it('asks the endpoint the form shows, with a key that is not yet stored', async () => {
|
||||
const discover = vi.fn(() => Promise.resolve(ok({ models: [{ id: 'acme-large', contextWindow: 65_536 }] })))
|
||||
await mountSection({ discover })
|
||||
openEditor('openai')
|
||||
|
||||
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'typed-not-saved' } })
|
||||
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://edited.example/v1' } })
|
||||
fireEvent.click(screen.getByText(en.fetchModels))
|
||||
|
||||
await waitFor(() => { expect(discover).toHaveBeenCalled() })
|
||||
expect(firstProbe(discover)).toEqual({
|
||||
settingsNs: 'llm-pi-ai',
|
||||
// The route is named, so an adapter that already describes it answers
|
||||
// from its own registry rather than the endpoint.
|
||||
provider: 'openai',
|
||||
baseURL: 'https://edited.example/v1',
|
||||
apiKey: 'typed-not-saved',
|
||||
})
|
||||
})
|
||||
|
||||
it('carries the protocol the profile already names', async () => {
|
||||
const discover = vi.fn(() => Promise.resolve(ok({ models: [] })))
|
||||
await mountSection({
|
||||
discover,
|
||||
providers: { openai: { baseURL: 'https://proxy.example/v1', api: 'openai-responses' } },
|
||||
})
|
||||
openEditor('openai')
|
||||
|
||||
fireEvent.click(screen.getByText(en.fetchModels))
|
||||
|
||||
await waitFor(() => { expect(discover).toHaveBeenCalled() })
|
||||
expect(firstProbe(discover)).toEqual({
|
||||
settingsNs: 'llm-pi-ai',
|
||||
provider: 'openai',
|
||||
baseURL: 'https://proxy.example/v1',
|
||||
api: 'openai-responses',
|
||||
})
|
||||
})
|
||||
|
||||
it('adopts only the picked candidates, keeping a row the user already tuned', async () => {
|
||||
const discover = vi.fn(() => Promise.resolve(ok({
|
||||
models: [{ id: 'kept', contextWindow: 999 }, { id: 'fresh', contextWindow: 4096, name: 'Fresh' }],
|
||||
})))
|
||||
const { mutate } = await mountSection({
|
||||
discover,
|
||||
providers: { openai: { baseURL: 'https://proxy.example/v1', models: [{ id: 'kept', contextWindow: 111 }] } },
|
||||
})
|
||||
openEditor('openai')
|
||||
|
||||
fireEvent.click(screen.getByText(en.fetchModels))
|
||||
await screen.findByText(en.fetchTitle)
|
||||
// The already-configured row starts unchecked; the new one starts checked.
|
||||
const boxes = [...document.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')]
|
||||
expect(boxes.map(box => box.checked)).toEqual([false, true])
|
||||
fireEvent.click(screen.getByText(en.fetchAdopt))
|
||||
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalled() })
|
||||
expect(firstMutate(mutate).ops[0]?.value).toEqual([
|
||||
{ id: 'kept', contextWindow: 111 },
|
||||
{ id: 'fresh', contextWindow: 4096, name: 'Fresh' },
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the rows editable when the provider cannot be interrogated', async () => {
|
||||
const discover = vi.fn(() => Promise.resolve(
|
||||
fail('https://proxy.example/v1/models answered 401; check the API key', 'model-discovery-failed'),
|
||||
))
|
||||
await mountSection({ discover })
|
||||
openEditor('openai')
|
||||
|
||||
fireEvent.click(screen.getByText(en.fetchModels))
|
||||
|
||||
await screen.findByText(/answered 401; check the API key/)
|
||||
// The failure is a detour, not a dead end: hand-entry is still offered.
|
||||
expect(screen.getByRole('button', { name: en.addModel })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('reports an empty listing and a rejected transport', async () => {
|
||||
const empty = vi.fn(() => Promise.resolve(ok({ models: [] })))
|
||||
await mountSection({ discover: empty })
|
||||
openEditor('openai')
|
||||
fireEvent.click(screen.getByText(en.fetchModels))
|
||||
await screen.findByText(en.fetchEmpty)
|
||||
cleanup()
|
||||
|
||||
const rejected = vi.fn(() => Promise.reject(new Error('carrier down')))
|
||||
await mountSection({ discover: rejected })
|
||||
openEditor('openai')
|
||||
fireEvent.click(screen.getByText(en.fetchModels))
|
||||
await screen.findByText('carrier down')
|
||||
})
|
||||
|
||||
it('can be asked for a configured route even with no endpoint', async () => {
|
||||
const discover = vi.fn(() => Promise.resolve(ok({ models: [{ id: 'from-registry' }] })))
|
||||
await mountSection({ discover, providers: { openai: {} } })
|
||||
openEditor('openai')
|
||||
|
||||
// A route the adapter already describes needs no endpoint at all.
|
||||
expect(buttonNamed(en.fetchModels).disabled).toBe(false)
|
||||
fireEvent.click(screen.getByText(en.fetchModels))
|
||||
|
||||
await waitFor(() => { expect(discover).toHaveBeenCalled() })
|
||||
expect(firstProbe(discover)).toEqual({ settingsNs: 'llm-pi-ai', provider: 'openai' })
|
||||
})
|
||||
|
||||
it('keeps the create card asking only once it has an endpoint', () => {
|
||||
// A provider being declared has no route yet, so the endpoint is the only
|
||||
// thing an interrogation could go on.
|
||||
const scripted = scriptedFace()
|
||||
render(
|
||||
<CustomProviderCard
|
||||
taken={[]} protocols={PROTOCOLS} revision={7} api={scripted.face as never}
|
||||
t={t} readOnly={false} onClose={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(buttonNamed(en.fetchModels).disabled).toBe(true)
|
||||
expect(buttonNamed(en.fetchModels).title).toBe(en.fetchNeedsBaseUrl)
|
||||
|
||||
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
|
||||
expect(buttonNamed(en.fetchModels).disabled).toBe(false)
|
||||
fireEvent.click(screen.getByText(en.fetchModels))
|
||||
|
||||
// A provider being declared names no route, so only the endpoint travels.
|
||||
expect(firstProbe(scripted.discover)).toEqual({
|
||||
settingsNs: 'llm-pi-ai',
|
||||
baseURL: 'https://acme.test/v1',
|
||||
api: 'openai-completions',
|
||||
})
|
||||
})
|
||||
|
||||
it('folds a row\u2019s capacities away until they are asked for', async () => {
|
||||
await mountSection({
|
||||
providers: { openai: { baseURL: 'https://proxy.example/v1', models: [{ id: 'only' }] } },
|
||||
})
|
||||
openEditor('openai')
|
||||
|
||||
// The row shows what identifies a model; capacities are the exception.
|
||||
expect(screen.queryByLabelText(`${en.modelContextWindow} 1`)).toBeNull()
|
||||
expandModel(1)
|
||||
expect(screen.getByLabelText(`${en.modelContextWindow} 1`)).toBeTruthy()
|
||||
expandModel(1)
|
||||
expect(screen.queryByLabelText(`${en.modelContextWindow} 1`)).toBeNull()
|
||||
})
|
||||
|
||||
it('closes the picker without adopting anything on cancel', async () => {
|
||||
const discover = vi.fn(() => Promise.resolve(ok({ models: [{ id: 'fresh' }] })))
|
||||
const { mutate } = await mountSection({ discover })
|
||||
openEditor('openai')
|
||||
|
||||
fireEvent.click(screen.getByText(en.fetchModels))
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
// The editor card carries a Cancel of its own; this one is the dialog's.
|
||||
fireEvent.click(within_(dialog, en.cancel))
|
||||
|
||||
await waitFor(() => { expect(screen.queryByText(en.fetchTitle)).toBeNull() })
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('toggles a candidate off and back on before adopting', async () => {
|
||||
const discover = vi.fn(() => Promise.resolve(ok({
|
||||
models: [{ id: 'a' }, { id: 'b', maxTokens: 2048 }],
|
||||
})))
|
||||
const { mutate } = await mountSection({ discover })
|
||||
openEditor('openai')
|
||||
|
||||
fireEvent.click(screen.getByText(en.fetchModels))
|
||||
await screen.findByText(en.fetchTitle)
|
||||
const boxes = [...document.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')]
|
||||
const first = boxes[0] as HTMLInputElement
|
||||
fireEvent.click(first)
|
||||
fireEvent.click(first)
|
||||
fireEvent.click(screen.getByText(en.fetchAdopt))
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalled() })
|
||||
// A disclosed output cap rides along with the candidate that has one.
|
||||
expect(firstMutate(mutate).ops[0]?.value).toEqual([{ id: 'a' }, { id: 'b', maxTokens: 2048 }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('hand-declared providers', () => {
|
||||
function mountCard(overrides: Partial<Parameters<typeof CustomProviderCard>[0]> = {}) {
|
||||
const scripted = scriptedFace()
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<CustomProviderCard
|
||||
taken={['openai']}
|
||||
protocols={PROTOCOLS}
|
||||
revision={7}
|
||||
api={scripted.face as never}
|
||||
t={t}
|
||||
readOnly={false}
|
||||
onClose={onClose}
|
||||
{...overrides}
|
||||
/>,
|
||||
)
|
||||
return { ...scripted, onClose }
|
||||
}
|
||||
|
||||
it('writes the whole profile and the key under the derived reference', async () => {
|
||||
const { mutate, set, onClose } = mountCard()
|
||||
|
||||
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme-gateway' } })
|
||||
fireEvent.change(screen.getByLabelText(en.customDisplayName), { target: { value: 'Acme Gateway' } })
|
||||
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://gateway.acme.example/v1' } })
|
||||
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'gw-key' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
|
||||
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } })
|
||||
expandModel(1)
|
||||
fireEvent.change(screen.getByLabelText(`${en.modelContextWindow} 1`), { target: { value: '65536' } })
|
||||
fireEvent.click(screen.getByText(en.create))
|
||||
|
||||
await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) })
|
||||
expect(firstMutate(mutate)).toEqual({
|
||||
ns: 'llm-pi-ai',
|
||||
ops: [{
|
||||
op: 'set',
|
||||
path: ['providers', 'acme-gateway'],
|
||||
value: {
|
||||
displayName: 'Acme Gateway',
|
||||
apiKeyEnv: 'ACME_GATEWAY_API_KEY',
|
||||
api: 'openai-completions',
|
||||
baseURL: 'https://gateway.acme.example/v1',
|
||||
models: [{ id: 'acme-large', contextWindow: 65_536 }],
|
||||
},
|
||||
}],
|
||||
// The section this card was drafted over: a route another tab declared
|
||||
// meanwhile makes this a conflict rather than an overwrite.
|
||||
expectedRevision: 7,
|
||||
})
|
||||
expect(set).toHaveBeenCalledWith({ ref: 'ACME_GATEWAY_API_KEY', value: 'gw-key' })
|
||||
})
|
||||
|
||||
it('names the blocked gate under the form, and nothing once it is satisfied', () => {
|
||||
mountCard()
|
||||
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
|
||||
|
||||
// Endpoint first: the gate names the one thing standing in the way.
|
||||
expect(screen.getByText(en.customNeedsBaseUrl)).toBeTruthy()
|
||||
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
|
||||
expect(screen.getByText(en.customNeedsModels)).toBeTruthy()
|
||||
|
||||
// Satisfied: the shared line disappears rather than rendering empty.
|
||||
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
|
||||
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } })
|
||||
expect(screen.queryByText(en.customNeedsBaseUrl)).toBeNull()
|
||||
expect(screen.queryByText(en.customNeedsModels)).toBeNull()
|
||||
expect(buttonNamed(en.create).disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses to create while a capacity is unreadable', () => {
|
||||
mountCard()
|
||||
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
|
||||
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
|
||||
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } })
|
||||
expandModel(1)
|
||||
fireEvent.change(screen.getByLabelText(`${en.modelContextWindow} 1`), { target: { value: '64 KiB' } })
|
||||
|
||||
expect(screen.getByText(`${en.model} 1: ${en.modelContextInvalid}`)).toBeTruthy()
|
||||
expect(buttonNamed(en.create).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps each half-typed capacity with its own row across a removal', () => {
|
||||
mountCard()
|
||||
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
|
||||
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
|
||||
for (const [at, id] of [[1, 'first'], [2, 'second'], [3, 'third']] as const) {
|
||||
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
|
||||
fireEvent.change(screen.getByLabelText(`${en.modelId} ${String(at)}`), { target: { value: id } })
|
||||
expandModel(at)
|
||||
// Deliberately mid-word: the buffer exists so text like this survives.
|
||||
fireEvent.change(screen.getByLabelText(`${en.modelContextWindow} ${String(at)}`),
|
||||
{ target: { value: `${String(at)}.` } })
|
||||
}
|
||||
|
||||
// Removing the middle row: the one before keeps its position and text, the
|
||||
// one after moves down carrying its own, and the removed row's text goes.
|
||||
fireEvent.click(screen.getByLabelText(`${en.removeModel} 2`))
|
||||
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelId} 1`).value).toBe('first')
|
||||
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelContextWindow} 1`).value).toBe('1.')
|
||||
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelId} 2`).value).toBe('third')
|
||||
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelContextWindow} 2`).value).toBe('3.')
|
||||
})
|
||||
|
||||
it('refuses two models sharing one id', () => {
|
||||
mountCard()
|
||||
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
|
||||
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
|
||||
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
|
||||
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'same' } })
|
||||
fireEvent.change(screen.getByLabelText(`${en.modelId} 2`), { target: { value: 'same' } })
|
||||
|
||||
// The adapter refuses a duplicate outright, so the form must not offer to
|
||||
// write one.
|
||||
expect(screen.getByText(`${en.model} 2: ${en.modelIdDuplicate}`)).toBeTruthy()
|
||||
expect(buttonNamed(en.create).disabled).toBe(true)
|
||||
|
||||
fireEvent.change(screen.getByLabelText(`${en.modelId} 2`), { target: { value: 'other' } })
|
||||
expect(buttonNamed(en.create).disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('creates a model with no capacities, which the route\u2019s fallbacks size', async () => {
|
||||
const { mutate, onClose } = mountCard()
|
||||
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
|
||||
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
|
||||
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'bare' } })
|
||||
|
||||
// A listing that discloses nothing but ids is enough to create a working
|
||||
// provider; the adapter sizes what configuration leaves out.
|
||||
expect(buttonNamed(en.create).disabled).toBe(false)
|
||||
fireEvent.click(screen.getByText(en.create))
|
||||
|
||||
await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) })
|
||||
expect(firstMutate(mutate).ops[0]?.value).toMatchObject({ models: [{ id: 'bare' }] })
|
||||
})
|
||||
|
||||
it('refuses to create until the route, endpoint, and a model are usable', () => {
|
||||
mountCard()
|
||||
expect(buttonNamed(en.create).disabled).toBe(true)
|
||||
|
||||
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'Acme Gateway' } })
|
||||
expect(screen.getByText(en.customRouteInvalid)).toBeTruthy()
|
||||
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'openai' } })
|
||||
expect(screen.getByText(en.customRouteTaken)).toBeTruthy()
|
||||
|
||||
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
|
||||
expect(screen.getByText(en.customNeedsBaseUrl)).toBeTruthy()
|
||||
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
|
||||
expect(screen.getByText(en.customNeedsModels)).toBeTruthy()
|
||||
expect(buttonNamed(en.create).disabled).toBe(true)
|
||||
|
||||
// A model row with no id is not a model.
|
||||
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
|
||||
expect(buttonNamed(en.create).disabled).toBe(true)
|
||||
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } })
|
||||
expect(buttonNamed(en.create).disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('surfaces a refused write and a rejected transport without closing', async () => {
|
||||
const refused = vi.fn(() => Promise.resolve(fail('read-only settings', 'settings-rejected')))
|
||||
const { onClose } = mountCard({ api: { ...scriptedFace({ mutate: refused }).face } as never })
|
||||
|
||||
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
|
||||
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
|
||||
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } })
|
||||
fireEvent.click(screen.getByText(en.create))
|
||||
|
||||
await screen.findByText('read-only settings')
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces a rejected transport during create', async () => {
|
||||
const rejecting = vi.fn(() => Promise.reject(new Error('carrier down')))
|
||||
const { onClose } = mountCard({ api: { ...scriptedFace({ mutate: rejecting }).face } as never })
|
||||
|
||||
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
|
||||
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
|
||||
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } })
|
||||
fireEvent.click(screen.getByText(en.create))
|
||||
|
||||
await screen.findByText('carrier down')
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a stored profile whose key write was refused', async () => {
|
||||
const set = vi.fn(() => Promise.resolve(fail('credential is read-only', 'credential-rejected')))
|
||||
const { onClose } = mountCard({ api: { ...scriptedFace({ set }).face } as never })
|
||||
|
||||
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
|
||||
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
|
||||
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'k' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
|
||||
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } })
|
||||
fireEvent.click(screen.getByText(en.create))
|
||||
|
||||
await screen.findByText('credential is read-only')
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('creates with the chosen protocol and no display name', async () => {
|
||||
const { mutate, onClose } = mountCard()
|
||||
|
||||
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
|
||||
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
|
||||
fireEvent.change(screen.getByLabelText(en.customApi), { target: { value: 'anthropic-messages' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
|
||||
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } })
|
||||
fireEvent.click(screen.getByText(en.create))
|
||||
|
||||
await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) })
|
||||
// No display name configured means none stored; the route id is the name.
|
||||
expect(firstMutate(mutate).ops[0]?.value).toEqual({
|
||||
apiKeyEnv: 'ACME_API_KEY',
|
||||
api: 'anthropic-messages',
|
||||
baseURL: 'https://acme.test/v1',
|
||||
models: [{ id: 'm' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('offers no protocol when the namespace declares none', () => {
|
||||
mountCard({ protocols: [] })
|
||||
expect(screen.getByLabelText<HTMLSelectElement>(en.customApi).value).toBe('')
|
||||
})
|
||||
|
||||
it('closes without writing on cancel, and honors a read-only deployment', () => {
|
||||
const { onClose, mutate } = mountCard()
|
||||
fireEvent.click(screen.getByText(en.cancel))
|
||||
expect(onClose).toHaveBeenCalledWith(false)
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
cleanup()
|
||||
|
||||
mountCard({ readOnly: true })
|
||||
expect(screen.getByLabelText<HTMLInputElement>(en.customRoute).disabled).toBe(true)
|
||||
expect(buttonNamed(en.create).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('closes the create card when an existing row is opened for editing', async () => {
|
||||
await mountSection({ providers: { openai: { baseURL: 'https://proxy.example/v1' } } })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: en.customAdd }))
|
||||
expect(screen.getByText(en.customTitle)).toBeTruthy()
|
||||
|
||||
// Two cards at once would each be closable by the other: whichever one is
|
||||
// dismissed clears the shared state and discards the other's draft.
|
||||
openEditor('openai')
|
||||
expect(screen.queryByText(en.customTitle)).toBeNull()
|
||||
})
|
||||
|
||||
it('reaches the card from the section and returns to the button on cancel', async () => {
|
||||
await mountSection()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: en.customAdd }))
|
||||
expect(screen.getByText(en.customTitle)).toBeTruthy()
|
||||
|
||||
fireEvent.click(screen.getByText(en.cancel))
|
||||
await waitFor(() => { expect(screen.queryByText(en.customTitle)).toBeNull() })
|
||||
expect(screen.getByRole('button', { name: en.customAdd })).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,18 @@
|
||||
/**
|
||||
* Models section stylesheet contract, asserted against the CSS text on disk.
|
||||
*
|
||||
* The section paints in both themes, and a `--dsw-*` name the theme does not
|
||||
* declare fails silently: the browser takes the `var()` fallback, so the sheet
|
||||
* still renders and only the dark theme looks wrong. Checking the names against
|
||||
* the sheet that declares them is what turns that into a test failure.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const css = readFileSync(fileURLToPath(new URL('../src/client/ModelsSection.module.css', import.meta.url)), 'utf8')
|
||||
// The theme package maps `./styles/*` to `./src/styles/*`, so the declarations
|
||||
// stay on the source plane rather than needing a build.
|
||||
const tokens = readFileSync(
|
||||
fileURLToPath(new URL('../../ui-theme/src/styles/design-platform.css', import.meta.url)),
|
||||
'utf8',
|
||||
@@ -35,4 +45,10 @@ describe('ModelsSection theme styles', () => {
|
||||
expect(block('.rowCard')).toContain('border: 1px solid var(--dsw-alias-border-l2)')
|
||||
expect(block('.rowCard')).not.toMatch(/\bbackground\s*:/)
|
||||
})
|
||||
|
||||
it('never falls back to a literal colour', () => {
|
||||
// A token that resolves is never the problem; an undeclared one takes this
|
||||
// branch, and a literal here is a single colour for both themes.
|
||||
expect(css).not.toMatch(/var\(--dsw-[a-z0-9-]+\s*,\s*(?:#|rgb|rgba|hsl|hsla)/)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user