apply.client.spec.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323
  1. /** Models section registration: slot declaration injection, the locale-following label thunk, and HMR recovery. */
  2. import { Context } from '@deepseek-ai/cordis'
  3. import { describe, expect, it, onTestFinished, vi } from 'vitest'
  4. import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
  5. import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
  6. import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
  7. import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
  8. import { remoteDefaultResponses } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/remote-default-responses.ts'
  9. import { ok, RemoteMock } from '@deepseek-ai/dsh-remote-mock'
  10. import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client'
  11. import { apply, inject, refreshIfLoaded } from '@deepseek-ai/dsh-client-ui-settings-models/client'
  12. import {
  13. WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION,
  14. } from '../src/onboarding-copy.ts'
  15. import { ModelsSection } from '../src/client/ModelsSection.tsx'
  16. import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx'
  17. import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx'
  18. import { apply as hostApply } from '../src/index.ts'
  19. // These specs assert the shipped Chinese copy. The lane has no jsdom `window`,
  20. // so browser-language detection never runs and a fresh LocaleRuntime opens on
  21. // FALLBACK_LOCALE (en); bench stages zh explicitly on the locale instead.
  22. async function bench(isLoopback = true, mock = RemoteMock.create().load(remoteDefaultResponses), services: object = {}) {
  23. onTestFinished(() => { mock.assertNoUnmatched() })
  24. const ctx = new Context()
  25. await ctx.plugin(SlotRegistry).await()
  26. const locale = new LocaleRuntime(ctx)
  27. locale.setLocale('zh')
  28. ctx.provide('locale', locale)
  29. const remote = new TestRemote(ctx, {
  30. credentials: {
  31. describe: vi.fn(() => Promise.resolve({ ok: true, value: {} })),
  32. set: vi.fn(),
  33. unset: vi.fn(),
  34. },
  35. llm: {
  36. listProviders: vi.fn(() => Promise.resolve({ ok: true, value: [] })),
  37. listConfigurableProviders: vi.fn(() => Promise.resolve({ ok: true, value: [] })),
  38. discoverModels: vi.fn(() => Promise.resolve({ ok: true, value: [] })),
  39. ...services,
  40. },
  41. settings: mock.remote.settings,
  42. })
  43. // The fixed Host facts the settings provider reads its persistence from.
  44. remote.$host = { home: undefined, isLoopback }
  45. await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await()
  46. return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, remote }
  47. }
  48. function declare(slots: SlotRegistry): () => void {
  49. return slots.register(
  50. {
  51. name: 'root',
  52. children: {
  53. 'settings.section': { kind: 'list', scope: 'root' },
  54. 'settings.onboarding': { kind: 'list', scope: 'root' },
  55. },
  56. } as never,
  57. () => null,
  58. )
  59. }
  60. describe('ui-settings-models apply', () => {
  61. it('keeps the host Loader entry inert', () => {
  62. expect(hostApply).not.toThrow()
  63. })
  64. it('declares the services it uses', () => {
  65. expect(inject).toEqual([
  66. 'slots', 'locale', 'remote', 'remote.credentials', 'remote.llm', 'remote.settings',
  67. 'settingsScope', 'settingsSchema',
  68. ])
  69. })
  70. it('registers the models nav entry for declarations before or after apply', async () => {
  71. const before = await bench()
  72. declare(before.slots)
  73. await before.ctx.plugin({ inject: [...inject], apply }).await()
  74. const entry = before.slots.entries('settings.section')[0]!
  75. expect(entry.component).toBe(ModelsSection)
  76. expect(entry.options).toMatchObject({ id: 'models', order: 10 })
  77. // The section claims its two extension seats in the same registration.
  78. expect(before.slots.spec('settings.models.provider-card')).toMatchObject({ kind: 'keyed', scope: 'root' })
  79. expect(before.slots.spec('settings.models.footer')).toMatchObject({ kind: 'list', scope: 'root' })
  80. // The nav label is a locale-following thunk; owners resolve at read time.
  81. expect(resolveSlotLabel(entry.options.label)).toBe('模型')
  82. const injected = (entry.inject as unknown as () => import('../src/client/ModelsSection.tsx').ModelsSectionInjected)()
  83. expect(injected.t('nav')).toBe('模型')
  84. expect(injected.t('deleteTitle')).toBe('删除 {provider}?')
  85. expect(typeof injected.controller.load).toBe('function')
  86. expect(injected.hooks.snapshot).toBe(injected.controller.store)
  87. expect(typeof injected.operations.writeSettings).toBe('function')
  88. const onboarding = before.slots.entries('settings.onboarding')
  89. expect(onboarding).toHaveLength(2)
  90. expect(onboarding.find(entry => entry.options.id === 'welcome-notice')).toMatchObject({
  91. component: WelcomeNotice,
  92. options: { id: 'welcome-notice', order: -100 },
  93. })
  94. const deepSeek = onboarding.find(entry => entry.options.id === 'deepseek-official')!
  95. expect(deepSeek.component).toBe(DeepSeekOnboardingDialog)
  96. expect(deepSeek.options).toMatchObject({ id: 'deepseek-official', order: 0 })
  97. const deepSeekInjected = (
  98. deepSeek.inject as unknown as () => import('../src/client/DeepSeekOnboardingDialog.tsx').DeepSeekOnboardingInjected
  99. )()
  100. expect(deepSeekInjected.hooks.models).toBe(injected.controller.store)
  101. expect(typeof deepSeekInjected.operations.storeCredential).toBe('function')
  102. const after = await bench()
  103. await after.ctx.plugin({ inject: [...inject], apply }).await()
  104. expect(after.slots.entries('settings.section')).toHaveLength(0)
  105. expect(after.slots.entries('settings.onboarding')).toHaveLength(0)
  106. declare(after.slots)
  107. await Promise.resolve()
  108. expect(after.slots.entries('settings.section')[0]!.component).toBe(ModelsSection)
  109. expect(after.slots.entries('settings.onboarding')).toHaveLength(2)
  110. // The self-inflicted ledger notifications hit the duplicate guard.
  111. expect(after.slots.entries('settings.section')).toHaveLength(1)
  112. })
  113. it('the label thunk follows the active locale without re-registration', async () => {
  114. const b = await bench()
  115. declare(b.slots)
  116. await b.ctx.plugin({ inject: [...inject], apply }).await()
  117. b.locale.setLocale('en')
  118. expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('Models')
  119. const injected = b.slots.entries('settings.section')[0]!.inject as unknown as () => import('../src/client/ModelsSection.tsx').ModelsSectionInjected
  120. expect(injected().t('deleteTitle')).toBe('Delete {provider}?')
  121. b.locale.setLocale('zh')
  122. expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('模型')
  123. expect(injected().t('deleteTitle')).toBe('删除 {provider}?')
  124. })
  125. it('locale change while the slot is undeclared stays a no-op', async () => {
  126. const b = await bench()
  127. await b.ctx.plugin({ inject: [...inject], apply }).await()
  128. b.locale.setLocale('en')
  129. expect(b.slots.entries('settings.section')).toHaveLength(0)
  130. b.locale.setLocale('zh')
  131. })
  132. it('re-registers after an HMR collapse re-declares the slot (stale disposer must not block)', async () => {
  133. const b = await bench()
  134. const redeclare = declare(b.slots)
  135. await b.ctx.plugin({ inject: [...inject], apply }).await()
  136. expect(b.slots.entries('settings.section')).toHaveLength(1)
  137. // Declarer unload: the cascade removes our entry while our local
  138. // disposer variable goes stale.
  139. redeclare()
  140. expect(b.slots.entries('settings.section')).toHaveLength(0)
  141. expect(b.slots.entries('settings.onboarding')).toHaveLength(0)
  142. declare(b.slots)
  143. await Promise.resolve()
  144. expect(b.slots.entries('settings.section')[0]!.component).toBe(ModelsSection)
  145. expect(b.slots.entries('settings.onboarding')).toHaveLength(2)
  146. // The locale path also recovers through the same ledger re-check.
  147. b.locale.setLocale('en')
  148. expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('Models')
  149. b.locale.setLocale('zh')
  150. })
  151. it('accepts extension entries under the declared seats and cascades them with the declarer', async () => {
  152. const b = await bench()
  153. declare(b.slots)
  154. const fiber = b.ctx.plugin({ inject: [...inject], apply })
  155. await fiber.await()
  156. // A keyed card extension and a footer entry register through the ordinary
  157. // ledger once the section's registration declared the seats.
  158. const disposeCard = b.slots.register(
  159. { name: 'settings.models.provider-card', key: 'llm-pi-ai' } as never,
  160. () => null,
  161. )
  162. b.slots.register({ name: 'settings.models.footer', id: 'extra', order: 0 } as never, () => null)
  163. expect(b.slots.entries('settings.models.provider-card')).toHaveLength(1)
  164. expect(b.slots.entries('settings.models.footer')).toHaveLength(1)
  165. // Extension-side HMR safety: its own disposer removes the entry.
  166. disposeCard()
  167. expect(b.slots.entries('settings.models.provider-card')).toHaveLength(0)
  168. // Declarer unload cascades whatever extension entries remain.
  169. await fiber.dispose()
  170. expect(b.slots.entries('settings.models.footer')).toHaveLength(0)
  171. })
  172. it('registers the zh/en nav dictionaries and disposes everything with the fiber', async () => {
  173. const b = await bench()
  174. declare(b.slots)
  175. const fiber = b.ctx.plugin({ inject: [...inject], apply })
  176. await fiber.await()
  177. expect(b.locale.bind('settings.models')('nav')).toBe('模型')
  178. await fiber.dispose()
  179. expect(b.slots.entries('settings.section')).toHaveLength(0)
  180. expect(b.slots.entries('settings.onboarding')).toHaveLength(0)
  181. // The (ns, locale) seats are free again — the dictionary disposers ran.
  182. expect(() => b.locale.register('settings.models', 'zh', {})).not.toThrow()
  183. expect(() => b.locale.register('settings.models', 'en', {})).not.toThrow()
  184. })
  185. it('keeps remote-browser acknowledgement in process memory', async () => {
  186. const b = await bench(false)
  187. declare(b.slots)
  188. await b.ctx.plugin({ inject: [...inject], apply }).await()
  189. const entry = b.slots.entries('settings.onboarding')
  190. .find(candidate => candidate.options.id === 'welcome-notice')!
  191. const injected = (
  192. entry.inject as unknown as () => import('../src/client/WelcomeNotice.tsx').WelcomeNoticeInjected
  193. )()
  194. await injected.controller.load()
  195. expect(injected.controller.store.getSnapshot()).toEqual({
  196. status: 'ready', acknowledged: false, error: null,
  197. })
  198. })
  199. })
  200. describe('pushed invalidations', () => {
  201. it('ignores invalidations before the page ever loaded', async () => {
  202. const b = await bench()
  203. declare(b.slots)
  204. await b.ctx.plugin({ inject: [...inject], apply }).await()
  205. // The fake wire face has no methods: a fetch attempt would throw.
  206. b.remote.emit('settings/document-updated', ['llm-pi-ai', 1])
  207. b.remote.emit('credentials/reference-updated', ['OPENAI_API_KEY'])
  208. b.remote.emit('llm/adapters-updated', [])
  209. b.ctx.emit('connection/reset')
  210. })
  211. it('refreshes a loaded page and skips an idle one', () => {
  212. const loads: number[] = []
  213. const controller = {
  214. store: { getSnapshot: () => ({ status: 'ready' }) },
  215. load: () => { loads.push(1); return Promise.resolve() },
  216. }
  217. refreshIfLoaded(controller as unknown as import('../src/client/store.ts').ModelsSettingsStore)
  218. expect(loads).toHaveLength(1)
  219. const idle = {
  220. store: { getSnapshot: () => ({ status: 'idle' }) },
  221. load: () => { loads.push(2); return Promise.resolve() },
  222. }
  223. refreshIfLoaded(idle as unknown as import('../src/client/store.ts').ModelsSettingsStore)
  224. expect(loads).toHaveLength(1)
  225. })
  226. it('routes pushed credential invalidation into the shared onboarding join', async () => {
  227. const b = await bench()
  228. declare(b.slots)
  229. await b.ctx.plugin({ inject: [...inject], apply }).await()
  230. const entry = b.slots.entries('settings.onboarding')
  231. .find(candidate => candidate.options.id === 'deepseek-official')!
  232. const injected = (
  233. entry.inject as unknown as
  234. () => import('../src/client/DeepSeekOnboardingDialog.tsx').DeepSeekOnboardingInjected
  235. )()
  236. injected.controller.store.update((state) => { state.status = 'ready' })
  237. const load = vi.spyOn(injected.controller, 'load').mockResolvedValue()
  238. b.remote.emit('credentials/reference-updated', ['DEEPSEEK_API_KEY'])
  239. expect(load).toHaveBeenCalledTimes(1)
  240. })
  241. it('welcome state follows the shared mirror across document commits', async () => {
  242. // The welcome notice derives from its settings scope: a document commit
  243. // reaches it through the mirror's one refresh, with no routing here.
  244. const mock = RemoteMock.create().load(remoteDefaultResponses)
  245. const namespace = {
  246. ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
  247. schema: {},
  248. value: {},
  249. applies: 'live' as const,
  250. secrets: [],
  251. revision: 0,
  252. }
  253. const document = { writable: true, hasDocument: false, namespaces: [namespace] }
  254. mock.remote.settings.describe.mockResolvedValue(ok(document))
  255. const b = await bench(true, mock)
  256. declare(b.slots)
  257. await b.ctx.plugin({ inject: [...inject], apply }).await()
  258. const entry = b.slots.entries('settings.onboarding')
  259. .find(candidate => candidate.options.id === 'welcome-notice')!
  260. const injected = (
  261. entry.inject as unknown as
  262. () => import('../src/client/WelcomeNotice.tsx').WelcomeNoticeInjected
  263. )()
  264. await injected.controller.load()
  265. await vi.waitFor(() => {
  266. expect(injected.hooks.welcome.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: false })
  267. })
  268. mock.remote.settings.describe.mockResolvedValue(ok({
  269. ...document,
  270. namespaces: [{ ...namespace, value: { [WELCOME_NOTICE_ACK_FIELD]: WELCOME_NOTICE_VERSION }, revision: 1 }],
  271. }))
  272. b.remote.emit('settings/document-updated', ['ui-onboarding', 1])
  273. await vi.waitFor(() => {
  274. expect(injected.hooks.welcome.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true })
  275. })
  276. })
  277. it('joins the refreshed mirror view on a settings invalidation', async () => {
  278. const mock = RemoteMock.create().load(remoteDefaultResponses)
  279. const namespace = { ns: 'llm-test', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 1 }
  280. const document = { writable: true, hasDocument: false, namespaces: [namespace] }
  281. const describe = mock.remote.settings.describe
  282. describe.mockResolvedValue(ok(document))
  283. const listProviders = vi.fn(() => Promise.resolve({ ok: true as const, value: [] }))
  284. const b = await bench(true, mock, { listProviders })
  285. declare(b.slots)
  286. await b.ctx.plugin({ inject: [...inject], apply }).await()
  287. const entry = b.slots.entries('settings.section')
  288. .find(candidate => candidate.options.id === 'models')!
  289. const injected = (
  290. entry.inject as unknown as
  291. () => import('../src/client/ModelsSection.tsx').ModelsSectionInjected
  292. )()
  293. await injected.controller.load()
  294. expect(injected.hooks.snapshot.getSnapshot().namespaces.get('llm-test')?.revision).toBe(1)
  295. describe.mockResolvedValue(ok({ ...document, namespaces: [{ ...namespace, revision: 2 }] }))
  296. b.remote.emit('settings/document-updated', ['llm-test', 2])
  297. await vi.waitFor(() => {
  298. expect(injected.hooks.snapshot.getSnapshot().namespaces.get('llm-test')?.revision).toBe(2)
  299. })
  300. expect(describe).toHaveBeenCalledTimes(2)
  301. })
  302. })