store.client.spec.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299
  1. /** Page-store join: directory × namespaces × credentials, with last-good rows on failure. */
  2. import { describe, expect, it } from 'vitest'
  3. import type { RpcResponse } from '@deepseek-ai/dsh-api-remotes/client'
  4. import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts'
  5. import { settingsSchema } from './settings-schema.client.ts'
  6. import { messageOf, ModelsSettingsStore } from '../src/client/store.ts'
  7. let nextRpc = 0
  8. function ok<T>(value: T): RpcResponse<T> {
  9. return { rpcId: `r-${nextRpc++}` as never, result: { ok: true, value } }
  10. }
  11. function fail<T>(message: string): RpcResponse<T> {
  12. return { rpcId: `r-${nextRpc++}` as never, result: { ok: false, error: { code: 'internal', message, details: {} } } }
  13. }
  14. const DIRECTORY = [
  15. { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true },
  16. { provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: true },
  17. { provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false },
  18. { provider: 'ghost', displayName: 'Ghost', settingsNs: '', settingsPath: [], active: true },
  19. ]
  20. const NAMESPACES = [
  21. {
  22. ns: 'llm-deepseek',
  23. schema: {},
  24. value: { apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://base' },
  25. base: { baseURL: 'https://base' },
  26. applies: 'live' as const,
  27. secrets: [],
  28. revision: 0,
  29. },
  30. {
  31. ns: 'llm-pi-ai',
  32. schema: {},
  33. value: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY' } } },
  34. user: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY' } } },
  35. applies: 'live' as const,
  36. secrets: [],
  37. revision: 0,
  38. },
  39. ]
  40. function api(overrides: {
  41. providers?: () => Promise<RpcResponse<{ providers: typeof DIRECTORY }>>
  42. describeSettings?: () => Promise<RpcResponse<{ writable: boolean; namespaces: typeof NAMESPACES }>>
  43. describeCredentials?: (refs: string[]) => Promise<RpcResponse<{ credentials: Record<string, unknown> }>>
  44. } = {}) {
  45. const seenRefs: string[][] = []
  46. const face = {
  47. llm: {
  48. providers: overrides.providers ?? (() => Promise.resolve(ok({ providers: DIRECTORY }))),
  49. models: () => Promise.resolve(ok({ groups: [], failures: [] })),
  50. },
  51. settings: {
  52. describe: overrides.describeSettings ?? (() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: NAMESPACES }))),
  53. update: () => Promise.resolve(fail('unused')),
  54. replace: () => Promise.resolve(fail('unused')),
  55. },
  56. credentials: {
  57. describe: (payload: { refs: string[] }) => {
  58. seenRefs.push(payload.refs)
  59. return (overrides.describeCredentials ?? (refs => Promise.resolve(ok({
  60. credentials: Object.fromEntries(refs.map(ref => [ref, { configured: ref === 'OPENAI_API_KEY', writable: true }])),
  61. }))))(payload.refs)
  62. },
  63. set: () => Promise.resolve(ok({})),
  64. unset: () => Promise.resolve(ok({})),
  65. },
  66. }
  67. const wire = face as never
  68. return { face: wire, mirror: new SettingsDescribeMirror(wire), seenRefs }
  69. }
  70. describe('ModelsSettingsStore', () => {
  71. it('joins rows with configured, removable, and credential state', async () => {
  72. const { face, mirror, seenRefs } = api()
  73. const store = new ModelsSettingsStore(face, settingsSchema, mirror)
  74. await store.load()
  75. const state = store.store.getSnapshot()
  76. expect(state.status).toBe('ready')
  77. expect(state.writable).toBe(true)
  78. expect(state.credentialError).toBeNull()
  79. expect(seenRefs).toEqual([['DEEPSEEK_API_KEY', 'OPENAI_API_KEY']])
  80. const byProvider = new Map(state.rows.map(row => [row.entry.provider, row]))
  81. expect(byProvider.get('deepseek-official')).toMatchObject({
  82. configured: true,
  83. removable: false,
  84. apiKeyEnv: 'DEEPSEEK_API_KEY',
  85. credential: { configured: false, writable: true },
  86. })
  87. expect(byProvider.get('openai')).toMatchObject({
  88. configured: true,
  89. removable: true,
  90. apiKeyEnv: 'OPENAI_API_KEY',
  91. credential: { configured: true },
  92. })
  93. expect(byProvider.get('anthropic')).toMatchObject({ configured: false, removable: false })
  94. expect(byProvider.get('anthropic')?.apiKeyEnv).toBeUndefined()
  95. expect(byProvider.get('ghost')).toMatchObject({ configured: false, removable: false })
  96. expect(state.namespaces.get('llm-pi-ai')?.ns).toBe('llm-pi-ai')
  97. })
  98. it('degrades the credential badge, not the page, when the credential domain fails', async () => {
  99. const { face, mirror } = api({ describeCredentials: () => Promise.resolve(fail('no provider')) })
  100. const store = new ModelsSettingsStore(face, settingsSchema, mirror)
  101. await store.load()
  102. const state = store.store.getSnapshot()
  103. expect(state.status).toBe('ready')
  104. expect(state.credentialError).toBe('no provider')
  105. expect(state.rows.every(row => row.credential === undefined)).toBe(true)
  106. })
  107. it('settles a credential transport rejection without leaving the store loading', async () => {
  108. const { face, mirror } = api({
  109. describeCredentials: () => Promise.reject(new Error('credential transport down')),
  110. })
  111. const store = new ModelsSettingsStore(face, settingsSchema, mirror)
  112. await expect(store.load()).resolves.toBeUndefined()
  113. expect(store.store.getSnapshot()).toMatchObject({
  114. status: 'ready',
  115. credentialError: 'credential transport down',
  116. })
  117. })
  118. it('stringifies a non-Error credential transport rejection', async () => {
  119. const { face, mirror } = api({
  120. describeCredentials: async () => { throw 'credential transport refusal' },
  121. })
  122. const store = new ModelsSettingsStore(face, settingsSchema, mirror)
  123. await expect(store.load()).resolves.toBeUndefined()
  124. expect(store.store.getSnapshot().credentialError).toBe('credential transport refusal')
  125. })
  126. it('surfaces a directory failure and keeps the last good rows', async () => {
  127. const { face, mirror } = api()
  128. const store = new ModelsSettingsStore(face, settingsSchema, mirror)
  129. await store.load()
  130. expect(store.store.getSnapshot().rows).toHaveLength(4)
  131. const broken = api({ providers: () => Promise.resolve(fail('directory down')) })
  132. const failing = new ModelsSettingsStore(broken.face, settingsSchema, broken.mirror)
  133. await failing.load()
  134. expect(failing.store.getSnapshot()).toMatchObject({ status: 'error', error: 'directory down' })
  135. // The first store's snapshot is untouched by the second's failure.
  136. expect(store.store.getSnapshot().status).toBe('ready')
  137. })
  138. it('lets the newest load win over a stale slow response', async () => {
  139. let release: (() => void) | undefined
  140. const gate = new Promise<void>((resolve) => { release = resolve })
  141. let call = 0
  142. const { face, mirror } = api({
  143. providers: async () => {
  144. call += 1
  145. if (call === 1) {
  146. await gate
  147. return fail('stale slow failure')
  148. }
  149. return ok({ providers: DIRECTORY })
  150. },
  151. })
  152. const store = new ModelsSettingsStore(face, settingsSchema, mirror)
  153. const first = store.load()
  154. const second = store.load()
  155. release?.()
  156. await Promise.all([first, second])
  157. expect(store.store.getSnapshot().status).toBe('ready')
  158. })
  159. })
  160. describe('edge joins', () => {
  161. it('treats a non-object profile as having no credential reference', async () => {
  162. const { face, mirror } = api({
  163. describeSettings: () => Promise.resolve(ok({
  164. writable: true,
  165. hasDocument: false,
  166. namespaces: [{
  167. ns: 'llm-pi-ai',
  168. schema: {},
  169. value: { providers: { weird: 'oops' } },
  170. applies: 'live' as const,
  171. secrets: [],
  172. revision: 0,
  173. }] as never,
  174. })),
  175. providers: () => Promise.resolve(ok({
  176. providers: [
  177. { provider: 'weird', displayName: 'weird', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'weird'], active: false },
  178. ] as never,
  179. })),
  180. })
  181. const store = new ModelsSettingsStore(face, settingsSchema, mirror)
  182. await store.load()
  183. const state = store.store.getSnapshot()
  184. expect(state.rows[0]).toMatchObject({ configured: true, removable: false })
  185. expect(state.rows[0]?.apiKeyEnv).toBeUndefined()
  186. })
  187. it('skips the credential describe entirely when no row names a reference', async () => {
  188. const { face, mirror, seenRefs } = api({
  189. describeSettings: () => Promise.resolve(ok({
  190. writable: true,
  191. hasDocument: false,
  192. namespaces: [{ ns: 'llm-pi-ai', schema: {}, value: { providers: {} }, applies: 'live' as const, secrets: [], revision: 0 }] as never,
  193. })),
  194. providers: () => Promise.resolve(ok({
  195. providers: [
  196. { provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false },
  197. ] as never,
  198. })),
  199. })
  200. const store = new ModelsSettingsStore(face, settingsSchema, mirror)
  201. await store.load()
  202. expect(seenRefs).toEqual([])
  203. expect(store.store.getSnapshot().status).toBe('ready')
  204. })
  205. it('surfaces a settings describe failure', async () => {
  206. const { face, mirror } = api({ describeSettings: () => Promise.resolve(fail('settings down')) })
  207. const store = new ModelsSettingsStore(face, settingsSchema, mirror)
  208. await store.load()
  209. expect(store.store.getSnapshot()).toMatchObject({ status: 'error', error: 'settings down' })
  210. })
  211. it('reports a terminally unavailable settings mirror precisely', async () => {
  212. const { face } = api()
  213. const store = new ModelsSettingsStore(
  214. face,
  215. settingsSchema,
  216. new SettingsDescribeMirror(face, 'memory'),
  217. )
  218. await store.load()
  219. expect(store.store.getSnapshot()).toMatchObject({
  220. status: 'error',
  221. error: 'settings are unavailable in this browser',
  222. })
  223. })
  224. it('reuses a held settings view after its refresh fails', async () => {
  225. let settingsCall = 0
  226. const { face, mirror } = api({
  227. describeSettings: () => {
  228. settingsCall += 1
  229. return Promise.resolve(settingsCall === 1
  230. ? ok({ writable: true, hasDocument: false, namespaces: NAMESPACES })
  231. : fail('settings refresh down'))
  232. },
  233. })
  234. const store = new ModelsSettingsStore(face, settingsSchema, mirror)
  235. await store.load()
  236. await mirror.load()
  237. expect(mirror.getSnapshot().error).toBe('settings refresh down')
  238. await store.load()
  239. expect(store.store.getSnapshot()).toMatchObject({ status: 'ready', error: null })
  240. expect(store.store.getSnapshot().rows).toHaveLength(4)
  241. })
  242. it('stringifies a non-Error load failure', async () => {
  243. // The wire can surface non-Error throwables; the store must stringify them.
  244. const { face, mirror } = api({ providers: async () => { throw 'plain refusal' } })
  245. const store = new ModelsSettingsStore(face, settingsSchema, mirror)
  246. await store.load()
  247. expect(store.store.getSnapshot()).toMatchObject({ status: 'error', error: 'plain refusal' })
  248. })
  249. it('drops a stale successful response after a newer load finished', async () => {
  250. let release: (() => void) | undefined
  251. const gate = new Promise<void>((resolve) => { release = resolve })
  252. let call = 0
  253. const { face, mirror } = api({
  254. providers: async () => {
  255. call += 1
  256. if (call === 1) {
  257. await gate
  258. return ok({ providers: [] as never })
  259. }
  260. return ok({ providers: DIRECTORY })
  261. },
  262. })
  263. const store = new ModelsSettingsStore(face, settingsSchema, mirror)
  264. const first = store.load()
  265. const second = store.load()
  266. await second
  267. release?.()
  268. await first
  269. // The stale empty directory never overwrote the newer join.
  270. expect(store.store.getSnapshot().rows).toHaveLength(4)
  271. })
  272. })
  273. describe('messageOf', () => {
  274. it('reads an Error message, and stringifies anything else a rejection may carry', () => {
  275. // The wire layer rejects with an Error, but a host or a runtime can reject
  276. // with any value, and the page still has to render something.
  277. expect(messageOf(new Error('connection lost'))).toBe('connection lost')
  278. expect(messageOf('the host refused')).toBe('the host refused')
  279. expect(messageOf(undefined)).toBe('undefined')
  280. })
  281. })