Merge remote-tracking branch 'origin/master' into worktree/provider-credential-lifecycle
# Conflicts: # packages/client/ui-models/README.i18n.yaml # packages/client/ui-models/README.md # packages/client/ui-models/README.zh.md # packages/client/ui-models/src/client/ModelsSection.tsx # packages/client/ui-models/src/client/ProviderEditor.tsx
This commit is contained in:
865
packages/client/ui-models/tests/provider-form.spec.tsx
Normal file
865
packages/client/ui-models/tests/provider-form.spec.tsx
Normal file
@@ -0,0 +1,865 @@
|
||||
// @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('spells a stored capacity back the way it is typed', async () => {
|
||||
await mountSection({
|
||||
providers: {
|
||||
openai: {
|
||||
baseURL: 'https://proxy.example/v1',
|
||||
models: [{ id: 'kept', contextWindow: 1_000_000, maxTokens: 256_000 }],
|
||||
},
|
||||
},
|
||||
})
|
||||
openEditor('openai')
|
||||
expandModel(1)
|
||||
|
||||
// Opening a row reads the stored counts, which are plain integers; showing
|
||||
// them as such would make an already-configured route look unlike one the
|
||||
// user just typed, and re-applying would rewrite the field it read.
|
||||
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelContextWindow} 1`).value).toBe('1M')
|
||||
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelMaxTokens} 1`).value).toBe('256K')
|
||||
})
|
||||
|
||||
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,12 +1,25 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
/**
|
||||
* 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 { readdirSync, 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')
|
||||
const tokens = readFileSync(
|
||||
fileURLToPath(new URL('../../ui-theme/src/styles/design-platform.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.
|
||||
// Every theme sheet, not just the platform tokens: font and scrollbar
|
||||
// variables are declared in siblings, and a gate reading one file would call
|
||||
// their names undeclared.
|
||||
const tokens = readdirSync(fileURLToPath(new URL('../../ui-theme/src/styles/', import.meta.url)))
|
||||
.filter(name => name.endsWith('.css'))
|
||||
.map(name => readFileSync(fileURLToPath(new URL(`../../ui-theme/src/styles/${name}`, import.meta.url)), 'utf8'))
|
||||
.join('\n')
|
||||
|
||||
/** The declarations of one top-level rule, by selector. */
|
||||
function block(selector: string): string {
|
||||
@@ -21,12 +34,24 @@ describe('ModelsSection theme styles', () => {
|
||||
// resolves to whatever literal sits in its fallback slot, which is how this
|
||||
// section stayed light under the dark theme before. Undeclared names have
|
||||
// no fallback at all and inherit, so both spellings must fail here.
|
||||
const named = [...css.matchAll(/var\((--dsw-[a-z0-9-]+)/g)].map(match => match[1])
|
||||
// Every theme-variable prefix the sheets actually use, not just `--dsw-`:
|
||||
// a `--dsh-` name reads as a plausible sibling and would otherwise slip
|
||||
// past this gate into a fallback literal.
|
||||
const named = [...css.matchAll(/var\((--(?:dsw|dsh|ds)-[a-z0-9-]+)/g)].map(match => match[1])
|
||||
const undeclared = [...new Set(named)].filter(name => !tokens.includes(` ${String(name)}:`))
|
||||
expect(undeclared).toEqual([])
|
||||
expect(css).not.toMatch(/var\(--(?:surface|text-|border|accent-strong)/)
|
||||
})
|
||||
|
||||
it('closes every block, so no rule is swallowed by the one above it', () => {
|
||||
// A missing `}` on an `@media` block is not a parse error: every rule after
|
||||
// it silently becomes conditional, and the whole fetch dialog once painted
|
||||
// unstyled for anyone whose system does not ask for reduced motion. Nothing
|
||||
// downstream reports this — the sheet loads and the classes still attach.
|
||||
const bare = css.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
expect((bare.match(/\}/g) ?? []).length).toBe((bare.match(/\{/g) ?? []).length)
|
||||
})
|
||||
|
||||
it('separates the row card from the editor it expands into', () => {
|
||||
// `bg-layer-3` and `bg-module-platform` both resolve to neutral-bluish-800
|
||||
// under the dark theme, so filling the row with either erases the nested
|
||||
@@ -35,4 +60,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