stores.client.spec.ts 43 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160
  1. /**
  2. * The staged card form: what a draft shows before it is written, which wire
  3. * call a save reaches, and what happens to drafts the Host did not accept.
  4. */
  5. import { describe, expect, it, vi } from 'vitest'
  6. import type { SettingsPathOpView } from '@deepseek-ai/dsh-api-remotes/client'
  7. import { RemoteError, stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
  8. import { CardForm, numberField, textField } from '../src/client/card-form.ts'
  9. import { AgentLoopCardController, type AgentLoopSettings } from '../src/client/agent-loop-card-controller.ts'
  10. import { BashCardController, type BashSettings } from '../src/client/bash-card-controller.ts'
  11. import {
  12. SettingsDescribeMirror, type SettingsMirrorSnapshot,
  13. } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts'
  14. import { ConfigurablePluginsTabController } from '../src/client/tab-store.ts'
  15. import {
  16. SubagentModelSelectionCardController,
  17. subagentModelCandidates,
  18. type SubagentModelSelectionSettings,
  19. } from '../src/client/subagent-model-selection-card-controller.ts'
  20. import { WebSearchCardController, type WebSearchSettings } from '../src/client/web-search-card-controller.ts'
  21. /** Make the stub behave like a Host that accepts every write. */
  22. function acceptWrites<T>(host: StubSettingsScope<T>): void {
  23. const section = (): Record<string, unknown> => ({ ...host.scope.getSnapshot().value as object })
  24. const layer = (): Record<string, unknown> => ({ ...host.scope.getSnapshot().user as object })
  25. host.set.mockImplementation((field: string, value: unknown) => {
  26. host.publish({ value: { ...section(), [field]: value } as T, user: { ...layer(), [field]: value } })
  27. })
  28. host.mutate.mockImplementation((ops: readonly SettingsPathOpView[]) => {
  29. const value = { ...section() }
  30. const user = { ...layer() }
  31. for (const op of ops) {
  32. const field = op.path[0]!
  33. if (op.op === 'set') {
  34. value[field] = op.value
  35. user[field] = op.value
  36. }
  37. }
  38. host.publish({ value: value as T, user })
  39. })
  40. host.unset.mockImplementation((field: string) => {
  41. const user = Object.fromEntries(Object.entries(layer()).filter(([key]) => key !== field))
  42. const base = host.scope.getSnapshot().base as Record<string, unknown> | undefined
  43. host.publish({ value: { ...section(), [field]: base?.[field] } as T, user })
  44. })
  45. }
  46. /** The card plugin's context, scripted down to the namespaces a card reaches. */
  47. function ctxWith(namespaces: object) {
  48. return { remote: namespaces } as never
  49. }
  50. function credentialsApi(configured: boolean) {
  51. const describe = vi.fn(() => Promise.resolve({
  52. ok: true as const,
  53. value: { DEEPSEEK_API_KEY: { configured, writable: true } },
  54. }))
  55. const set = vi.fn(() => Promise.resolve({ ok: true as const, value: undefined }))
  56. return { ctx: ctxWith({ credentials: { describe, set } }), describe, set }
  57. }
  58. function modelsApi(options: {
  59. groups?: readonly {
  60. id: string
  61. name: string
  62. models: readonly { id: string; name: string }[]
  63. }[]
  64. failures?: readonly { id: string; name: string; message: string }[]
  65. error?: string
  66. } = {}) {
  67. const models = vi.fn(() => Promise.resolve({
  68. ...(options.error === undefined
  69. ? { ok: true as const, value: { groups: options.groups ?? [], failures: options.failures ?? [] } }
  70. : { ok: false as const, error: new RemoteError('gateway/internal', options.error, {}) }),
  71. }))
  72. return { ctx: ctxWith({ session: { modelCatalog: models } }), models }
  73. }
  74. function deferred<T>() {
  75. let resolve!: (value: T) => void
  76. let reject!: (error: unknown) => void
  77. const promise = new Promise<T>((accept, fail) => {
  78. resolve = accept
  79. reject = fail
  80. })
  81. return { promise, resolve, reject }
  82. }
  83. describe('CardForm', () => {
  84. function form() {
  85. const host = stubSettingsScope<Record<string, unknown>>()
  86. const subject = new CardForm(host.scope, [numberField('timeoutMs'), textField('baseURL')])
  87. host.publish({
  88. status: 'ready',
  89. writable: true,
  90. value: { timeoutMs: 60_000, baseURL: 'https://search.test/v1' },
  91. base: { timeoutMs: 60_000, baseURL: 'https://search.test/v1' },
  92. user: {},
  93. })
  94. return { host, subject }
  95. }
  96. it('shows the effective value and stays clean until something is staged', () => {
  97. const { subject } = form()
  98. expect(subject.field('timeoutMs')).toEqual({ text: '60000', overridden: false, invalid: false })
  99. expect(subject.shell()).toMatchObject({ available: true, writable: true, dirty: false, invalid: false })
  100. })
  101. it('marks a field the user layer carries as overridden', () => {
  102. const { host, subject } = form()
  103. host.publish({ value: { timeoutMs: 60_000 }, user: { timeoutMs: 60_000 } })
  104. // An override equal to the composition default is still an override.
  105. expect(subject.field('timeoutMs').overridden).toBe(true)
  106. })
  107. it('writes nothing until the form is saved', async () => {
  108. const { host, subject } = form()
  109. acceptWrites(host)
  110. subject.actions().edit('timeoutMs', '9000')
  111. expect(subject.field('timeoutMs')).toEqual({ text: '9000', overridden: true, invalid: false })
  112. expect(subject.shell().dirty).toBe(true)
  113. expect(host.set).not.toHaveBeenCalled()
  114. await subject.save()
  115. expect(host.set.mock.calls).toEqual([['timeoutMs', 9_000]])
  116. expect(subject.shell()).toMatchObject({ dirty: false, failed: false, saving: false })
  117. })
  118. it('drops a draft that settles back on the value already shown', async () => {
  119. const { host, subject } = form()
  120. subject.actions().edit('timeoutMs', '9000')
  121. subject.actions().edit('timeoutMs', '60000')
  122. expect(subject.shell().dirty).toBe(false)
  123. await subject.save()
  124. expect(host.set).not.toHaveBeenCalled()
  125. })
  126. it('refuses to save while a draft is not a value the field accepts', async () => {
  127. const { host, subject } = form()
  128. subject.actions().edit('timeoutMs', 'soon')
  129. expect(subject.field('timeoutMs')).toEqual({ text: 'soon', overridden: false, invalid: true })
  130. expect(subject.shell()).toMatchObject({ dirty: true, invalid: true })
  131. await subject.save()
  132. expect(host.set).not.toHaveBeenCalled()
  133. expect(subject.field('timeoutMs').text).toBe('soon')
  134. })
  135. it('stages a reset that clears the field only once saved', async () => {
  136. const { host, subject } = form()
  137. acceptWrites(host)
  138. host.publish({ value: { timeoutMs: 9_000 }, user: { timeoutMs: 9_000 } })
  139. subject.actions().resetField('timeoutMs')
  140. // The badge previews the save: the field will no longer be overridden.
  141. expect(subject.field('timeoutMs')).toEqual({ text: '60000', overridden: false, invalid: false })
  142. expect(host.unset).not.toHaveBeenCalled()
  143. await subject.save()
  144. expect(host.unset.mock.calls).toEqual([['timeoutMs']])
  145. expect(subject.shell()).toMatchObject({ dirty: false, failed: false })
  146. })
  147. it('treats resetting an inherited field as no change at all', async () => {
  148. const { host, subject } = form()
  149. subject.actions().resetField('timeoutMs')
  150. expect(subject.shell().dirty).toBe(false)
  151. await subject.save()
  152. expect(host.unset).not.toHaveBeenCalled()
  153. })
  154. it('clears a number field by emptying it', async () => {
  155. const { host, subject } = form()
  156. acceptWrites(host)
  157. host.publish({ user: { timeoutMs: 9_000 } })
  158. subject.actions().edit('timeoutMs', '')
  159. expect(subject.field('timeoutMs')).toEqual({ text: '', overridden: false, invalid: false })
  160. await subject.save()
  161. expect(host.unset.mock.calls).toEqual([['timeoutMs']])
  162. })
  163. it('clears a text field by emptying it', async () => {
  164. const { host, subject } = form()
  165. acceptWrites(host)
  166. host.publish({ user: { baseURL: 'https://search.test/v1' } })
  167. subject.actions().edit('baseURL', ' ')
  168. await subject.save()
  169. expect(host.unset.mock.calls).toEqual([['baseURL']])
  170. })
  171. it('writes the trimmed text of a text field', async () => {
  172. const { host, subject } = form()
  173. acceptWrites(host)
  174. subject.actions().edit('baseURL', ' https://other.test ')
  175. await subject.save()
  176. expect(host.set.mock.calls).toEqual([['baseURL', 'https://other.test']])
  177. })
  178. it('keeps the drafts a save did not land, and reports the failure', async () => {
  179. const { host, subject } = form()
  180. subject.actions().edit('timeoutMs', '9000')
  181. await subject.save()
  182. // The stub Host accepted the call without storing it, exactly as a
  183. // validator that refuses the value does.
  184. expect(host.set).toHaveBeenCalledWith('timeoutMs', 9_000)
  185. expect(subject.shell()).toMatchObject({ dirty: true, failed: true, saving: false })
  186. expect(subject.field('timeoutMs').text).toBe('9000')
  187. })
  188. it('reports a reset the Host did not apply as a failure', async () => {
  189. const { host, subject } = form()
  190. host.publish({ user: { timeoutMs: 9_000 } })
  191. subject.actions().resetField('timeoutMs')
  192. await subject.save()
  193. expect(host.unset).toHaveBeenCalledWith('timeoutMs')
  194. expect(subject.shell().failed).toBe(true)
  195. })
  196. it('clears the failure as soon as the user edits again', async () => {
  197. const { subject } = form()
  198. subject.actions().edit('timeoutMs', '9000')
  199. await subject.save()
  200. expect(subject.shell().failed).toBe(true)
  201. subject.actions().edit('timeoutMs', '9001')
  202. expect(subject.shell().failed).toBe(false)
  203. })
  204. it('discards every staged edit', async () => {
  205. const { host, subject } = form()
  206. subject.actions().edit('timeoutMs', '9000')
  207. subject.actions().discard()
  208. expect(subject.field('timeoutMs').text).toBe('60000')
  209. expect(subject.shell()).toMatchObject({ dirty: false, failed: false })
  210. // A discard with nothing staged publishes nothing.
  211. const before = subject.shell()
  212. subject.actions().discard()
  213. expect(subject.shell()).toEqual(before)
  214. await subject.save()
  215. expect(host.set).not.toHaveBeenCalled()
  216. })
  217. it('refuses a second save while one is in flight', async () => {
  218. const { host, subject } = form()
  219. acceptWrites(host)
  220. subject.actions().edit('timeoutMs', '9000')
  221. const first = subject.save()
  222. expect(subject.shell().saving).toBe(true)
  223. const second = subject.save()
  224. await Promise.all([first, second])
  225. expect(host.set).toHaveBeenCalledTimes(1)
  226. })
  227. it('publishes a projection whenever the scope or a draft changes', () => {
  228. const { host, subject } = form()
  229. const store = subject.bind(() => subject.field('timeoutMs').text)
  230. expect(store.getSnapshot()).toBe('60000')
  231. host.publish({ value: { timeoutMs: 1_000 } })
  232. expect(store.getSnapshot()).toBe('1000')
  233. subject.actions().edit('timeoutMs', '2000')
  234. expect(store.getSnapshot()).toBe('2000')
  235. })
  236. it('refuses to address a field the card never declared', () => {
  237. const { subject } = form()
  238. expect(() => subject.field('nope')).toThrow('plugin card has no field nope')
  239. })
  240. it('renders an absent section value as an empty draft', () => {
  241. const host = stubSettingsScope<Record<string, unknown>>()
  242. const subject = new CardForm(host.scope, [numberField('timeoutMs'), textField('baseURL')])
  243. host.publish({ status: 'ready', writable: true, value: {}, base: {}, user: undefined })
  244. expect(subject.field('timeoutMs').text).toBe('')
  245. expect(subject.field('baseURL').text).toBe('')
  246. expect(subject.shell().available).toBe(true)
  247. })
  248. it('stays unavailable while the namespace is not served', () => {
  249. const host = stubSettingsScope<Record<string, unknown>>()
  250. const subject = new CardForm(host.scope, [numberField('timeoutMs')])
  251. host.publish({ status: 'unavailable' })
  252. expect(subject.shell()).toMatchObject({ available: false, writable: false })
  253. })
  254. })
  255. describe('BashCardController', () => {
  256. it('projects both fields and saves them in one write pass', async () => {
  257. const host = stubSettingsScope<BashSettings>()
  258. acceptWrites(host)
  259. const controller = new BashCardController(host.scope)
  260. host.publish({
  261. status: 'ready',
  262. writable: true,
  263. value: { timeoutMs: 5_000, maxOutputBytes: 64_000 },
  264. base: { timeoutMs: 60_000, maxOutputBytes: 64_000 },
  265. user: { timeoutMs: 5_000 },
  266. })
  267. const face = controller.inject()
  268. expect(face.hooks.bashCard.getSnapshot()).toMatchObject({
  269. available: true,
  270. writable: true,
  271. dirty: false,
  272. timeoutMs: { text: '5000', overridden: true },
  273. maxOutputBytes: { text: '64000', overridden: false },
  274. })
  275. face.edit('timeoutMs', '9000')
  276. face.edit('maxOutputBytes', '1024')
  277. expect(face.hooks.bashCard.getSnapshot().dirty).toBe(true)
  278. face.save()
  279. await vi.waitFor(() => { expect(host.set).toHaveBeenCalledTimes(2) })
  280. expect(host.set.mock.calls).toEqual([['timeoutMs', 9_000], ['maxOutputBytes', 1_024]])
  281. expect(face.hooks.bashCard.getSnapshot().dirty).toBe(false)
  282. })
  283. it('stages a reset and applies it on save', async () => {
  284. const host = stubSettingsScope<BashSettings>()
  285. acceptWrites(host)
  286. const controller = new BashCardController(host.scope)
  287. host.publish({
  288. status: 'ready',
  289. writable: true,
  290. value: { timeoutMs: 5_000 },
  291. base: { timeoutMs: 60_000 },
  292. user: { timeoutMs: 5_000 },
  293. })
  294. const face = controller.inject()
  295. face.resetField('timeoutMs')
  296. expect(face.hooks.bashCard.getSnapshot().timeoutMs.text).toBe('60000')
  297. face.save()
  298. await vi.waitFor(() => { expect(host.unset).toHaveBeenCalledWith('timeoutMs') })
  299. expect(face.hooks.bashCard.getSnapshot()).toMatchObject({
  300. dirty: false,
  301. timeoutMs: { text: '60000', overridden: false },
  302. })
  303. })
  304. it('discards staged edits without writing', () => {
  305. const host = stubSettingsScope<BashSettings>()
  306. const controller = new BashCardController(host.scope)
  307. host.publish({ status: 'ready', writable: true, value: { timeoutMs: 5_000 }, user: {} })
  308. const face = controller.inject()
  309. face.edit('timeoutMs', '9000')
  310. face.discard()
  311. expect(face.hooks.bashCard.getSnapshot().timeoutMs.text).toBe('5000')
  312. expect(host.set).not.toHaveBeenCalled()
  313. })
  314. })
  315. describe('AgentLoopCardController', () => {
  316. it('saves the only field it owns', async () => {
  317. const host = stubSettingsScope<AgentLoopSettings>()
  318. acceptWrites(host)
  319. const controller = new AgentLoopCardController(host.scope)
  320. host.publish({
  321. status: 'ready',
  322. writable: true,
  323. value: { maxParallelToolCalls: 10 },
  324. base: { maxParallelToolCalls: 10 },
  325. user: {},
  326. })
  327. const face = controller.inject()
  328. face.edit('maxParallelToolCalls', '4')
  329. face.save()
  330. await vi.waitFor(() => { expect(host.set).toHaveBeenCalledWith('maxParallelToolCalls', 4) })
  331. expect(face.hooks.agentLoopCard.getSnapshot()).toMatchObject({
  332. dirty: false,
  333. maxParallelToolCalls: { text: '4', overridden: true },
  334. })
  335. })
  336. it('reports a read-only document so the card can disable its controls', () => {
  337. const host = stubSettingsScope<AgentLoopSettings>()
  338. const controller = new AgentLoopCardController(host.scope)
  339. host.publish({ status: 'ready', writable: false, value: { maxParallelToolCalls: 10 } })
  340. expect(controller.inject().hooks.agentLoopCard.getSnapshot().writable).toBe(false)
  341. })
  342. })
  343. describe('SubagentModelSelectionCardController', () => {
  344. it('joins stored routes with the live catalog without dropping unavailable choices', () => {
  345. const candidates = subagentModelCandidates(
  346. [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }],
  347. [{ provider: 'legacy', model: 'old' }],
  348. new Set(['legacy\0old']),
  349. )
  350. expect(candidates).toEqual([
  351. {
  352. key: 'alpha\0fast', provider: 'alpha', model: 'fast', providerName: 'Alpha API',
  353. modelName: 'Fast', available: true, selected: false,
  354. },
  355. {
  356. key: 'legacy\0old', provider: 'legacy', model: 'old', providerName: 'legacy',
  357. modelName: 'old', available: false, selected: true,
  358. },
  359. ])
  360. })
  361. it('loads adapter models and saves the switch and routes atomically', async () => {
  362. const host = stubSettingsScope<SubagentModelSelectionSettings>()
  363. acceptWrites(host)
  364. const models = modelsApi({
  365. groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }],
  366. })
  367. const controller = new SubagentModelSelectionCardController(host.scope, models.ctx)
  368. host.publish({
  369. status: 'ready', writable: true, revision: 3,
  370. value: { enabled: false, allowedModels: [] }, user: {},
  371. })
  372. const face = controller.inject()
  373. expect(face.hooks.subagentModelSelectionCard.getSnapshot().enabled).toBe(false)
  374. face.toggleEnabled()
  375. await vi.waitFor(() => {
  376. expect(face.hooks.subagentModelSelectionCard.getSnapshot().candidates).toHaveLength(1)
  377. })
  378. face.toggleModel('alpha\0fast')
  379. face.save()
  380. await vi.waitFor(() => {
  381. expect(host.mutate).toHaveBeenCalledWith([
  382. { op: 'set', path: ['enabled'], value: true },
  383. { op: 'set', path: ['allowedModels'], value: [{ provider: 'alpha', model: 'fast' }] },
  384. ], 3)
  385. })
  386. expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({
  387. enabled: true,
  388. dirty: false,
  389. saving: false,
  390. failed: false,
  391. })
  392. })
  393. it('starts an empty draft when a ready test scope has no decoded value', () => {
  394. const host = stubSettingsScope<SubagentModelSelectionSettings>()
  395. const controller = new SubagentModelSelectionCardController(host.scope, modelsApi().ctx)
  396. host.publish({ status: 'ready', writable: true, revision: 0, value: undefined })
  397. const face = controller.inject()
  398. face.toggleEnabled()
  399. expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({
  400. enabled: true, dirty: true, invalid: true,
  401. })
  402. })
  403. it('keeps the Host value and reports a rejected write', async () => {
  404. const host = stubSettingsScope<SubagentModelSelectionSettings>()
  405. const models = modelsApi({
  406. groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }],
  407. })
  408. const controller = new SubagentModelSelectionCardController(host.scope, models.ctx)
  409. host.publish({ status: 'ready', writable: true, value: { enabled: false, allowedModels: [] }, user: {} })
  410. const face = controller.inject()
  411. face.toggleEnabled()
  412. await vi.waitFor(() => {
  413. expect(face.hooks.subagentModelSelectionCard.getSnapshot().candidates).toHaveLength(1)
  414. })
  415. face.toggleModel('alpha\0fast')
  416. face.save()
  417. await vi.waitFor(() => {
  418. expect(face.hooks.subagentModelSelectionCard.getSnapshot().failed).toBe(true)
  419. })
  420. expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({
  421. enabled: true,
  422. dirty: true,
  423. saving: false,
  424. })
  425. })
  426. it('loads stored routes, stages removal and disablement, and discards both', async () => {
  427. const host = stubSettingsScope<SubagentModelSelectionSettings>()
  428. const models = modelsApi({
  429. groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }],
  430. failures: [{ id: 'beta', name: 'Beta', message: 'offline' }],
  431. })
  432. const controller = new SubagentModelSelectionCardController(host.scope, models.ctx)
  433. host.publish({
  434. status: 'ready', writable: true, revision: 5,
  435. value: { enabled: true, allowedModels: [{ provider: 'alpha', model: 'fast' }] }, user: {},
  436. })
  437. const face = controller.inject()
  438. const state = () => face.hooks.subagentModelSelectionCard.getSnapshot()
  439. await vi.waitFor(() => { expect(state().catalogStatus).toBe('ready') })
  440. expect(state().catalogPartial).toBe(true)
  441. face.toggleModel('missing')
  442. expect(state().dirty).toBe(false)
  443. face.toggleModel('alpha\0fast')
  444. expect(state()).toMatchObject({ dirty: true, invalid: true })
  445. face.discard()
  446. expect(state()).toMatchObject({ dirty: false, invalid: false, enabled: true })
  447. face.toggleEnabled()
  448. expect(state()).toMatchObject({ dirty: true, enabled: false })
  449. face.toggleEnabled()
  450. expect(state()).toMatchObject({ dirty: false, enabled: true })
  451. })
  452. it('retains selected routes when disabling and loads an already-ready enabled card', async () => {
  453. const host = stubSettingsScope<SubagentModelSelectionSettings>()
  454. acceptWrites(host)
  455. host.publish({
  456. status: 'ready', writable: true, revision: 5,
  457. value: { enabled: true, allowedModels: [{ provider: 'alpha', model: 'fast' }] }, user: {},
  458. })
  459. const models = modelsApi({
  460. groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }],
  461. })
  462. const controller = new SubagentModelSelectionCardController(host.scope, models.ctx)
  463. const face = controller.inject()
  464. await vi.waitFor(() => { expect(models.models).toHaveBeenCalledOnce() })
  465. face.toggleEnabled()
  466. face.save()
  467. await vi.waitFor(() => {
  468. expect(host.mutate).toHaveBeenCalledWith([
  469. { op: 'set', path: ['enabled'], value: false },
  470. { op: 'set', path: ['allowedModels'], value: [{ provider: 'alpha', model: 'fast' }] },
  471. ], 5)
  472. })
  473. expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({
  474. enabled: false, dirty: false,
  475. })
  476. })
  477. it('reports a directory error and retries it', async () => {
  478. const host = stubSettingsScope<SubagentModelSelectionSettings>()
  479. const models = modelsApi({ error: 'offline' })
  480. const controller = new SubagentModelSelectionCardController(host.scope, models.ctx)
  481. host.publish({ status: 'ready', writable: true, value: { enabled: false, allowedModels: [] }, user: {} })
  482. const face = controller.inject()
  483. const state = () => face.hooks.subagentModelSelectionCard.getSnapshot()
  484. face.toggleEnabled()
  485. await vi.waitFor(() => { expect(state().catalogStatus).toBe('error') })
  486. face.retryCatalog()
  487. await vi.waitFor(() => { expect(models.models).toHaveBeenCalledTimes(2) })
  488. })
  489. it('rejects a draft after the Host revision changes', async () => {
  490. const host = stubSettingsScope<SubagentModelSelectionSettings>()
  491. const models = modelsApi({
  492. groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }],
  493. })
  494. const controller = new SubagentModelSelectionCardController(host.scope, models.ctx)
  495. host.publish({
  496. status: 'ready', writable: true, revision: 4,
  497. value: { enabled: false, allowedModels: [] }, user: {},
  498. })
  499. const face = controller.inject()
  500. face.toggleEnabled()
  501. await vi.waitFor(() => {
  502. expect(face.hooks.subagentModelSelectionCard.getSnapshot().candidates).toHaveLength(1)
  503. })
  504. face.toggleModel('alpha\0fast')
  505. host.publish({
  506. revision: 5,
  507. value: { enabled: true, allowedModels: [{ provider: 'other', model: 'new' }] },
  508. })
  509. expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({
  510. conflicted: true, failed: false, dirty: true,
  511. })
  512. face.save()
  513. await Promise.resolve()
  514. expect(host.mutate).not.toHaveBeenCalled()
  515. face.discard()
  516. expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({
  517. conflicted: false, failed: false, dirty: false, enabled: true,
  518. })
  519. })
  520. it('settles a draft when a newer Host revision already contains it', async () => {
  521. const host = stubSettingsScope<SubagentModelSelectionSettings>()
  522. const models = modelsApi({
  523. groups: [{ id: 'alpha', name: 'Alpha', models: [{ id: 'fast', name: 'Fast' }] }],
  524. })
  525. const controller = new SubagentModelSelectionCardController(host.scope, models.ctx)
  526. host.publish({
  527. status: 'ready', writable: true, revision: 4,
  528. value: { enabled: false, allowedModels: [] }, user: {},
  529. })
  530. const face = controller.inject()
  531. face.toggleEnabled()
  532. await vi.waitFor(() => { expect(face.hooks.subagentModelSelectionCard.getSnapshot().candidates).toHaveLength(1) })
  533. face.toggleModel('alpha\0fast')
  534. host.publish({
  535. revision: 5,
  536. value: { enabled: true, allowedModels: [{ provider: 'alpha', model: 'fast' }] },
  537. })
  538. expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({
  539. conflicted: false, dirty: false, enabled: true,
  540. })
  541. })
  542. it('retains unsaved routes across a catalog refresh', async () => {
  543. const host = stubSettingsScope<SubagentModelSelectionSettings>()
  544. acceptWrites(host)
  545. host.publish({
  546. status: 'ready', writable: true, revision: 2,
  547. value: { enabled: false, allowedModels: [] }, user: {},
  548. })
  549. const refreshed = deferred<never>()
  550. const models = vi.fn()
  551. .mockResolvedValueOnce({
  552. ok: true, value: {
  553. groups: [{ id: 'alpha', name: 'Alpha', models: [{ id: 'fast', name: 'Fast' }] }],
  554. failures: [],
  555. },
  556. })
  557. .mockImplementationOnce(() => refreshed.promise)
  558. const controller = new SubagentModelSelectionCardController(
  559. host.scope, ctxWith({ session: { modelCatalog: models } }),
  560. )
  561. const face = controller.inject()
  562. const state = () => face.hooks.subagentModelSelectionCard.getSnapshot()
  563. face.toggleEnabled()
  564. await vi.waitFor(() => { expect(state().candidates).toHaveLength(1) })
  565. face.toggleModel('alpha\0fast')
  566. controller.refreshCatalog()
  567. expect(state()).toMatchObject({
  568. catalogStatus: 'loading',
  569. candidates: [expect.objectContaining({ key: 'alpha\0fast', selected: true })],
  570. })
  571. refreshed.resolve({
  572. ok: true, value: { groups: [], failures: [] },
  573. } as never)
  574. await vi.waitFor(() => { expect(state().catalogStatus).toBe('ready') })
  575. expect(state().candidates).toEqual([
  576. expect.objectContaining({ key: 'alpha\0fast', available: false, selected: true }),
  577. ])
  578. face.save()
  579. await vi.waitFor(() => {
  580. expect(host.mutate).toHaveBeenCalledWith([
  581. { op: 'set', path: ['enabled'], value: true },
  582. { op: 'set', path: ['allowedModels'], value: [{ provider: 'alpha', model: 'fast' }] },
  583. ], 2)
  584. })
  585. })
  586. it('drops a draft when the connection generation changes', async () => {
  587. const host = stubSettingsScope<SubagentModelSelectionSettings>()
  588. const models = modelsApi({
  589. groups: [{ id: 'alpha', name: 'Alpha', models: [{ id: 'fast', name: 'Fast' }] }],
  590. })
  591. host.publish({
  592. status: 'ready', writable: true, revision: 4,
  593. value: { enabled: false, allowedModels: [] }, user: {},
  594. })
  595. const controller = new SubagentModelSelectionCardController(host.scope, models.ctx)
  596. const face = controller.inject()
  597. face.toggleEnabled()
  598. await vi.waitFor(() => { expect(face.hooks.subagentModelSelectionCard.getSnapshot().candidates).toHaveLength(1) })
  599. face.toggleModel('alpha\0fast')
  600. controller.resetConnection()
  601. host.publish({
  602. revision: 4,
  603. value: { enabled: true, allowedModels: [{ provider: 'other', model: 'new' }] },
  604. })
  605. expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({
  606. conflicted: false, dirty: false, enabled: true,
  607. })
  608. face.save()
  609. await Promise.resolve()
  610. expect(host.mutate).not.toHaveBeenCalled()
  611. })
  612. it('reloads the model catalog after invalidation', async () => {
  613. const host = stubSettingsScope<SubagentModelSelectionSettings>()
  614. host.publish({
  615. status: 'ready', writable: true, revision: 1,
  616. value: { enabled: true, allowedModels: [] }, user: {},
  617. })
  618. const models = vi.fn()
  619. .mockResolvedValueOnce({
  620. ok: true, value: {
  621. groups: [{ id: 'alpha', name: 'Alpha', models: [{ id: 'fast', name: 'Fast' }] }],
  622. failures: [],
  623. },
  624. })
  625. .mockResolvedValueOnce({
  626. ok: true, value: {
  627. groups: [{ id: 'beta', name: 'Beta', models: [{ id: 'new', name: 'New' }] }],
  628. failures: [],
  629. },
  630. })
  631. const controller = new SubagentModelSelectionCardController(
  632. host.scope, ctxWith({ session: { modelCatalog: models } }),
  633. )
  634. const state = () => controller.inject().hooks.subagentModelSelectionCard.getSnapshot()
  635. await vi.waitFor(() => { expect(state().candidates[0]?.provider).toBe('alpha') })
  636. controller.refreshCatalog()
  637. await vi.waitFor(() => { expect(state().candidates[0]?.provider).toBe('beta') })
  638. expect(models).toHaveBeenCalledTimes(2)
  639. })
  640. it('suppresses duplicate actions and late save settlements', async () => {
  641. const host = stubSettingsScope<SubagentModelSelectionSettings>()
  642. const catalog = modelsApi({
  643. groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }],
  644. })
  645. const write = deferred<undefined>()
  646. const mutate = vi.fn(async (ops: readonly SettingsPathOpView[]) => {
  647. await write.promise
  648. const enabled = ops.find(op => op.path[0] === 'enabled')
  649. const allowedModels = ops.find(op => op.path[0] === 'allowedModels')
  650. host.publish({ value: {
  651. enabled: enabled?.op === 'set' ? enabled.value as boolean : false,
  652. allowedModels: allowedModels?.op === 'set' ? allowedModels.value as never[] : [],
  653. } })
  654. })
  655. const controller = new SubagentModelSelectionCardController({ ...host.scope, mutate }, catalog.ctx)
  656. const face = controller.inject()
  657. face.save()
  658. face.toggleModel('alpha\0fast')
  659. host.publish({ status: 'ready', writable: true, value: { enabled: false, allowedModels: [] }, user: {} })
  660. face.save()
  661. face.toggleEnabled()
  662. await vi.waitFor(() => { expect(face.hooks.subagentModelSelectionCard.getSnapshot().catalogStatus).toBe('ready') })
  663. face.save()
  664. face.toggleModel('alpha\0fast')
  665. face.save()
  666. expect(face.hooks.subagentModelSelectionCard.getSnapshot().saving).toBe(true)
  667. face.toggleEnabled()
  668. face.toggleModel('alpha\0fast')
  669. face.save()
  670. face.discard()
  671. controller.dispose()
  672. write.resolve(undefined)
  673. await write.promise
  674. expect(mutate).toHaveBeenCalledOnce()
  675. })
  676. it('suppresses duplicate directory loads and late settlements', async () => {
  677. const host = stubSettingsScope<SubagentModelSelectionSettings>()
  678. host.publish({ status: 'ready', writable: true, value: { enabled: false, allowedModels: [] }, user: {} })
  679. const pending = deferred<never>()
  680. const models = vi.fn(() => pending.promise)
  681. const controller = new SubagentModelSelectionCardController(host.scope, ctxWith({ session: { modelCatalog: models } }))
  682. const face = controller.inject()
  683. face.toggleEnabled()
  684. face.retryCatalog()
  685. expect(models).toHaveBeenCalledOnce()
  686. controller.dispose()
  687. pending.resolve({ ok: false, error: new RemoteError('gateway/internal', 'late failure', {}) } as never)
  688. await pending.promise
  689. const pendingResolve = deferred<never>()
  690. const resolving = new SubagentModelSelectionCardController(
  691. host.scope,
  692. ctxWith({ session: { modelCatalog: () => pendingResolve.promise } }),
  693. )
  694. const resolvingFace = resolving.inject()
  695. resolvingFace.toggleEnabled()
  696. resolving.dispose()
  697. pendingResolve.resolve({
  698. ok: true, value: { groups: [], failures: [] },
  699. } as never)
  700. await pendingResolve.promise
  701. })
  702. it('ignores writes while read-only and scope notifications after disposal', () => {
  703. const host = stubSettingsScope<SubagentModelSelectionSettings>()
  704. const controller = new SubagentModelSelectionCardController(host.scope, modelsApi().ctx)
  705. host.publish({ status: 'ready', writable: false, value: { enabled: false, allowedModels: [] }, user: {} })
  706. const face = controller.inject()
  707. face.toggleEnabled()
  708. face.toggleModel('alpha\0fast')
  709. face.save()
  710. expect(host.mutate).not.toHaveBeenCalled()
  711. controller.dispose()
  712. controller.refreshCatalog()
  713. controller.resetConnection()
  714. face.toggleEnabled()
  715. face.retryCatalog()
  716. face.save()
  717. host.publish({ value: { enabled: true, allowedModels: [{ provider: 'alpha', model: 'fast' }] } })
  718. expect(host.mutate).not.toHaveBeenCalled()
  719. expect(face.hooks.subagentModelSelectionCard.getSnapshot().enabled).toBe(false)
  720. })
  721. })
  722. describe('WebSearchCardController', () => {
  723. it('reads the credential state for the reference the tab names', async () => {
  724. const host = stubSettingsScope<WebSearchSettings>()
  725. const credentials = credentialsApi(true)
  726. const controller = new WebSearchCardController(host.scope, credentials.ctx)
  727. const state = () => controller.inject().hooks.webSearchCard.getSnapshot()
  728. await vi.waitFor(() => { expect(credentials.describe).toHaveBeenCalled() })
  729. host.publish({ status: 'ready', writable: true, value: { baseURL: 'https://search.test/v1' }, user: {} })
  730. await vi.waitFor(() => { expect(state().apiKeyConfigured).toBe(true) })
  731. expect(state()).toMatchObject({
  732. baseURL: { text: 'https://search.test/v1', overridden: false },
  733. apiKey: { text: '', overridden: false },
  734. })
  735. })
  736. it('writes the staged key through the credentials domain, never the settings section', async () => {
  737. const host = stubSettingsScope<WebSearchSettings>()
  738. const credentials = credentialsApi(false)
  739. const controller = new WebSearchCardController(host.scope, credentials.ctx)
  740. host.publish({ status: 'ready', writable: true, value: {}, user: {} })
  741. const face = controller.inject()
  742. face.edit('apiKey', ' ds-secret ')
  743. expect(face.hooks.webSearchCard.getSnapshot().dirty).toBe(true)
  744. expect(credentials.set).not.toHaveBeenCalled()
  745. credentials.describe.mockImplementation(() => Promise.resolve({
  746. ok: true as const,
  747. value: { DEEPSEEK_API_KEY: { configured: true, writable: true } },
  748. }))
  749. face.save()
  750. await vi.waitFor(() => { expect(credentials.set).toHaveBeenCalled() })
  751. expect(credentials.set).toHaveBeenCalledWith('DEEPSEEK_API_KEY', 'ds-secret')
  752. expect(host.set).not.toHaveBeenCalled()
  753. await vi.waitFor(() => {
  754. expect(face.hooks.webSearchCard.getSnapshot()).toMatchObject({ dirty: false, apiKeyConfigured: true })
  755. })
  756. })
  757. it('keeps the stored key when the draft is left blank', () => {
  758. const host = stubSettingsScope<WebSearchSettings>()
  759. const credentials = credentialsApi(true)
  760. const controller = new WebSearchCardController(host.scope, credentials.ctx)
  761. host.publish({ status: 'ready', writable: true, value: {}, user: {} })
  762. const face = controller.inject()
  763. face.edit('apiKey', ' ')
  764. expect(face.hooks.webSearchCard.getSnapshot().dirty).toBe(false)
  765. face.save()
  766. expect(credentials.set).not.toHaveBeenCalled()
  767. })
  768. it('re-reads when the Host reports the watched reference changed', async () => {
  769. const host = stubSettingsScope<WebSearchSettings>()
  770. const credentials = credentialsApi(false)
  771. const controller = new WebSearchCardController(host.scope, credentials.ctx)
  772. host.publish({ status: 'ready', writable: true, value: {}, user: {} })
  773. await vi.waitFor(() => { expect(credentials.describe).toHaveBeenCalled() })
  774. credentials.describe.mockClear()
  775. // Another reference is not this card's business.
  776. controller.refreshCredential('OTHER_KEY')
  777. expect(credentials.describe).not.toHaveBeenCalled()
  778. // A key written on another surface reaches this card only through this signal.
  779. credentials.describe.mockImplementation(() => Promise.resolve({
  780. ok: true as const,
  781. value: { DEEPSEEK_API_KEY: { configured: true, writable: true } },
  782. }))
  783. controller.refreshCredential('DEEPSEEK_API_KEY')
  784. await vi.waitFor(() => {
  785. expect(controller.inject().hooks.webSearchCard.getSnapshot().apiKeyConfigured).toBe(true)
  786. })
  787. })
  788. it('addresses the reference the tab declares rather than the default', async () => {
  789. const host = stubSettingsScope<WebSearchSettings>()
  790. const credentials = credentialsApi(false)
  791. const controller = new WebSearchCardController(host.scope, credentials.ctx)
  792. host.publish({ status: 'ready', writable: true, value: { apiKeyEnv: 'SEARCH_KEY' }, user: {} })
  793. const face = controller.inject()
  794. face.edit('apiKey', 'ds-secret')
  795. face.save()
  796. await vi.waitFor(() => { expect(credentials.set).toHaveBeenCalled() })
  797. expect(credentials.set).toHaveBeenCalledWith('SEARCH_KEY', 'ds-secret')
  798. })
  799. it('reports a key the Host did not store as a failed save', async () => {
  800. const host = stubSettingsScope<WebSearchSettings>()
  801. const credentials = credentialsApi(false)
  802. const controller = new WebSearchCardController(host.scope, credentials.ctx)
  803. host.publish({ status: 'ready', writable: true, value: {}, user: {} })
  804. const face = controller.inject()
  805. face.edit('apiKey', 'ds-secret')
  806. face.save()
  807. await vi.waitFor(() => {
  808. expect(face.hooks.webSearchCard.getSnapshot()).toMatchObject({ failed: true, dirty: true })
  809. })
  810. })
  811. it('keeps the card usable when the credential read is refused', async () => {
  812. const host = stubSettingsScope<WebSearchSettings>()
  813. const refusal = () => Promise.resolve({
  814. ok: false as const,
  815. error: new RemoteError('credential/rejected', 'offline', { ref: 'DEEPSEEK_API_KEY' }),
  816. })
  817. const describe = vi.fn(refusal)
  818. const set = vi.fn(refusal)
  819. const controller = new WebSearchCardController(host.scope, ctxWith({ credentials: { describe, set } }))
  820. const face = controller.inject()
  821. await vi.waitFor(() => { expect(describe).toHaveBeenCalled() })
  822. host.publish({ status: 'ready', writable: true, value: { baseURL: 'https://search.test/v1' }, user: {} })
  823. face.edit('apiKey', 'ds-secret')
  824. face.save()
  825. await vi.waitFor(() => { expect(set).toHaveBeenCalled() })
  826. expect(face.hooks.webSearchCard.getSnapshot()).toMatchObject({
  827. available: true,
  828. apiKeyConfigured: false,
  829. baseURL: { text: 'https://search.test/v1' },
  830. })
  831. })
  832. it('ignores a credential read the Host refused', async () => {
  833. const host = stubSettingsScope<WebSearchSettings>()
  834. const describe = vi.fn(() => Promise.resolve({
  835. ok: false as const,
  836. error: new RemoteError('gateway/internal', 'no credential provider', {}),
  837. }))
  838. const controller = new WebSearchCardController(host.scope, ctxWith({
  839. credentials: { describe, set: vi.fn() },
  840. }))
  841. await vi.waitFor(() => { expect(describe).toHaveBeenCalled() })
  842. expect(controller.inject().hooks.webSearchCard.getSnapshot().apiKeyConfigured).toBe(false)
  843. })
  844. it('saves the endpoint and the search budget together', async () => {
  845. const host = stubSettingsScope<WebSearchSettings>()
  846. acceptWrites(host)
  847. const credentials = credentialsApi(true)
  848. const controller = new WebSearchCardController(host.scope, credentials.ctx)
  849. host.publish({ status: 'ready', writable: true, value: {}, base: {}, user: {} })
  850. const face = controller.inject()
  851. face.edit('baseURL', 'https://other.test')
  852. face.edit('maxUses', '3')
  853. face.save()
  854. await vi.waitFor(() => { expect(host.set).toHaveBeenCalledTimes(2) })
  855. expect(host.set.mock.calls).toEqual([['baseURL', 'https://other.test'], ['maxUses', 3]])
  856. expect(credentials.set).not.toHaveBeenCalled()
  857. })
  858. })
  859. describe('ConfigurablePluginsTabController', () => {
  860. function settingsApi(namespaces: string[]) {
  861. const describe = vi.fn(() => Promise.resolve({
  862. ok: true as const,
  863. value: {
  864. writable: true,
  865. hasDocument: true,
  866. namespaces: namespaces.map(ns => ({
  867. ns, schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0,
  868. })),
  869. },
  870. }))
  871. return { mirror: new SettingsDescribeMirror(ctxWith({ settings: { describe } })), describe }
  872. }
  873. /** Slot ledger stand-in: one stored entry per registered card key. */
  874. function ledger(...keys: string[]) {
  875. return keys.map(key => ({ component: null, options: { key } }))
  876. }
  877. it('dispatches the served namespaces a card claims, in card registration order', async () => {
  878. const settings = settingsApi(['bash', 'ui-theme', 'agent-loop'])
  879. const controller = new ConfigurablePluginsTabController(settings.mirror, () => ledger('agent-loop', 'bash'))
  880. await settings.mirror.ensure()
  881. // ui-theme is served but claimed by no card here — another surface owns
  882. // it. The order is the cards', not the Host's: plugin activation can
  883. // reorder the description between boots.
  884. expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces)
  885. .toEqual(['agent-loop', 'bash'])
  886. })
  887. it('never dispatches a card whose namespace this deployment does not serve', async () => {
  888. const settings = settingsApi(['bash'])
  889. const controller = new ConfigurablePluginsTabController(settings.mirror, () => ledger('bash', 'web-search-deepseek'))
  890. await settings.mirror.ensure()
  891. expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual(['bash'])
  892. })
  893. it('takes a card registered after the read without asking the Host again', async () => {
  894. const settings = settingsApi(['bash'])
  895. let entries = ledger()
  896. const controller = new ConfigurablePluginsTabController(settings.mirror, () => entries)
  897. await settings.mirror.ensure()
  898. expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual([])
  899. entries = ledger('bash')
  900. controller.refresh()
  901. expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual(['bash'])
  902. expect(settings.describe).toHaveBeenCalledOnce()
  903. })
  904. it('keeps the namespaces it knew when a refresh fails', async () => {
  905. const settings = settingsApi(['bash'])
  906. const controller = new ConfigurablePluginsTabController(settings.mirror, () => ledger('bash'))
  907. await settings.mirror.ensure()
  908. settings.describe.mockRejectedValueOnce(new Error('offline'))
  909. await settings.mirror.load()
  910. expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual(['bash'])
  911. })
  912. it('stops following the mirror once disposed, and never claims it was answered', async () => {
  913. const settings = settingsApi(['bash'])
  914. const controller = new ConfigurablePluginsTabController(settings.mirror, () => ledger('bash'))
  915. controller.dispose()
  916. await settings.mirror.load()
  917. expect(controller.inject().hooks.configurablePlugins.getSnapshot())
  918. .toEqual({ loaded: false, namespaces: [] })
  919. })
  920. it('ignores a slot-ledger change that arrives after disposal', async () => {
  921. const settings = settingsApi(['bash'])
  922. let entries = ledger()
  923. const controller = new ConfigurablePluginsTabController(settings.mirror, () => entries)
  924. await settings.mirror.ensure()
  925. controller.dispose()
  926. entries = ledger('bash')
  927. controller.refresh()
  928. expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual([])
  929. })
  930. it('ignores a mirror notification already queued when disposal starts', () => {
  931. let notify = (): void => {}
  932. let snapshot: SettingsMirrorSnapshot = {
  933. status: 'ready' as const,
  934. view: { writable: true, hasDocument: true, namespaces: [] },
  935. error: null,
  936. }
  937. const describeFace = {
  938. getSnapshot: () => snapshot,
  939. subscribe: (listener: () => void) => {
  940. notify = listener
  941. return () => {}
  942. },
  943. ensure: () => Promise.resolve(),
  944. acceptView: vi.fn(),
  945. } as never
  946. const controller = new ConfigurablePluginsTabController(describeFace, () => ledger('bash'))
  947. expect(controller.inject().hooks.configurablePlugins.getSnapshot())
  948. .toEqual({ loaded: true, namespaces: [] })
  949. controller.dispose()
  950. snapshot = {
  951. status: 'ready',
  952. view: {
  953. writable: true,
  954. hasDocument: true,
  955. namespaces: [{
  956. ns: 'bash', schema: {}, value: {}, applies: 'live', secrets: [], revision: 1,
  957. }],
  958. },
  959. error: null,
  960. }
  961. notify()
  962. expect(controller.inject().hooks.configurablePlugins.getSnapshot())
  963. .toEqual({ loaded: true, namespaces: [] })
  964. })
  965. it('reports the Host answered even when it serves nothing this tab shows', async () => {
  966. const settings = settingsApi(['ui-theme'])
  967. const controller = new ConfigurablePluginsTabController(settings.mirror, () => ledger('bash'))
  968. await settings.mirror.ensure()
  969. expect(controller.inject().hooks.configurablePlugins.getSnapshot())
  970. .toEqual({ loaded: true, namespaces: [] })
  971. })
  972. })