stores.client.spec.ts 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687
  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 { stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
  7. import { CardForm, numberField, textField } from '../src/client/card-form.ts'
  8. import { AgentLoopCardController, type AgentLoopSettings } from '../src/client/agent-loop-card-controller.ts'
  9. import { BashCardController, type BashSettings } from '../src/client/bash-card-controller.ts'
  10. import {
  11. SettingsDescribeMirror, type SettingsMirrorSnapshot,
  12. } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts'
  13. import { ConfigurablePluginsTabController } from '../src/client/tab-store.ts'
  14. import { WebSearchCardController, type WebSearchSettings } from '../src/client/web-search-card-controller.ts'
  15. /** Make the stub behave like a Host that accepts every write. */
  16. function acceptWrites<T>(host: StubSettingsScope<T>): void {
  17. const section = (): Record<string, unknown> => ({ ...host.scope.getSnapshot().value as object })
  18. const layer = (): Record<string, unknown> => ({ ...host.scope.getSnapshot().user as object })
  19. host.set.mockImplementation((field: string, value: unknown) => {
  20. host.publish({ value: { ...section(), [field]: value } as T, user: { ...layer(), [field]: value } })
  21. })
  22. host.unset.mockImplementation((field: string) => {
  23. const user = Object.fromEntries(Object.entries(layer()).filter(([key]) => key !== field))
  24. const base = host.scope.getSnapshot().base as Record<string, unknown> | undefined
  25. host.publish({ value: { ...section(), [field]: base?.[field] } as T, user })
  26. })
  27. }
  28. function credentialsApi(configured: boolean) {
  29. const describe = vi.fn(() => Promise.resolve({
  30. rpcId: 'c-1' as never,
  31. result: { ok: true as const, value: { credentials: { DEEPSEEK_API_KEY: { configured, writable: true } } } },
  32. }))
  33. const set = vi.fn(() => Promise.resolve({ rpcId: 'c-2' as never, result: { ok: true as const, value: {} } }))
  34. return { api: { credentials: { describe, set } } as never, describe, set }
  35. }
  36. describe('CardForm', () => {
  37. function form() {
  38. const host = stubSettingsScope<Record<string, unknown>>()
  39. const subject = new CardForm(host.scope, [numberField('timeoutMs'), textField('baseURL')])
  40. host.publish({
  41. status: 'ready',
  42. writable: true,
  43. value: { timeoutMs: 60_000, baseURL: 'https://search.test/v1' },
  44. base: { timeoutMs: 60_000, baseURL: 'https://search.test/v1' },
  45. user: {},
  46. })
  47. return { host, subject }
  48. }
  49. it('shows the effective value and stays clean until something is staged', () => {
  50. const { subject } = form()
  51. expect(subject.field('timeoutMs')).toEqual({ text: '60000', overridden: false, invalid: false })
  52. expect(subject.shell()).toMatchObject({ available: true, writable: true, dirty: false, invalid: false })
  53. })
  54. it('marks a field the user layer carries as overridden', () => {
  55. const { host, subject } = form()
  56. host.publish({ value: { timeoutMs: 60_000 }, user: { timeoutMs: 60_000 } })
  57. // An override equal to the composition default is still an override.
  58. expect(subject.field('timeoutMs').overridden).toBe(true)
  59. })
  60. it('writes nothing until the form is saved', async () => {
  61. const { host, subject } = form()
  62. acceptWrites(host)
  63. subject.actions().edit('timeoutMs', '9000')
  64. expect(subject.field('timeoutMs')).toEqual({ text: '9000', overridden: true, invalid: false })
  65. expect(subject.shell().dirty).toBe(true)
  66. expect(host.set).not.toHaveBeenCalled()
  67. await subject.save()
  68. expect(host.set.mock.calls).toEqual([['timeoutMs', 9_000]])
  69. expect(subject.shell()).toMatchObject({ dirty: false, failed: false, saving: false })
  70. })
  71. it('drops a draft that settles back on the value already shown', async () => {
  72. const { host, subject } = form()
  73. subject.actions().edit('timeoutMs', '9000')
  74. subject.actions().edit('timeoutMs', '60000')
  75. expect(subject.shell().dirty).toBe(false)
  76. await subject.save()
  77. expect(host.set).not.toHaveBeenCalled()
  78. })
  79. it('refuses to save while a draft is not a value the field accepts', async () => {
  80. const { host, subject } = form()
  81. subject.actions().edit('timeoutMs', 'soon')
  82. expect(subject.field('timeoutMs')).toEqual({ text: 'soon', overridden: false, invalid: true })
  83. expect(subject.shell()).toMatchObject({ dirty: true, invalid: true })
  84. await subject.save()
  85. expect(host.set).not.toHaveBeenCalled()
  86. expect(subject.field('timeoutMs').text).toBe('soon')
  87. })
  88. it('stages a reset that clears the field only once saved', async () => {
  89. const { host, subject } = form()
  90. acceptWrites(host)
  91. host.publish({ value: { timeoutMs: 9_000 }, user: { timeoutMs: 9_000 } })
  92. subject.actions().resetField('timeoutMs')
  93. // The badge previews the save: the field will no longer be overridden.
  94. expect(subject.field('timeoutMs')).toEqual({ text: '60000', overridden: false, invalid: false })
  95. expect(host.unset).not.toHaveBeenCalled()
  96. await subject.save()
  97. expect(host.unset.mock.calls).toEqual([['timeoutMs']])
  98. expect(subject.shell()).toMatchObject({ dirty: false, failed: false })
  99. })
  100. it('treats resetting an inherited field as no change at all', async () => {
  101. const { host, subject } = form()
  102. subject.actions().resetField('timeoutMs')
  103. expect(subject.shell().dirty).toBe(false)
  104. await subject.save()
  105. expect(host.unset).not.toHaveBeenCalled()
  106. })
  107. it('clears a number field by emptying it', async () => {
  108. const { host, subject } = form()
  109. acceptWrites(host)
  110. host.publish({ user: { timeoutMs: 9_000 } })
  111. subject.actions().edit('timeoutMs', '')
  112. expect(subject.field('timeoutMs')).toEqual({ text: '', overridden: false, invalid: false })
  113. await subject.save()
  114. expect(host.unset.mock.calls).toEqual([['timeoutMs']])
  115. })
  116. it('clears a text field by emptying it', async () => {
  117. const { host, subject } = form()
  118. acceptWrites(host)
  119. host.publish({ user: { baseURL: 'https://search.test/v1' } })
  120. subject.actions().edit('baseURL', ' ')
  121. await subject.save()
  122. expect(host.unset.mock.calls).toEqual([['baseURL']])
  123. })
  124. it('writes the trimmed text of a text field', async () => {
  125. const { host, subject } = form()
  126. acceptWrites(host)
  127. subject.actions().edit('baseURL', ' https://other.test ')
  128. await subject.save()
  129. expect(host.set.mock.calls).toEqual([['baseURL', 'https://other.test']])
  130. })
  131. it('keeps the drafts a save did not land, and reports the failure', async () => {
  132. const { host, subject } = form()
  133. subject.actions().edit('timeoutMs', '9000')
  134. await subject.save()
  135. // The stub Host accepted the call without storing it, exactly as a
  136. // validator that refuses the value does.
  137. expect(host.set).toHaveBeenCalledWith('timeoutMs', 9_000)
  138. expect(subject.shell()).toMatchObject({ dirty: true, failed: true, saving: false })
  139. expect(subject.field('timeoutMs').text).toBe('9000')
  140. })
  141. it('reports a reset the Host did not apply as a failure', async () => {
  142. const { host, subject } = form()
  143. host.publish({ user: { timeoutMs: 9_000 } })
  144. subject.actions().resetField('timeoutMs')
  145. await subject.save()
  146. expect(host.unset).toHaveBeenCalledWith('timeoutMs')
  147. expect(subject.shell().failed).toBe(true)
  148. })
  149. it('clears the failure as soon as the user edits again', async () => {
  150. const { subject } = form()
  151. subject.actions().edit('timeoutMs', '9000')
  152. await subject.save()
  153. expect(subject.shell().failed).toBe(true)
  154. subject.actions().edit('timeoutMs', '9001')
  155. expect(subject.shell().failed).toBe(false)
  156. })
  157. it('discards every staged edit', async () => {
  158. const { host, subject } = form()
  159. subject.actions().edit('timeoutMs', '9000')
  160. subject.actions().discard()
  161. expect(subject.field('timeoutMs').text).toBe('60000')
  162. expect(subject.shell()).toMatchObject({ dirty: false, failed: false })
  163. // A discard with nothing staged publishes nothing.
  164. const before = subject.shell()
  165. subject.actions().discard()
  166. expect(subject.shell()).toEqual(before)
  167. await subject.save()
  168. expect(host.set).not.toHaveBeenCalled()
  169. })
  170. it('refuses a second save while one is in flight', async () => {
  171. const { host, subject } = form()
  172. acceptWrites(host)
  173. subject.actions().edit('timeoutMs', '9000')
  174. const first = subject.save()
  175. expect(subject.shell().saving).toBe(true)
  176. const second = subject.save()
  177. await Promise.all([first, second])
  178. expect(host.set).toHaveBeenCalledTimes(1)
  179. })
  180. it('publishes a projection whenever the scope or a draft changes', () => {
  181. const { host, subject } = form()
  182. const store = subject.bind(() => subject.field('timeoutMs').text)
  183. expect(store.getSnapshot()).toBe('60000')
  184. host.publish({ value: { timeoutMs: 1_000 } })
  185. expect(store.getSnapshot()).toBe('1000')
  186. subject.actions().edit('timeoutMs', '2000')
  187. expect(store.getSnapshot()).toBe('2000')
  188. })
  189. it('refuses to address a field the card never declared', () => {
  190. const { subject } = form()
  191. expect(() => subject.field('nope')).toThrow('plugin card has no field nope')
  192. })
  193. it('renders an absent section value as an empty draft', () => {
  194. const host = stubSettingsScope<Record<string, unknown>>()
  195. const subject = new CardForm(host.scope, [numberField('timeoutMs'), textField('baseURL')])
  196. host.publish({ status: 'ready', writable: true, value: {}, base: {}, user: undefined })
  197. expect(subject.field('timeoutMs').text).toBe('')
  198. expect(subject.field('baseURL').text).toBe('')
  199. expect(subject.shell().available).toBe(true)
  200. })
  201. it('stays unavailable while the namespace is not served', () => {
  202. const host = stubSettingsScope<Record<string, unknown>>()
  203. const subject = new CardForm(host.scope, [numberField('timeoutMs')])
  204. host.publish({ status: 'unavailable' })
  205. expect(subject.shell()).toMatchObject({ available: false, writable: false })
  206. })
  207. })
  208. describe('BashCardController', () => {
  209. it('projects both fields and saves them in one write pass', async () => {
  210. const host = stubSettingsScope<BashSettings>()
  211. acceptWrites(host)
  212. const controller = new BashCardController(host.scope)
  213. host.publish({
  214. status: 'ready',
  215. writable: true,
  216. value: { timeoutMs: 5_000, maxOutputBytes: 64_000 },
  217. base: { timeoutMs: 60_000, maxOutputBytes: 64_000 },
  218. user: { timeoutMs: 5_000 },
  219. })
  220. const face = controller.inject()
  221. expect(face.hooks.bashCard.getSnapshot()).toMatchObject({
  222. available: true,
  223. writable: true,
  224. dirty: false,
  225. timeoutMs: { text: '5000', overridden: true },
  226. maxOutputBytes: { text: '64000', overridden: false },
  227. })
  228. face.edit('timeoutMs', '9000')
  229. face.edit('maxOutputBytes', '1024')
  230. expect(face.hooks.bashCard.getSnapshot().dirty).toBe(true)
  231. face.save()
  232. await vi.waitFor(() => { expect(host.set).toHaveBeenCalledTimes(2) })
  233. expect(host.set.mock.calls).toEqual([['timeoutMs', 9_000], ['maxOutputBytes', 1_024]])
  234. expect(face.hooks.bashCard.getSnapshot().dirty).toBe(false)
  235. })
  236. it('stages a reset and applies it on save', async () => {
  237. const host = stubSettingsScope<BashSettings>()
  238. acceptWrites(host)
  239. const controller = new BashCardController(host.scope)
  240. host.publish({
  241. status: 'ready',
  242. writable: true,
  243. value: { timeoutMs: 5_000 },
  244. base: { timeoutMs: 60_000 },
  245. user: { timeoutMs: 5_000 },
  246. })
  247. const face = controller.inject()
  248. face.resetField('timeoutMs')
  249. expect(face.hooks.bashCard.getSnapshot().timeoutMs.text).toBe('60000')
  250. face.save()
  251. await vi.waitFor(() => { expect(host.unset).toHaveBeenCalledWith('timeoutMs') })
  252. expect(face.hooks.bashCard.getSnapshot()).toMatchObject({
  253. dirty: false,
  254. timeoutMs: { text: '60000', overridden: false },
  255. })
  256. })
  257. it('discards staged edits without writing', () => {
  258. const host = stubSettingsScope<BashSettings>()
  259. const controller = new BashCardController(host.scope)
  260. host.publish({ status: 'ready', writable: true, value: { timeoutMs: 5_000 }, user: {} })
  261. const face = controller.inject()
  262. face.edit('timeoutMs', '9000')
  263. face.discard()
  264. expect(face.hooks.bashCard.getSnapshot().timeoutMs.text).toBe('5000')
  265. expect(host.set).not.toHaveBeenCalled()
  266. })
  267. })
  268. describe('AgentLoopCardController', () => {
  269. it('saves the only field it owns', async () => {
  270. const host = stubSettingsScope<AgentLoopSettings>()
  271. acceptWrites(host)
  272. const controller = new AgentLoopCardController(host.scope)
  273. host.publish({
  274. status: 'ready',
  275. writable: true,
  276. value: { maxParallelToolCalls: 10 },
  277. base: { maxParallelToolCalls: 10 },
  278. user: {},
  279. })
  280. const face = controller.inject()
  281. face.edit('maxParallelToolCalls', '4')
  282. face.save()
  283. await vi.waitFor(() => { expect(host.set).toHaveBeenCalledWith('maxParallelToolCalls', 4) })
  284. expect(face.hooks.agentLoopCard.getSnapshot()).toMatchObject({
  285. dirty: false,
  286. maxParallelToolCalls: { text: '4', overridden: true },
  287. })
  288. })
  289. it('reports a read-only document so the card can disable its controls', () => {
  290. const host = stubSettingsScope<AgentLoopSettings>()
  291. const controller = new AgentLoopCardController(host.scope)
  292. host.publish({ status: 'ready', writable: false, value: { maxParallelToolCalls: 10 } })
  293. expect(controller.inject().hooks.agentLoopCard.getSnapshot().writable).toBe(false)
  294. })
  295. })
  296. describe('WebSearchCardController', () => {
  297. it('reads the credential state for the reference the tab names', async () => {
  298. const host = stubSettingsScope<WebSearchSettings>()
  299. const credentials = credentialsApi(true)
  300. const controller = new WebSearchCardController(host.scope, credentials.api)
  301. const state = () => controller.inject().hooks.webSearchCard.getSnapshot()
  302. await vi.waitFor(() => { expect(credentials.describe).toHaveBeenCalled() })
  303. host.publish({ status: 'ready', writable: true, value: { baseURL: 'https://search.test/v1' }, user: {} })
  304. await vi.waitFor(() => { expect(state().apiKeyConfigured).toBe(true) })
  305. expect(state()).toMatchObject({
  306. baseURL: { text: 'https://search.test/v1', overridden: false },
  307. apiKey: { text: '', overridden: false },
  308. })
  309. })
  310. it('writes the staged key through the credentials domain, never the settings section', async () => {
  311. const host = stubSettingsScope<WebSearchSettings>()
  312. const credentials = credentialsApi(false)
  313. const controller = new WebSearchCardController(host.scope, credentials.api)
  314. host.publish({ status: 'ready', writable: true, value: {}, user: {} })
  315. const face = controller.inject()
  316. face.edit('apiKey', ' ds-secret ')
  317. expect(face.hooks.webSearchCard.getSnapshot().dirty).toBe(true)
  318. expect(credentials.set).not.toHaveBeenCalled()
  319. credentials.describe.mockImplementation(() => Promise.resolve({
  320. rpcId: 'c-1' as never,
  321. result: { ok: true as const, value: { credentials: { DEEPSEEK_API_KEY: { configured: true, writable: true } } } },
  322. }))
  323. face.save()
  324. await vi.waitFor(() => { expect(credentials.set).toHaveBeenCalled() })
  325. expect(credentials.set).toHaveBeenCalledWith({ ref: 'DEEPSEEK_API_KEY', value: 'ds-secret' })
  326. expect(host.set).not.toHaveBeenCalled()
  327. await vi.waitFor(() => {
  328. expect(face.hooks.webSearchCard.getSnapshot()).toMatchObject({ dirty: false, apiKeyConfigured: true })
  329. })
  330. })
  331. it('keeps the stored key when the draft is left blank', () => {
  332. const host = stubSettingsScope<WebSearchSettings>()
  333. const credentials = credentialsApi(true)
  334. const controller = new WebSearchCardController(host.scope, credentials.api)
  335. host.publish({ status: 'ready', writable: true, value: {}, user: {} })
  336. const face = controller.inject()
  337. face.edit('apiKey', ' ')
  338. expect(face.hooks.webSearchCard.getSnapshot().dirty).toBe(false)
  339. face.save()
  340. expect(credentials.set).not.toHaveBeenCalled()
  341. })
  342. it('re-reads when the Host reports the watched reference changed', async () => {
  343. const host = stubSettingsScope<WebSearchSettings>()
  344. const credentials = credentialsApi(false)
  345. const controller = new WebSearchCardController(host.scope, credentials.api)
  346. host.publish({ status: 'ready', writable: true, value: {}, user: {} })
  347. await vi.waitFor(() => { expect(credentials.describe).toHaveBeenCalled() })
  348. credentials.describe.mockClear()
  349. // Another reference is not this card's business.
  350. controller.refreshCredential('OTHER_KEY')
  351. expect(credentials.describe).not.toHaveBeenCalled()
  352. // A key written on another surface reaches this card only through this signal.
  353. credentials.describe.mockImplementation(() => Promise.resolve({
  354. rpcId: 'c-1' as never,
  355. result: { ok: true as const, value: { credentials: { DEEPSEEK_API_KEY: { configured: true, writable: true } } } },
  356. }))
  357. controller.refreshCredential('DEEPSEEK_API_KEY')
  358. await vi.waitFor(() => {
  359. expect(controller.inject().hooks.webSearchCard.getSnapshot().apiKeyConfigured).toBe(true)
  360. })
  361. })
  362. it('addresses the reference the tab declares rather than the default', async () => {
  363. const host = stubSettingsScope<WebSearchSettings>()
  364. const credentials = credentialsApi(false)
  365. const controller = new WebSearchCardController(host.scope, credentials.api)
  366. host.publish({ status: 'ready', writable: true, value: { apiKeyEnv: 'SEARCH_KEY' }, user: {} })
  367. const face = controller.inject()
  368. face.edit('apiKey', 'ds-secret')
  369. face.save()
  370. await vi.waitFor(() => { expect(credentials.set).toHaveBeenCalled() })
  371. expect(credentials.set).toHaveBeenCalledWith({ ref: 'SEARCH_KEY', value: 'ds-secret' })
  372. })
  373. it('reports a key the Host did not store as a failed save', async () => {
  374. const host = stubSettingsScope<WebSearchSettings>()
  375. const credentials = credentialsApi(false)
  376. const controller = new WebSearchCardController(host.scope, credentials.api)
  377. host.publish({ status: 'ready', writable: true, value: {}, user: {} })
  378. const face = controller.inject()
  379. face.edit('apiKey', 'ds-secret')
  380. face.save()
  381. await vi.waitFor(() => {
  382. expect(face.hooks.webSearchCard.getSnapshot()).toMatchObject({ failed: true, dirty: true })
  383. })
  384. })
  385. it('keeps the card usable when the credential read fails', async () => {
  386. const host = stubSettingsScope<WebSearchSettings>()
  387. const describe = vi.fn(() => Promise.reject(new Error('offline')))
  388. const set = vi.fn(() => Promise.reject(new Error('offline')))
  389. const controller = new WebSearchCardController(host.scope, { credentials: { describe, set } } as never)
  390. const face = controller.inject()
  391. await vi.waitFor(() => { expect(describe).toHaveBeenCalled() })
  392. host.publish({ status: 'ready', writable: true, value: { baseURL: 'https://search.test/v1' }, user: {} })
  393. face.edit('apiKey', 'ds-secret')
  394. face.save()
  395. await vi.waitFor(() => { expect(set).toHaveBeenCalled() })
  396. expect(face.hooks.webSearchCard.getSnapshot()).toMatchObject({
  397. available: true,
  398. apiKeyConfigured: false,
  399. baseURL: { text: 'https://search.test/v1' },
  400. })
  401. })
  402. it('ignores a credential read the Host refused', async () => {
  403. const host = stubSettingsScope<WebSearchSettings>()
  404. const describe = vi.fn(() => Promise.resolve({
  405. rpcId: 'c-1' as never,
  406. result: { ok: false as const, error: { code: 'credentials-unavailable', message: 'no provider' } },
  407. }))
  408. const controller = new WebSearchCardController(host.scope, { credentials: { describe, set: vi.fn() } } as never)
  409. await vi.waitFor(() => { expect(describe).toHaveBeenCalled() })
  410. expect(controller.inject().hooks.webSearchCard.getSnapshot().apiKeyConfigured).toBe(false)
  411. })
  412. it('saves the endpoint and the search budget together', async () => {
  413. const host = stubSettingsScope<WebSearchSettings>()
  414. acceptWrites(host)
  415. const credentials = credentialsApi(true)
  416. const controller = new WebSearchCardController(host.scope, credentials.api)
  417. host.publish({ status: 'ready', writable: true, value: {}, base: {}, user: {} })
  418. const face = controller.inject()
  419. face.edit('baseURL', 'https://other.test')
  420. face.edit('maxUses', '3')
  421. face.save()
  422. await vi.waitFor(() => { expect(host.set).toHaveBeenCalledTimes(2) })
  423. expect(host.set.mock.calls).toEqual([['baseURL', 'https://other.test'], ['maxUses', 3]])
  424. expect(credentials.set).not.toHaveBeenCalled()
  425. })
  426. })
  427. describe('ConfigurablePluginsTabController', () => {
  428. function settingsApi(namespaces: string[]) {
  429. const describe = vi.fn(() => Promise.resolve({
  430. rpcId: 's-1' as never,
  431. result: {
  432. ok: true as const,
  433. value: {
  434. writable: true,
  435. hasDocument: true,
  436. namespaces: namespaces.map(ns => ({
  437. ns, schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0,
  438. })),
  439. },
  440. },
  441. }))
  442. return { mirror: new SettingsDescribeMirror({ settings: { describe } } as never), describe }
  443. }
  444. /** Slot ledger stand-in: one stored entry per registered card key. */
  445. function ledger(...keys: string[]) {
  446. return keys.map(key => ({ component: null, options: { key } }))
  447. }
  448. it('dispatches the served namespaces a card claims, in card registration order', async () => {
  449. const settings = settingsApi(['bash', 'ui-theme', 'agent-loop'])
  450. const controller = new ConfigurablePluginsTabController(settings.mirror, () => ledger('agent-loop', 'bash'))
  451. await settings.mirror.ensure()
  452. // ui-theme is served but claimed by no card here — another surface owns
  453. // it. The order is the cards', not the Host's: plugin activation can
  454. // reorder the description between boots.
  455. expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces)
  456. .toEqual(['agent-loop', 'bash'])
  457. })
  458. it('never dispatches a card whose namespace this deployment does not serve', async () => {
  459. const settings = settingsApi(['bash'])
  460. const controller = new ConfigurablePluginsTabController(settings.mirror, () => ledger('bash', 'web-search-deepseek'))
  461. await settings.mirror.ensure()
  462. expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual(['bash'])
  463. })
  464. it('takes a card registered after the read without asking the Host again', async () => {
  465. const settings = settingsApi(['bash'])
  466. let entries = ledger()
  467. const controller = new ConfigurablePluginsTabController(settings.mirror, () => entries)
  468. await settings.mirror.ensure()
  469. expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual([])
  470. entries = ledger('bash')
  471. controller.refresh()
  472. expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual(['bash'])
  473. expect(settings.describe).toHaveBeenCalledOnce()
  474. })
  475. it('keeps the namespaces it knew when a refresh fails', async () => {
  476. const settings = settingsApi(['bash'])
  477. const controller = new ConfigurablePluginsTabController(settings.mirror, () => ledger('bash'))
  478. await settings.mirror.ensure()
  479. settings.describe.mockRejectedValueOnce(new Error('offline'))
  480. await settings.mirror.load()
  481. expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual(['bash'])
  482. })
  483. it('stops following the mirror once disposed, and never claims it was answered', async () => {
  484. const settings = settingsApi(['bash'])
  485. const controller = new ConfigurablePluginsTabController(settings.mirror, () => ledger('bash'))
  486. controller.dispose()
  487. await settings.mirror.load()
  488. expect(controller.inject().hooks.configurablePlugins.getSnapshot())
  489. .toEqual({ loaded: false, namespaces: [] })
  490. })
  491. it('ignores a slot-ledger change that arrives after disposal', async () => {
  492. const settings = settingsApi(['bash'])
  493. let entries = ledger()
  494. const controller = new ConfigurablePluginsTabController(settings.mirror, () => entries)
  495. await settings.mirror.ensure()
  496. controller.dispose()
  497. entries = ledger('bash')
  498. controller.refresh()
  499. expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual([])
  500. })
  501. it('ignores a mirror notification already queued when disposal starts', () => {
  502. let notify = (): void => {}
  503. let snapshot: SettingsMirrorSnapshot = {
  504. status: 'ready' as const,
  505. view: { writable: true, hasDocument: true, namespaces: [] },
  506. error: null,
  507. }
  508. const describeFace = {
  509. getSnapshot: () => snapshot,
  510. subscribe: (listener: () => void) => {
  511. notify = listener
  512. return () => {}
  513. },
  514. ensure: () => Promise.resolve(),
  515. acceptView: vi.fn(),
  516. } as never
  517. const controller = new ConfigurablePluginsTabController(describeFace, () => ledger('bash'))
  518. expect(controller.inject().hooks.configurablePlugins.getSnapshot())
  519. .toEqual({ loaded: true, namespaces: [] })
  520. controller.dispose()
  521. snapshot = {
  522. status: 'ready',
  523. view: {
  524. writable: true,
  525. hasDocument: true,
  526. namespaces: [{
  527. ns: 'bash', schema: {}, value: {}, applies: 'live', secrets: [], revision: 1,
  528. }],
  529. },
  530. error: null,
  531. }
  532. notify()
  533. expect(controller.inject().hooks.configurablePlugins.getSnapshot())
  534. .toEqual({ loaded: true, namespaces: [] })
  535. })
  536. it('reports the Host answered even when it serves nothing this tab shows', async () => {
  537. const settings = settingsApi(['ui-theme'])
  538. const controller = new ConfigurablePluginsTabController(settings.mirror, () => ledger('bash'))
  539. await settings.mirror.ensure()
  540. expect(controller.inject().hooks.configurablePlugins.getSnapshot())
  541. .toEqual({ loaded: true, namespaces: [] })
  542. })
  543. })