components.spec.tsx 46 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012
  1. // @vitest-environment jsdom
  2. /** Section, setup-card, and hand-written editor behavior over a scripted wire face. */
  3. import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
  4. import { afterEach, describe, expect, it, vi } from 'vitest'
  5. import Schema from 'schemastery'
  6. import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
  7. import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
  8. import { ModelsSection, needsSetup, removeProviderProfile } from '../src/client/ModelsSection.tsx'
  9. import type { ModelsSectionInjected, ModelsSectionProps } from '../src/client/ModelsSection.tsx'
  10. import { pathOps } from '../src/client/ProviderEditor.tsx'
  11. import {
  12. DeepSeekModelsEditor, formatContextWindow, modelDrafts, parseContextWindow, validateDeepSeekModels,
  13. } from '../src/client/DeepSeekModelsEditor.tsx'
  14. import { deriveKeyRef, ModelsSettingsStore } from '../src/client/store.ts'
  15. import type { ProviderRow } from '../src/client/store.ts'
  16. import { en } from '../src/client/locales.ts'
  17. afterEach(cleanup)
  18. const t: ModelsSectionInjected['t'] = key => en[key]
  19. const PiAiConfig = Schema.object({
  20. token: Schema.string().role('secret'),
  21. providers: Schema.dict(Schema.object({
  22. apiKey: Schema.string().role('secret'),
  23. apiKeyEnv: Schema.string().role('credential-ref'),
  24. baseURL: Schema.string(),
  25. reasoning: Schema.union(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']),
  26. headers: Schema.dict(Schema.string()),
  27. })),
  28. })
  29. const DeepSeekConfig = Schema.object({
  30. apiKey: Schema.string().role('secret'),
  31. apiKeyEnv: Schema.string().role('credential-ref'),
  32. baseURL: Schema.string().pattern(/^https:\/\//),
  33. reasoningEffort: Schema.union(['off', 'high', 'max']),
  34. defaultContextWindow: Schema.number().step(1).min(1),
  35. models: Schema.array(Schema.object({
  36. id: Schema.string().required(),
  37. name: Schema.string(),
  38. description: Schema.string(),
  39. contextWindow: Schema.number().step(1).min(1),
  40. // The adapter declares its catalog as a schema default rather than a
  41. // composition entry, which is what the restore-defaults path has to read.
  42. })).default([
  43. {
  44. id: 'deepseek-v4-flash',
  45. name: 'DeepSeek-V4-Flash',
  46. description: '',
  47. contextWindow: 1_000_000,
  48. },
  49. {
  50. id: 'deepseek-v4-pro',
  51. name: 'DeepSeek-V4-Pro',
  52. description: '',
  53. contextWindow: 1_000_000,
  54. },
  55. ]),
  56. })
  57. const DEFAULT_DEEPSEEK_MODELS = [
  58. {
  59. id: 'deepseek-v4-flash',
  60. name: 'DeepSeek-V4-Flash',
  61. description: 'Preserved hidden detail',
  62. contextWindow: 1_000_000,
  63. },
  64. { id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', contextWindow: 1_000_000 },
  65. ]
  66. function wireNamespaces(): SettingsNamespaceView[] {
  67. return [
  68. {
  69. ns: 'llm-deepseek',
  70. schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as unknown,
  71. value: {
  72. apiKeyEnv: 'DEEPSEEK_API_KEY',
  73. baseURL: 'https://base',
  74. reasoningEffort: 'high',
  75. defaultContextWindow: 1_000_000,
  76. models: DEFAULT_DEEPSEEK_MODELS,
  77. },
  78. base: { defaultContextWindow: 1_000_000, models: DEFAULT_DEEPSEEK_MODELS },
  79. user: { reasoningEffort: 'high' },
  80. applies: 'live',
  81. secrets: [{ path: ['apiKey'], set: false }],
  82. revision: 0,
  83. },
  84. {
  85. ns: 'llm-plain',
  86. schema: JSON.parse(JSON.stringify(Schema.object({
  87. profiles: Schema.dict(Schema.object({ note: Schema.string() })),
  88. }).toJSON())) as unknown,
  89. value: {},
  90. applies: 'live',
  91. secrets: [],
  92. revision: 0,
  93. },
  94. {
  95. ns: 'llm-pi-ai',
  96. schema: JSON.parse(JSON.stringify(PiAiConfig.toJSON())) as unknown,
  97. value: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://proxy', headers: { 'X-Team': 'a' } }, zombie: {} } },
  98. user: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://proxy', headers: { 'X-Team': 'a' } }, zombie: {} } },
  99. applies: 'live',
  100. secrets: [{ path: ['token'], set: false }, { path: ['providers', 'openai', 'apiKey'], set: false }],
  101. revision: 0,
  102. },
  103. ]
  104. }
  105. let nextRpc = 0
  106. function ok<T>(value: T): RpcResponse<T> {
  107. return { rpcId: `r-${nextRpc++}` as never, result: { ok: true, value } }
  108. }
  109. function fail<T>(message: string, code = 'settings-rejected'): RpcResponse<T> {
  110. return {
  111. rpcId: `r-${nextRpc++}` as never,
  112. result: { ok: false, error: { code, message, details: { ns: 'x' } } as never },
  113. }
  114. }
  115. function scriptedFace(overrides: {
  116. update?: ReturnType<typeof vi.fn>
  117. replace?: ReturnType<typeof vi.fn>
  118. mutate?: ReturnType<typeof vi.fn>
  119. set?: ReturnType<typeof vi.fn>
  120. } = {}) {
  121. const update = overrides.update ?? vi.fn(() => Promise.resolve(ok(wireNamespaces()[2])))
  122. const replace = overrides.replace ?? vi.fn(() => Promise.resolve(ok(wireNamespaces()[2])))
  123. const mutate = overrides.mutate ?? vi.fn(() => Promise.resolve(ok(wireNamespaces()[2])))
  124. const set = overrides.set ?? vi.fn(() => Promise.resolve(ok({})))
  125. const face = {
  126. llm: {
  127. providers: vi.fn(() => Promise.resolve(ok({
  128. providers: [
  129. { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true },
  130. { provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: true },
  131. { provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false },
  132. { provider: 'zombie', displayName: 'zombie', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'zombie'], active: false },
  133. { provider: 'broken', displayName: 'broken', settingsNs: 'llm-pi-ai', settingsPath: ['nope', 'x'], active: false },
  134. { provider: 'plain', displayName: 'plain', settingsNs: 'llm-plain', settingsPath: ['profiles', 'plain'], active: false },
  135. ],
  136. }))),
  137. models: vi.fn(() => Promise.resolve(ok({ groups: [], failures: [] }))),
  138. },
  139. settings: {
  140. describe: vi.fn(() => Promise.resolve(ok({ writable: true, namespaces: wireNamespaces() }))),
  141. update,
  142. replace,
  143. mutate,
  144. },
  145. credentials: {
  146. describe: vi.fn((payload: { refs: string[] }) => Promise.resolve(ok({
  147. credentials: Object.fromEntries(payload.refs.map(ref => [ref, {
  148. configured: ref === 'OPENAI_API_KEY',
  149. ...ref === 'OPENAI_API_KEY' ? { source: 'file' } : {},
  150. writable: true,
  151. }])),
  152. }))),
  153. set,
  154. unset: vi.fn(() => Promise.resolve(ok({}))),
  155. },
  156. }
  157. return { face, update, replace, mutate, set }
  158. }
  159. type WireFace = ConstructorParameters<typeof ModelsSettingsStore>[0]
  160. async function mountSection(overrides: Parameters<typeof scriptedFace>[0] = {}) {
  161. const { face, update, replace, mutate, set } = scriptedFace(overrides)
  162. const controller = new ModelsSettingsStore(face as unknown as WireFace)
  163. await controller.load()
  164. const injected: ModelsSectionInjected = {
  165. controller,
  166. useSnapshot: bindSnapshotSelector(controller.store),
  167. api: face as never,
  168. t,
  169. }
  170. const view = render(<ModelsSection {...injected} />)
  171. return { view, face, update, replace, mutate, set, controller }
  172. }
  173. describe('ModelsSection', () => {
  174. it('renders nothing before the slot injects its dependencies', () => {
  175. const uninjected = {} as ModelsSectionProps
  176. render(<ModelsSection {...uninjected} />)
  177. expect(document.body.textContent).toBe('')
  178. })
  179. it('renders the unkeyed whole-section provider as an open setup card beside the rows', async () => {
  180. await mountSection()
  181. // DeepSeek has no configured credential and no stored apiKey → setup card.
  182. expect(screen.getByText('DeepSeek')).toBeTruthy()
  183. expect(screen.getByLabelText(en.keyInput)).toBeTruthy()
  184. expect(screen.getByText('openai')).toBeTruthy()
  185. expect(screen.queryByText('Active')).toBeNull()
  186. expect(screen.queryByText('Inactive')).toBeNull()
  187. expect(screen.getByText(en.add)).toBeTruthy()
  188. })
  189. it('turns the setup card into a row once the credential reports configured', async () => {
  190. const { face } = await mountSection()
  191. face.credentials.describe.mockImplementation((payload: { refs: string[] }) => Promise.resolve(ok({
  192. credentials: Object.fromEntries(payload.refs.map(ref => [ref, { configured: true, writable: true }])),
  193. })))
  194. const controller = new ModelsSettingsStore(face as unknown as WireFace)
  195. await controller.load()
  196. cleanup()
  197. render(<ModelsSection
  198. controller={controller}
  199. useSnapshot={bindSnapshotSelector(controller.store)}
  200. api={face as never}
  201. t={t}
  202. />)
  203. // Now a row with an Edit button, not an open card.
  204. expect(screen.getAllByText(en.edit).length).toBeGreaterThan(1)
  205. expect(screen.queryByLabelText(en.keyInput)).toBeNull()
  206. })
  207. it('decides setup need from the joined credential state and literal-key sidecar', () => {
  208. const entry = { provider: 'p', displayName: 'p', settingsNs: 'llm-deepseek', settingsPath: [], active: true }
  209. const row = (
  210. credential: ProviderRow['credential'],
  211. literalApiKeyConfigured = false,
  212. ): ProviderRow => ({
  213. entry,
  214. configured: true,
  215. removable: false,
  216. apiKeyEnv: 'X',
  217. credential,
  218. literalApiKeyConfigured,
  219. })
  220. expect(needsSetup(row(undefined))).toBe(true)
  221. expect(needsSetup(row({ configured: true, writable: true }))).toBe(false)
  222. expect(needsSetup(row(undefined, true))).toBe(false)
  223. const nested = { ...row(undefined), entry: { ...entry, settingsPath: ['providers', 'x'] } }
  224. expect(needsSetup(nested)).toBe(false)
  225. })
  226. it('derives conventional credential references from route ids', () => {
  227. expect(deriveKeyRef('anthropic')).toBe('ANTHROPIC_API_KEY')
  228. expect(deriveKeyRef('minimax-cn')).toBe('MINIMAX_CN_API_KEY')
  229. })
  230. it('names only the fields the card can see, so an unseen secret survives', () => {
  231. // `before` is the REDACTED subtree: a stored literal apiKey is in neither
  232. // side, so no op mentions it and the seam leaves it alone.
  233. expect(pathOps(['providers', 'openai'], { baseURL: 'https://old', reasoning: 'high' }, { reasoning: 'high' }))
  234. .toEqual([{ op: 'unset', path: ['providers', 'openai', 'baseURL'] }])
  235. expect(pathOps([], { b: 1 }, { b: 2, d: 3 }))
  236. .toEqual([{ op: 'set', path: ['b'], value: 2 }, { op: 'set', path: ['d'], value: 3 }])
  237. expect(pathOps([], undefined, {})).toEqual([])
  238. expect(pathOps([], { a: 1 }, { a: 1 })).toEqual([])
  239. })
  240. it('stores a typed key write-only from the setup card without touching settings', async () => {
  241. const { set, update, face } = await mountSection()
  242. const key = screen.getByLabelText<HTMLInputElement>(en.keyInput)
  243. fireEvent.change(key, { target: { value: 'sk-live' } })
  244. fireEvent.click(screen.getByText(en.apply))
  245. await waitFor(() => { expect(set).toHaveBeenCalledWith({ ref: 'DEEPSEEK_API_KEY', value: 'sk-live' }) })
  246. expect(update).not.toHaveBeenCalled()
  247. await waitFor(() => { expect(face.settings.describe.mock.calls.length).toBeGreaterThan(1) })
  248. })
  249. it('applies customized deepseek fields as path ops', async () => {
  250. const { mutate } = await mountSection({
  251. mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
  252. })
  253. fireEvent.click(screen.getByText(en.customized))
  254. const baseURL = screen.getByLabelText<HTMLInputElement>(en.baseUrl)
  255. // The deepseek placeholder is pinned to the public endpoint, not the
  256. // effective value (which may reflect a launch-environment override).
  257. expect(baseURL.placeholder).toBe('https://api.deepseek.com')
  258. fireEvent.change(baseURL, { target: { value: 'https://next2' } })
  259. fireEvent.click(screen.getByText(en.apply))
  260. await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
  261. // Only the field that actually changed: reasoningEffort was already
  262. // 'high' in the loaded profile, so it produces no op.
  263. expect(mutate.mock.calls[0]?.[0]).toEqual({
  264. ns: 'llm-deepseek',
  265. ops: [{ op: 'set', path: ['baseURL'], value: 'https://next2' }],
  266. expectedRevision: 0,
  267. })
  268. })
  269. it('materializes inherited models and adds an arbitrary DeepSeek id', async () => {
  270. const { mutate } = await mountSection({
  271. mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
  272. })
  273. fireEvent.click(screen.getByText(en.customized))
  274. expect(screen.getByText(en.modelsInherited)).toBeTruthy()
  275. expect(screen.getAllByLabelText(new RegExp(en.modelId)).map(input => (input as HTMLInputElement).value))
  276. .toEqual(['deepseek-v4-flash', 'deepseek-v4-pro'])
  277. fireEvent.click(screen.getByText(en.addModel))
  278. const ids = screen.getAllByLabelText(new RegExp(en.modelId))
  279. const names = screen.getAllByLabelText(new RegExp(en.modelName))
  280. const windows = screen.getAllByLabelText(new RegExp(en.contextWindow))
  281. fireEvent.change(ids[2] as HTMLInputElement, { target: { value: 'private-preview' } })
  282. fireEvent.change(names[2] as HTMLInputElement, { target: { value: 'Private Preview' } })
  283. fireEvent.change(windows[2] as HTMLInputElement, { target: { value: '131072' } })
  284. fireEvent.click(screen.getByText(en.apply))
  285. await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
  286. expect(mutate.mock.calls[0]?.[0]).toEqual({
  287. ns: 'llm-deepseek',
  288. ops: [{
  289. op: 'set',
  290. path: ['models'],
  291. value: [
  292. ...DEFAULT_DEEPSEEK_MODELS,
  293. { id: 'private-preview', name: 'Private Preview', contextWindow: 131_072 },
  294. ],
  295. }],
  296. expectedRevision: 0,
  297. })
  298. })
  299. it('rejects duplicate DeepSeek model ids before writing', async () => {
  300. const { mutate } = await mountSection()
  301. fireEvent.click(screen.getByText(en.customized))
  302. fireEvent.click(screen.getByText(en.addModel))
  303. const ids = screen.getAllByLabelText(new RegExp(en.modelId))
  304. fireEvent.change(ids[2] as HTMLInputElement, { target: { value: 'deepseek-v4-flash' } })
  305. fireEvent.click(screen.getByText(en.apply))
  306. await screen.findByText(`Model 3: ${en.modelIdDuplicate}`)
  307. expect(mutate).not.toHaveBeenCalled()
  308. })
  309. it('validates every adapter-owned model catalog invariant', () => {
  310. expect(modelDrafts(undefined)).toEqual([])
  311. expect(modelDrafts([null, 'bad', { id: 'ok' }])).toEqual([{}, {}, { id: 'ok' }])
  312. expect(validateDeepSeekModels([{}])).toEqual({ index: 0, key: 'modelIdRequired' })
  313. expect(validateDeepSeekModels([{ id: 'same' }, { id: 'same' }]))
  314. .toEqual({ index: 1, key: 'modelIdDuplicate' })
  315. expect(validateDeepSeekModels([{ id: 'model', name: '' }]))
  316. .toEqual({ index: 0, key: 'modelNameInvalid' })
  317. expect(validateDeepSeekModels([{ id: 'model', contextWindow: null }]))
  318. .toEqual({ index: 0, key: 'modelContextInvalid' })
  319. expect(validateDeepSeekModels([{ id: 'model', contextWindow: 1.5 }]))
  320. .toEqual({ index: 0, key: 'modelContextInvalid' })
  321. expect(validateDeepSeekModels([{ id: 'model', contextWindow: 0 }]))
  322. .toEqual({ index: 0, key: 'modelContextInvalid' })
  323. expect(validateDeepSeekModels([{ id: 'model', contextWindow: 1 }])).toBeUndefined()
  324. })
  325. it('reads context windows written as counts, thousands, or millions', () => {
  326. expect(parseContextWindow('')).toBeUndefined()
  327. expect(parseContextWindow(' ')).toBeUndefined()
  328. expect(parseContextWindow('131072')).toBe(131_072)
  329. expect(parseContextWindow(' 256K ')).toBe(256_000)
  330. expect(parseContextWindow('256k')).toBe(256_000)
  331. expect(parseContextWindow('1M')).toBe(1_000_000)
  332. expect(parseContextWindow('1m')).toBe(1_000_000)
  333. // 1M is 1000K, not 1024K: capacities are quoted in decimal.
  334. expect(parseContextWindow('1M')).toBe(parseContextWindow('1000K'))
  335. // 2.3 * 1e6 is a few ULPs high in binary floating point; an integral
  336. // intent must not become a fractional count the validator rejects.
  337. expect(parseContextWindow('2.3M')).toBe(2_300_000)
  338. expect(Number.isInteger(parseContextWindow('1.5M'))).toBe(true)
  339. // A genuinely fractional count survives as one, for the validator to reject.
  340. expect(parseContextWindow('0.0001K')).toBeCloseTo(0.1)
  341. expect(parseContextWindow('abc')).toBeNaN()
  342. expect(parseContextWindow('1G')).toBeNaN()
  343. expect(parseContextWindow('1M1')).toBeNaN()
  344. })
  345. it('spells a stored count in the shortest form that round-trips', () => {
  346. expect(formatContextWindow(1_000_000)).toBe('1M')
  347. expect(formatContextWindow(256_000)).toBe('256K')
  348. expect(formatContextWindow(1_500_000)).toBe('1500K')
  349. expect(formatContextWindow(131_072)).toBe('131072')
  350. // Values the validator will reject are shown as-is rather than dressed up.
  351. expect(formatContextWindow(Number.NaN)).toBe('NaN')
  352. expect(formatContextWindow(0)).toBe('0')
  353. for (const text of ['1M', '256K', '131072', '1500K']) {
  354. expect(formatContextWindow(parseContextWindow(text) as number)).toBe(text)
  355. }
  356. })
  357. it('accepts a suffixed context window and stores the plain count', async () => {
  358. const { mutate } = await mountSection({
  359. mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
  360. })
  361. fireEvent.click(screen.getByText(en.customized))
  362. const windows = screen.getAllByLabelText<HTMLInputElement>(new RegExp(en.contextWindow))
  363. // The inherited 1000000 reads back short.
  364. expect((windows[0] as HTMLInputElement).value).toBe('1M')
  365. // Keystrokes stay verbatim while the row has focus, so typing `1000` does
  366. // not rewrite itself to `1K` mid-word.
  367. fireEvent.change(windows[0] as HTMLInputElement, { target: { value: '1000' } })
  368. expect((windows[0] as HTMLInputElement).value).toBe('1000')
  369. fireEvent.change(windows[0] as HTMLInputElement, { target: { value: '1000K' } })
  370. expect((windows[0] as HTMLInputElement).value).toBe('1000K')
  371. // Blur settles the row to the canonical spelling of the same count.
  372. fireEvent.blur(windows[0] as HTMLInputElement)
  373. expect((windows[0] as HTMLInputElement).value).toBe('1M')
  374. fireEvent.change(windows[1] as HTMLInputElement, { target: { value: '256K' } })
  375. fireEvent.blur(windows[1] as HTMLInputElement)
  376. fireEvent.click(screen.getByText(en.apply))
  377. await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
  378. expect(mutate.mock.calls[0]?.[0]).toEqual({
  379. ns: 'llm-deepseek',
  380. ops: [{
  381. op: 'set',
  382. path: ['models'],
  383. value: [
  384. { ...DEFAULT_DEEPSEEK_MODELS[0], contextWindow: 1_000_000 },
  385. { ...DEFAULT_DEEPSEEK_MODELS[1], contextWindow: 256_000 },
  386. ],
  387. }],
  388. expectedRevision: 0,
  389. })
  390. })
  391. it('keeps unreadable context-window text on screen and refuses the write', async () => {
  392. const { mutate } = await mountSection()
  393. fireEvent.click(screen.getByText(en.customized))
  394. const windows = screen.getAllByLabelText<HTMLInputElement>(new RegExp(en.contextWindow))
  395. fireEvent.change(windows[0] as HTMLInputElement, { target: { value: '1 gazillion' } })
  396. // Blurring a row that is not the edited one leaves the buffer alone.
  397. fireEvent.blur(windows[1] as HTMLInputElement)
  398. fireEvent.blur(windows[0] as HTMLInputElement)
  399. // The text the user typed is still there to correct.
  400. expect((windows[0] as HTMLInputElement).value).toBe('1 gazillion')
  401. fireEvent.click(screen.getByText(en.apply))
  402. await screen.findByText(`Model 1: ${en.modelContextInvalid}`)
  403. expect(mutate).not.toHaveBeenCalled()
  404. })
  405. it.each([
  406. ['the schema default', undefined],
  407. ['the composition entry', { models: [{ id: 'pinned-by-deployment' }] }],
  408. ])('restores %s the moment the override is dropped, not after a reload', async (_label, base) => {
  409. // The regression: reset read the EFFECTIVE value, which still carries the
  410. // stored override until the unset is applied — so the rows did not change
  411. // and the catalog only looked restored after reopening the card.
  412. const { face } = scriptedFace()
  413. const stored = { models: [{ id: 'user-only-model', name: 'User Only' }] }
  414. const overridden: SettingsNamespaceView = {
  415. ns: 'llm-deepseek',
  416. schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as unknown,
  417. value: { ...stored, defaultContextWindow: 1_000_000 },
  418. ...base === undefined ? {} : { base },
  419. user: stored,
  420. applies: 'live',
  421. secrets: [],
  422. revision: 0,
  423. }
  424. const { ProviderEditor } = await import('../src/client/ProviderEditor.tsx')
  425. render(<ProviderEditor
  426. provider="deepseek-official"
  427. displayName="DeepSeek"
  428. namespace={overridden}
  429. settingsPath={[]}
  430. api={face as never}
  431. t={t}
  432. readOnly={false}
  433. onClose={() => {}}
  434. />)
  435. fireEvent.click(screen.getByText(en.customized))
  436. expect(screen.getByText(en.modelsCustomized)).toBeTruthy()
  437. expect(screen.getAllByLabelText(new RegExp(en.modelId)).map(input => (input as HTMLInputElement).value))
  438. .toEqual(['user-only-model'])
  439. fireEvent.click(screen.getByText(en.resetModels))
  440. expect(screen.getByText(en.modelsInherited)).toBeTruthy()
  441. expect(screen.getAllByLabelText(new RegExp(en.modelId)).map(input => (input as HTMLInputElement).value))
  442. .toEqual(base === undefined ? ['deepseek-v4-flash', 'deepseek-v4-pro'] : ['pinned-by-deployment'])
  443. })
  444. it('keeps every row\'s unreadable text, not just the last one edited', async () => {
  445. // The regression: one active buffer meant editing a second row displaced
  446. // the first, which then fell back to rendering its stored NaN as `NaN` —
  447. // losing the text the user was told they could still correct.
  448. await mountSection()
  449. fireEvent.click(screen.getByText(en.customized))
  450. const windows = screen.getAllByLabelText<HTMLInputElement>(new RegExp(en.contextWindow))
  451. fireEvent.change(windows[0] as HTMLInputElement, { target: { value: 'not a number' } })
  452. fireEvent.blur(windows[0] as HTMLInputElement)
  453. fireEvent.change(windows[1] as HTMLInputElement, { target: { value: '2M' } })
  454. expect((windows[0] as HTMLInputElement).value).toBe('not a number')
  455. expect((windows[1] as HTMLInputElement).value).toBe('2M')
  456. })
  457. it('re-keys the typed text around a removed row', async () => {
  458. await mountSection()
  459. fireEvent.click(screen.getByText(en.customized))
  460. const windows = (): HTMLInputElement[] =>
  461. screen.getAllByLabelText<HTMLInputElement>(new RegExp(en.contextWindow))
  462. const removeRow = (at: number): void => {
  463. fireEvent.click(screen.getAllByText(en.removeModel)[at] as HTMLElement)
  464. }
  465. // Three rows, with text parked on the outer two.
  466. fireEvent.click(screen.getByText(en.addModel))
  467. fireEvent.change(windows()[0] as HTMLInputElement, { target: { value: 'top text' } })
  468. fireEvent.blur(windows()[0] as HTMLInputElement)
  469. fireEvent.change(windows()[2] as HTMLInputElement, { target: { value: 'bottom text' } })
  470. fireEvent.blur(windows()[2] as HTMLInputElement)
  471. // Dropping the middle row leaves the row above untouched and carries the
  472. // row below down with its own text, rather than stranding it.
  473. removeRow(1)
  474. expect(windows()).toHaveLength(2)
  475. expect((windows()[0] as HTMLInputElement).value).toBe('top text')
  476. expect((windows()[1] as HTMLInputElement).value).toBe('bottom text')
  477. // Dropping a row that holds text takes that text with it; the survivor
  478. // keeps its own rather than inheriting the deleted row's.
  479. removeRow(0)
  480. expect(windows()).toHaveLength(1)
  481. expect((windows()[0] as HTMLInputElement).value).toBe('bottom text')
  482. })
  483. it('drops the typed text when reset replaces the rows it annotated', async () => {
  484. // The regression: reset removed the override but left the buffer, so an
  485. // inherited row displayed text no settings layer stores — and because an
  486. // unreadable buffer never settles, it stayed there indefinitely.
  487. const { mutate } = await mountSection({
  488. mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
  489. })
  490. fireEvent.click(screen.getByText(en.customized))
  491. const windows = screen.getAllByLabelText<HTMLInputElement>(new RegExp(en.contextWindow))
  492. fireEvent.change(windows[0] as HTMLInputElement, { target: { value: 'garbage' } })
  493. fireEvent.blur(windows[0] as HTMLInputElement)
  494. fireEvent.click(screen.getByText(en.resetModels))
  495. const restored = screen.getAllByLabelText<HTMLInputElement>(new RegExp(en.contextWindow))
  496. expect((restored[0] as HTMLInputElement).value).toBe('1M')
  497. // Reset put the draft back where it started, so Apply writes nothing at
  498. // all rather than persisting whatever the stale text had parsed to.
  499. fireEvent.click(screen.getByText(en.apply))
  500. await waitFor(() => { expect(screen.getByText(en.apply)).toBeTruthy() })
  501. expect(mutate).not.toHaveBeenCalled()
  502. })
  503. it('settles a pasted id and refuses whitespace that would never match', async () => {
  504. await mountSection()
  505. fireEvent.click(screen.getByText(en.customized))
  506. const ids = screen.getAllByLabelText<HTMLInputElement>(new RegExp(en.modelId))
  507. fireEvent.change(ids[0] as HTMLInputElement, { target: { value: ' deepseek-v4-flash ' } })
  508. fireEvent.blur(ids[0] as HTMLInputElement)
  509. expect((ids[0] as HTMLInputElement).value).toBe('deepseek-v4-flash')
  510. // A settled id needs no second trim.
  511. fireEvent.blur(ids[0] as HTMLInputElement)
  512. expect((ids[0] as HTMLInputElement).value).toBe('deepseek-v4-flash')
  513. // An id that is only whitespace is as absent as an empty one, and a padded
  514. // id no longer slips past the duplicate check against its own twin.
  515. expect(validateDeepSeekModels([{ id: ' ' }])).toEqual({ index: 0, key: 'modelIdRequired' })
  516. expect(validateDeepSeekModels([{ id: 'model' }, { id: 'model ' }]))
  517. .toEqual({ index: 1, key: 'modelIdDuplicate' })
  518. })
  519. it('renders malformed draft fallbacks without inventing catalog values', () => {
  520. render(<DeepSeekModelsEditor
  521. models={[{}]}
  522. overridden={false}
  523. defaultContextWindow={undefined}
  524. t={t}
  525. disabled={true}
  526. onChange={vi.fn()}
  527. onReset={vi.fn()}
  528. />)
  529. expect(screen.getByLabelText<HTMLInputElement>(`${en.modelId} 1`).value).toBe('')
  530. expect(screen.getByLabelText<HTMLInputElement>(`${en.contextWindow} 1`).placeholder)
  531. .toBe(en.contextWindowPlaceholder)
  532. })
  533. it('can empty and reset the model override, then clear optional fields without dropping hidden data', async () => {
  534. const { mutate } = await mountSection({
  535. mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
  536. })
  537. fireEvent.click(screen.getByText(en.customized))
  538. fireEvent.click(screen.getAllByText(en.removeModel)[0] as HTMLElement)
  539. fireEvent.click(screen.getByText(en.removeModel))
  540. expect(screen.getByText(en.modelsEmpty)).toBeTruthy()
  541. fireEvent.click(screen.getByText(en.resetModels))
  542. expect(screen.getByText(en.modelsInherited)).toBeTruthy()
  543. const names = screen.getAllByLabelText(new RegExp(en.modelName))
  544. const windows = screen.getAllByLabelText(new RegExp(en.contextWindow))
  545. fireEvent.change(names[0] as HTMLInputElement, { target: { value: '' } })
  546. fireEvent.change(windows[0] as HTMLInputElement, { target: { value: '' } })
  547. fireEvent.click(screen.getByText(en.apply))
  548. await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
  549. expect(mutate.mock.calls[0]?.[0]).toEqual({
  550. ns: 'llm-deepseek',
  551. ops: [{
  552. op: 'set',
  553. path: ['models'],
  554. value: [
  555. { id: 'deepseek-v4-flash', description: 'Preserved hidden detail' },
  556. DEFAULT_DEEPSEEK_MODELS[1],
  557. ],
  558. }],
  559. expectedRevision: 0,
  560. })
  561. })
  562. it('clears an inherited override with an unset op, never a whole-section replace', async () => {
  563. // The data-loss shape: the old path rebuilt the section from the REDACTED
  564. // user layer and replaced it wholesale, deleting any stored literal key.
  565. const { replace, update, mutate } = await mountSection()
  566. fireEvent.click(screen.getByText(en.customized))
  567. const effort = screen.getByLabelText<HTMLSelectElement>(en.effort)
  568. expect(effort.value).toBe('high')
  569. fireEvent.change(effort, { target: { value: '' } })
  570. fireEvent.click(screen.getByText(en.apply))
  571. await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
  572. expect(replace).not.toHaveBeenCalled()
  573. expect(update).not.toHaveBeenCalled()
  574. expect(mutate.mock.calls[0]?.[0]).toEqual({
  575. ns: 'llm-deepseek',
  576. ops: [{ op: 'unset', path: ['reasoningEffort'] }],
  577. expectedRevision: 0,
  578. })
  579. })
  580. it('pins the deepseek placeholder and clears typed input back to inherited', async () => {
  581. const { face } = scriptedFace()
  582. const bare: SettingsNamespaceView = {
  583. ns: 'llm-deepseek',
  584. schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as unknown,
  585. value: {},
  586. applies: 'live',
  587. secrets: [],
  588. revision: 0,
  589. }
  590. const { ProviderEditor } = await import('../src/client/ProviderEditor.tsx')
  591. render(<ProviderEditor
  592. provider="deepseek-official"
  593. displayName="DeepSeek"
  594. namespace={bare}
  595. settingsPath={[]}
  596. api={face as never}
  597. t={t}
  598. readOnly={false}
  599. onClose={() => {}}
  600. />)
  601. fireEvent.click(screen.getByText(en.customized))
  602. const baseURL = screen.getByLabelText<HTMLInputElement>(en.baseUrl)
  603. expect(baseURL.placeholder).toBe('https://api.deepseek.com')
  604. fireEvent.change(baseURL, { target: { value: 'https://x' } })
  605. expect(baseURL.value).toBe('https://x')
  606. fireEvent.change(baseURL, { target: { value: '' } })
  607. expect(baseURL.value).toBe('')
  608. })
  609. it('rejects an invalid draft before writing', async () => {
  610. const { update } = await mountSection()
  611. fireEvent.click(screen.getByText(en.customized))
  612. fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'not-a-url' } })
  613. fireEvent.click(screen.getByText(en.apply))
  614. await screen.findByText(/baseURL/)
  615. expect(update).not.toHaveBeenCalled()
  616. })
  617. it('edits a pi-ai profile with the curated fields only', async () => {
  618. const { mutate } = await mountSection()
  619. fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement)
  620. // The configured credential shows as the stored placeholder.
  621. const keys = await screen.findAllByLabelText<HTMLInputElement>(en.keyInput)
  622. const editorKey = keys[keys.length - 1] as HTMLInputElement
  623. await waitFor(() => { expect(editorKey.placeholder).toBe(en.keyStored) })
  624. // pi-ai carries Base URL too: the stored override shows as the value and
  625. // the effective profile endpoint as its placeholder source.
  626. fireEvent.click(screen.getAllByText(en.customized)[1] as HTMLElement)
  627. const urls = screen.getAllByLabelText<HTMLInputElement>(en.baseUrl)
  628. expect(urls).toHaveLength(2)
  629. expect((urls[1] as HTMLInputElement).value).toBe('https://proxy')
  630. const effort = screen.getAllByLabelText<HTMLSelectElement>(en.effort)
  631. fireEvent.change(effort[effort.length - 1] as HTMLSelectElement, { target: { value: 'xhigh' } })
  632. fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
  633. await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
  634. // Only the edited field travels: apiKeyEnv, baseURL and headers were
  635. // already stored with these values, so no op restates them — and the
  636. // profile's stored literal apiKey, absent from the redacted view the card
  637. // read, is named by nothing at all.
  638. expect(mutate.mock.calls[0]?.[0]).toEqual({
  639. ns: 'llm-pi-ai',
  640. ops: [{ op: 'set', path: ['providers', 'openai', 'reasoning'], value: 'xhigh' }],
  641. expectedRevision: 0,
  642. })
  643. })
  644. it('adds a dormant provider with a derived reference and stores its key', async () => {
  645. const { mutate, set } = await mountSection()
  646. fireEvent.click(screen.getByText(en.add))
  647. const pick = await screen.findByLabelText<HTMLSelectElement>(en.provider)
  648. expect([...pick.options].map(option => option.value)).toEqual(['anthropic', 'broken', 'plain'])
  649. expect(pick.value).toBe('anthropic')
  650. // A dormant profile has no endpoint anywhere: the pi-ai placeholder
  651. // falls back to the provider-default wording.
  652. fireEvent.click(screen.getAllByText(en.customized)[1] as HTMLElement)
  653. const urls = screen.getAllByLabelText<HTMLInputElement>(en.baseUrl)
  654. expect((urls[1] as HTMLInputElement).placeholder).toBe(en.baseUrlDefault)
  655. const keys = screen.getAllByLabelText<HTMLInputElement>(en.keyInput)
  656. const addKey = keys[keys.length - 1] as HTMLInputElement
  657. fireEvent.change(addKey, { target: { value: 'sk-ant' } })
  658. fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
  659. await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
  660. expect(mutate.mock.calls[0]?.[0]).toEqual({
  661. ns: 'llm-pi-ai',
  662. ops: [{ op: 'set', path: ['providers', 'anthropic', 'apiKeyEnv'], value: 'ANTHROPIC_API_KEY' }],
  663. expectedRevision: 0,
  664. })
  665. await waitFor(() => { expect(set).toHaveBeenCalledWith({ ref: 'ANTHROPIC_API_KEY', value: 'sk-ant' }) })
  666. })
  667. it('switches the add card target and degrades unknown or broken targets loudly', async () => {
  668. await mountSection()
  669. fireEvent.click(screen.getByText(en.add))
  670. const pick = await screen.findByLabelText<HTMLSelectElement>(en.provider)
  671. fireEvent.change(pick, { target: { value: 'broken' } })
  672. await screen.findByText(/unresolvable settings path/)
  673. fireEvent.change(pick, { target: { value: 'plain' } })
  674. await waitFor(() => {
  675. expect(screen.getAllByText(content => content.includes(en.advancedHint)).length).toBeGreaterThan(0)
  676. })
  677. // The hint-only card cannot apply anything.
  678. const applies = screen.getAllByText<HTMLButtonElement>(en.apply)
  679. expect((applies[applies.length - 1] as HTMLButtonElement).disabled).toBe(true)
  680. expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1)
  681. })
  682. it('surfaces a rejected settings write and never stores the key after it', async () => {
  683. const { set } = await mountSection({
  684. mutate: vi.fn(() => Promise.resolve(fail('llm-pi-ai: unknown pi-ai provider "bogus"'))),
  685. })
  686. fireEvent.click(screen.getByText(en.add))
  687. await screen.findByLabelText(en.provider)
  688. const keys = screen.getAllByLabelText<HTMLInputElement>(en.keyInput)
  689. fireEvent.change(keys[keys.length - 1] as HTMLInputElement, { target: { value: 'sk-x' } })
  690. fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
  691. await screen.findByText(/unknown pi-ai provider/)
  692. expect(set).not.toHaveBeenCalled()
  693. })
  694. it('renders the card without the stored-key hint when the credential probe rejects', async () => {
  695. // The probe is a placeholder hint, not a precondition: an escaping
  696. // rejection would surface in the browser as an unhandled rejection.
  697. const { face } = scriptedFace()
  698. face.credentials.describe = vi.fn(() => Promise.reject(new Error('connection lost')))
  699. const unhandled = vi.fn()
  700. process.on('unhandledRejection', unhandled)
  701. try {
  702. const controller = new ModelsSettingsStore(face as unknown as WireFace)
  703. await controller.load()
  704. render(<ModelsSection
  705. controller={controller}
  706. useSnapshot={bindSnapshotSelector(controller.store)}
  707. api={face as never}
  708. t={t}
  709. />)
  710. const key = await screen.findByLabelText<HTMLInputElement>(en.keyInput)
  711. expect(key.placeholder).toBe(en.keyPlaceholder)
  712. await new Promise(resolve => setTimeout(resolve, 10))
  713. expect(unhandled).not.toHaveBeenCalled()
  714. } finally {
  715. process.off('unhandledRejection', unhandled)
  716. }
  717. })
  718. it('tells the user to reopen when another writer moved the namespace first', async () => {
  719. // The stale-draft overwrite: two tabs open the same card, the other saves,
  720. // and this one must be refused rather than replay its opening snapshot.
  721. const { set } = await mountSection({
  722. mutate: vi.fn(() => Promise.resolve(fail('changed since it was read', 'settings-conflict'))),
  723. })
  724. fireEvent.click(screen.getByText(en.customized))
  725. fireEvent.change(screen.getByLabelText<HTMLInputElement>(en.baseUrl), { target: { value: 'https://mine' } })
  726. fireEvent.click(screen.getByText(en.apply))
  727. await screen.findByText(en.conflict)
  728. expect(set).not.toHaveBeenCalled()
  729. })
  730. it('keeps the card usable when the write rejects instead of answering', async () => {
  731. // A transport failure (disconnect, or the 403 a non-loopback browser now
  732. // gets on the whole configuration plane) rejects rather than returning a
  733. // failed envelope: without a catch the card would stay busy forever.
  734. await mountSection({ mutate: vi.fn(() => Promise.reject(new Error('connection lost'))) })
  735. fireEvent.click(screen.getByText(en.customized))
  736. fireEvent.change(screen.getByLabelText<HTMLInputElement>(en.baseUrl), { target: { value: 'https://next' } })
  737. fireEvent.click(screen.getByText(en.apply))
  738. await screen.findByText('connection lost')
  739. // Not stuck in `applying…`: the finally cleared busy, so Apply is live again.
  740. expect(screen.getByText(en.apply)).toBeTruthy()
  741. })
  742. it('surfaces a shadowed credential write on the card', async () => {
  743. await mountSection({
  744. set: vi.fn(() => Promise.resolve(fail('credentials: DEEPSEEK_API_KEY is shadowed by the read-only environment', 'credential-rejected'))),
  745. })
  746. const key = screen.getByLabelText<HTMLInputElement>(en.keyInput)
  747. fireEvent.change(key, { target: { value: 'sk-live' } })
  748. fireEvent.click(screen.getByText(en.apply))
  749. await screen.findByText(/shadowed by the read-only environment/)
  750. })
  751. it('locks the key input when the launch environment provides the credential', async () => {
  752. const { face } = await mountSection()
  753. face.credentials.describe.mockImplementation((payload: { refs: string[] }) => Promise.resolve(ok({
  754. credentials: Object.fromEntries(payload.refs.map(ref => [ref, {
  755. configured: ref === 'OPENAI_API_KEY', source: 'env', writable: false,
  756. }])),
  757. })))
  758. fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement)
  759. const keys = await screen.findAllByLabelText<HTMLInputElement>(en.keyInput)
  760. const editorKey = keys[keys.length - 1] as HTMLInputElement
  761. await waitFor(() => { expect(editorKey.placeholder).toBe(en.keyEnvLocked) })
  762. expect(editorKey.disabled).toBe(true)
  763. })
  764. it('keeps a failed credential describe silent and the input usable', async () => {
  765. const { face, set } = await mountSection()
  766. face.credentials.describe.mockImplementation(() => Promise.resolve(fail('down', 'internal')) as never)
  767. fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement)
  768. const keys = await screen.findAllByLabelText<HTMLInputElement>(en.keyInput)
  769. const editorKey = keys[keys.length - 1] as HTMLInputElement
  770. expect(editorKey.placeholder).toBe(en.keyPlaceholder)
  771. fireEvent.change(editorKey, { target: { value: 'sk-live' } })
  772. fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
  773. await waitFor(() => { expect(set).toHaveBeenCalledTimes(1) })
  774. })
  775. it('requires confirmation before removing a user-added provider', async () => {
  776. const { replace, mutate } = await mountSection()
  777. fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
  778. const dialog = screen.getByRole('dialog', { name: en.deleteTitle })
  779. expect(dialog.textContent).toContain(en.deleteDescription)
  780. expect(document.activeElement).toBe(within(dialog).getByRole('button', { name: en.cancel }))
  781. expect(mutate).not.toHaveBeenCalled()
  782. fireEvent.click(within(dialog).getByRole('button', { name: en.cancel }))
  783. expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull()
  784. expect(mutate).not.toHaveBeenCalled()
  785. fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
  786. fireEvent.click(within(screen.getByRole('dialog', { name: en.deleteTitle }))
  787. .getByRole('button', { name: en.close }))
  788. expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull()
  789. expect(mutate).not.toHaveBeenCalled()
  790. fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
  791. fireEvent.click(within(screen.getByRole('dialog', { name: en.deleteTitle }))
  792. .getByRole('button', { name: en.deleteConfirm }))
  793. await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
  794. expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull()
  795. expect(replace).not.toHaveBeenCalled()
  796. expect(mutate.mock.calls[0]?.[0]).toEqual({
  797. ns: 'llm-pi-ai',
  798. ops: [{ op: 'unset', path: ['providers', 'openai'] }],
  799. })
  800. })
  801. it('blocks duplicate deletion while the confirmed removal is pending', async () => {
  802. let resolveRemoval!: (response: RpcResponse<SettingsNamespaceView>) => void
  803. const mutate = vi.fn(() => new Promise<RpcResponse<SettingsNamespaceView>>((resolve) => {
  804. resolveRemoval = resolve
  805. }))
  806. await mountSection({ mutate })
  807. fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
  808. const dialog = screen.getByRole('dialog', { name: en.deleteTitle })
  809. const confirm = within(dialog).getByRole<HTMLButtonElement>('button', { name: en.deleteConfirm })
  810. fireEvent.click(confirm)
  811. fireEvent.click(confirm)
  812. expect(mutate).toHaveBeenCalledOnce()
  813. expect(confirm.disabled).toBe(true)
  814. expect(within(dialog).getByRole<HTMLButtonElement>('button', { name: en.cancel }).disabled).toBe(true)
  815. expect(within(dialog).getByRole('button', { name: en.deleting })).toBe(confirm)
  816. fireEvent.click(within(dialog).getByRole('button', { name: en.close }))
  817. expect(screen.getByRole('dialog', { name: en.deleteTitle })).toBe(dialog)
  818. expect(mutate).toHaveBeenCalledOnce()
  819. await act(async () => { resolveRemoval(ok(wireNamespaces()[2]!)) })
  820. await waitFor(() => { expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull() })
  821. })
  822. it('renders the load failure with a retry control', async () => {
  823. const face = scriptedFace()
  824. face.face.llm.providers = vi.fn(() => Promise.resolve(fail('directory down', 'internal'))) as never
  825. const controller = new ModelsSettingsStore(face.face as unknown as WireFace)
  826. await controller.load()
  827. render(<ModelsSection
  828. controller={controller}
  829. useSnapshot={bindSnapshotSelector(controller.store)}
  830. api={face.face as never}
  831. t={t}
  832. />)
  833. expect(screen.getByText(/directory down/)).toBeTruthy()
  834. fireEvent.click(screen.getByText(en.retry))
  835. await waitFor(() => { expect(screen.queryByText(/directory down/)).toBeNull() })
  836. })
  837. it('shows the read-only notice and disables mutations for a read-only provider', async () => {
  838. const { face } = await mountSection()
  839. face.settings.describe.mockImplementation(() => Promise.resolve(ok({
  840. writable: false,
  841. namespaces: wireNamespaces(),
  842. })))
  843. const controller = new ModelsSettingsStore(face as unknown as WireFace)
  844. await controller.load()
  845. cleanup()
  846. render(<ModelsSection
  847. controller={controller}
  848. useSnapshot={bindSnapshotSelector(controller.store)}
  849. api={face as never}
  850. t={t}
  851. />)
  852. expect(screen.getByText(en.readOnly)).toBeTruthy()
  853. expect(screen.getAllByText<HTMLButtonElement>(en.remove).every(button => button.disabled)).toBe(true)
  854. expect(screen.getByText<HTMLButtonElement>(en.add).disabled).toBe(true)
  855. })
  856. it('toggles the row editor closed on a second edit click and on cancel', async () => {
  857. const { update } = await mountSection()
  858. const edit = screen.getAllByText(en.edit)[0] as HTMLElement
  859. fireEvent.click(edit)
  860. await waitFor(() => { expect(screen.getAllByLabelText(en.keyInput).length).toBe(2) })
  861. fireEvent.click(edit)
  862. expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1)
  863. fireEvent.click(edit)
  864. await waitFor(() => { expect(screen.getAllByLabelText(en.keyInput).length).toBe(2) })
  865. fireEvent.click(screen.getAllByText(en.cancel)[1] as HTMLElement)
  866. expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1)
  867. expect(update).not.toHaveBeenCalled()
  868. })
  869. it('cancels the add card back to the add button', async () => {
  870. await mountSection()
  871. fireEvent.click(screen.getByText(en.add))
  872. await screen.findByLabelText(en.provider)
  873. fireEvent.click(screen.getAllByText(en.cancel)[1] as HTMLElement)
  874. await screen.findByText(en.add)
  875. expect(screen.queryByLabelText(en.provider)).toBeNull()
  876. })
  877. it('loads on first render of an idle controller', async () => {
  878. const { face } = scriptedFace()
  879. const controller = new ModelsSettingsStore(face as unknown as WireFace)
  880. render(<ModelsSection
  881. controller={controller}
  882. useSnapshot={bindSnapshotSelector(controller.store)}
  883. api={face as never}
  884. t={t}
  885. />)
  886. await screen.findByText('DeepSeek')
  887. })
  888. it('removes by unsetting the profile path, never by rebuilding the section', async () => {
  889. // The section rebuild is what dropped stored literal secrets: this page
  890. // only ever holds the redacted descriptor, so the removal names the path.
  891. const { face, mutate, replace, controller } = await mountSection()
  892. await removeProviderProfile(
  893. face as unknown as Parameters<typeof removeProviderProfile>[0],
  894. controller,
  895. { settingsNs: 'llm-plain', settingsPath: ['ghost-profile'] },
  896. )
  897. expect(mutate.mock.calls[0]?.[0]).toEqual({
  898. ns: 'llm-plain',
  899. ops: [{ op: 'unset', path: ['ghost-profile'] }],
  900. })
  901. expect(replace).not.toHaveBeenCalled()
  902. })
  903. it('keeps the snapshot untouched and reports the message when a removal write is refused', async () => {
  904. const { face, controller } = await mountSection({
  905. mutate: vi.fn(() => Promise.resolve(fail('read-only'))),
  906. })
  907. const before = controller.store.getSnapshot().rows
  908. const failure = await removeProviderProfile(
  909. face as unknown as Parameters<typeof removeProviderProfile>[0],
  910. controller,
  911. { settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'] },
  912. )
  913. expect(failure).toBe('read-only')
  914. expect(controller.store.getSnapshot().rows).toBe(before)
  915. })
  916. it('shows a failed removal on the page banner, including a non-Error rejection', async () => {
  917. // The whole click path: the row's Remove button, the transport rejecting
  918. // with a non-Error value, and the store surfacing it where a load failure
  919. // would appear — rather than the row silently staying put.
  920. await mountSection({ mutate: vi.fn(() => Promise.reject(new Error('the host refused'))) })
  921. fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
  922. fireEvent.click(within(screen.getByRole('dialog', { name: en.deleteTitle }))
  923. .getByRole('button', { name: en.deleteConfirm }))
  924. await screen.findByText(`${en.loadFailed}: the host refused`)
  925. })
  926. it('reports a transport rejection instead of failing the removal silently', async () => {
  927. const { face, controller } = await mountSection({
  928. mutate: vi.fn(() => Promise.reject(new Error('connection lost'))),
  929. })
  930. const failure = await removeProviderProfile(
  931. face as unknown as Parameters<typeof removeProviderProfile>[0],
  932. controller,
  933. { settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'] },
  934. )
  935. expect(failure).toBe('connection lost')
  936. })
  937. })