settings-scope.spec.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352
  1. import { Context } from '@deepseek-ai/cordis'
  2. import z from '@deepseek-ai/schemastery'
  3. import { describe, expect, it, vi } from 'vitest'
  4. import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
  5. import {
  6. bindSettingsScope, SettingsScopeController, type SettingsScope,
  7. } from '../src/client/settings-scope.ts'
  8. interface UiTestSettings {
  9. preference: 'light' | 'dark' | 'system'
  10. }
  11. const ENVELOPE = z.object({
  12. preference: z.union(['light', 'dark', 'system']).default('system'),
  13. }).toJSON()
  14. let rpc = 0
  15. function ok<T>(value: T): RpcResponse<T> {
  16. return { rpcId: `scope-${rpc++}` as never, result: { ok: true, value } }
  17. }
  18. function rejected<T>(): RpcResponse<T> {
  19. return {
  20. rpcId: `scope-${rpc++}` as never,
  21. result: {
  22. ok: false,
  23. error: { code: 'settings-rejected', message: 'conflict', details: { ns: 'ui-test' } },
  24. },
  25. }
  26. }
  27. function view(value: unknown, revision = 0): SettingsNamespaceView {
  28. return {
  29. ns: 'ui-test',
  30. schema: ENVELOPE,
  31. value,
  32. applies: 'live',
  33. secrets: [],
  34. revision,
  35. }
  36. }
  37. function described(value: unknown, revision = 0) {
  38. return ok({ writable: true, hasDocument: true, namespaces: [view(value, revision)] })
  39. }
  40. function deferred<T>() {
  41. let resolve!: (value: T) => void
  42. let reject!: (reason: unknown) => void
  43. const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
  44. return { promise, resolve, reject }
  45. }
  46. /** Record each distinct published section, starting from the current one. */
  47. function trackValues(scope: SettingsScope<UiTestSettings>): Array<UiTestSettings | undefined> {
  48. const seen: Array<UiTestSettings | undefined> = [scope.getSnapshot().value]
  49. scope.subscribe(() => {
  50. const value = scope.getSnapshot().value
  51. if (value !== seen[seen.length - 1]) seen.push(value)
  52. })
  53. return seen
  54. }
  55. describe('SettingsScopeController', () => {
  56. it('starts loading and publishes a schema-valid section with revision and writability', async () => {
  57. const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'dark' }, 3))
  58. const scope = new SettingsScopeController<UiTestSettings>(
  59. { settings: { describe: describeCall } } as never,
  60. { namespace: 'ui-test' },
  61. )
  62. expect(scope.getSnapshot()).toEqual({
  63. status: 'loading', value: undefined, revision: undefined, writable: false, mode: 'host',
  64. })
  65. await scope.load()
  66. expect(scope.getSnapshot()).toEqual({
  67. status: 'ready', value: { preference: 'dark' }, revision: 3, writable: true, mode: 'host',
  68. })
  69. })
  70. it('keeps the last good value across invalid, rejected, and failed reads while tracking revisions', async () => {
  71. const describeCall = vi.fn()
  72. .mockResolvedValueOnce(described({ preference: 'dark' }, 3))
  73. .mockResolvedValueOnce(described({ preference: 'sepia' }, 4))
  74. .mockResolvedValueOnce(described(null, 5))
  75. .mockResolvedValueOnce(described('scalar', 6))
  76. .mockResolvedValueOnce(described(['queue'], 7))
  77. .mockResolvedValueOnce(rejected())
  78. .mockRejectedValueOnce(new Error('offline'))
  79. const scope = new SettingsScopeController<UiTestSettings>(
  80. { settings: { describe: describeCall } } as never,
  81. { namespace: 'ui-test' },
  82. )
  83. const good = trackValues(scope)
  84. for (let i = 0; i < 7; i++) await scope.load()
  85. expect(scope.getSnapshot()).toMatchObject({
  86. status: 'ready', value: { preference: 'dark' }, revision: 7,
  87. })
  88. expect(good).toEqual([undefined, { preference: 'dark' }])
  89. })
  90. it('treats a schema envelope it cannot rehydrate as vouching for no section', async () => {
  91. const broken = { ...view({ preference: 'dark' }, 2), schema: null }
  92. const describeCall = vi.fn()
  93. .mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [broken] }))
  94. const scope = new SettingsScopeController<UiTestSettings>(
  95. { settings: { describe: describeCall } } as never,
  96. { namespace: 'ui-test' },
  97. )
  98. await scope.load()
  99. expect(scope.getSnapshot()).toMatchObject({ status: 'loading', value: undefined, revision: 2 })
  100. })
  101. it('suppresses a superseded read of an unexposed namespace', async () => {
  102. const describeCall = vi.fn()
  103. .mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [] }))
  104. .mockResolvedValueOnce(described({ preference: 'dark' }, 1))
  105. const scope = new SettingsScopeController<UiTestSettings>(
  106. { settings: { describe: describeCall } } as never,
  107. { namespace: 'ui-test' },
  108. )
  109. const statuses: string[] = []
  110. scope.subscribe(() => { statuses.push(scope.getSnapshot().status) })
  111. const stale = scope.load()
  112. const fresh = scope.load()
  113. await Promise.all([stale, fresh])
  114. expect(statuses).not.toContain('unavailable')
  115. expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'dark' } })
  116. })
  117. it('reports an unexposed namespace as unavailable and recovers when it reappears', async () => {
  118. const describeCall = vi.fn()
  119. .mockResolvedValueOnce(described({ preference: 'light' }, 1))
  120. .mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [] }))
  121. .mockResolvedValueOnce(described({ preference: 'system' }, 2))
  122. const scope = new SettingsScopeController<UiTestSettings>(
  123. { settings: { describe: describeCall } } as never,
  124. { namespace: 'ui-test' },
  125. )
  126. await scope.load()
  127. expect(scope.getSnapshot().status).toBe('ready')
  128. await scope.load()
  129. expect(scope.getSnapshot()).toMatchObject({ status: 'unavailable', value: { preference: 'light' } })
  130. await scope.load()
  131. expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'system' }, revision: 2 })
  132. })
  133. it('applies a custom decode override in place of the wire schema', async () => {
  134. const describeCall = vi.fn()
  135. .mockResolvedValueOnce(described({ preference: 'light' }, 1))
  136. .mockResolvedValueOnce(described({ preference: 'dark' }, 2))
  137. const scope = new SettingsScopeController<UiTestSettings>(
  138. { settings: { describe: describeCall } } as never,
  139. {
  140. namespace: 'ui-test',
  141. decode: section => (section as UiTestSettings).preference === 'dark'
  142. ? section as UiTestSettings
  143. : undefined,
  144. },
  145. )
  146. await scope.load()
  147. expect(scope.getSnapshot()).toMatchObject({ status: 'loading', value: undefined, revision: 1 })
  148. await scope.load()
  149. expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'dark' }, revision: 2 })
  150. })
  151. it('serializes rapid set writes, carries revisions, and publishes only the latest settlement', async () => {
  152. const first = deferred<RpcResponse<SettingsNamespaceView>>()
  153. const describeCall = vi.fn().mockResolvedValue(described({ preference: 'system' }, 4))
  154. const mutate = vi.fn()
  155. .mockReturnValueOnce(first.promise)
  156. .mockResolvedValueOnce(ok(view({ preference: 'light' }, 6)))
  157. const scope = new SettingsScopeController<UiTestSettings>(
  158. { settings: { describe: describeCall, mutate } } as never,
  159. { namespace: 'ui-test' },
  160. )
  161. const published = trackValues(scope)
  162. await scope.load()
  163. const dark = scope.set('preference', 'dark')
  164. const light = scope.set('preference', 'light')
  165. await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
  166. first.resolve(ok(view({ preference: 'dark' }, 5)))
  167. await Promise.all([dark, light])
  168. expect(published.map(section => section?.preference)).toEqual([undefined, 'system', 'light'])
  169. expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 6 })
  170. expect(mutate).toHaveBeenNthCalledWith(1, {
  171. ns: 'ui-test',
  172. ops: [{ op: 'set', path: ['preference'], value: 'dark' }],
  173. expectedRevision: 4,
  174. })
  175. expect(mutate).toHaveBeenNthCalledWith(2, {
  176. ns: 'ui-test',
  177. ops: [{ op: 'set', path: ['preference'], value: 'light' }],
  178. expectedRevision: 5,
  179. })
  180. })
  181. it('recovers the latest rejected or thrown write from Host state', async () => {
  182. const describeCall = vi.fn()
  183. .mockResolvedValueOnce(described({ preference: 'system' }, 2))
  184. .mockResolvedValueOnce(described({ preference: 'light' }, 3))
  185. const mutate = vi.fn()
  186. .mockResolvedValueOnce(rejected())
  187. .mockRejectedValueOnce(new Error('offline'))
  188. const scope = new SettingsScopeController<UiTestSettings>(
  189. { settings: { describe: describeCall, mutate } } as never,
  190. { namespace: 'ui-test' },
  191. )
  192. const published = trackValues(scope)
  193. await scope.set('preference', 'dark')
  194. await scope.set('preference', 'system')
  195. expect(published.map(section => section?.preference)).toEqual([undefined, 'system', 'light'])
  196. })
  197. it('does not recover superseded rejected or thrown writes', async () => {
  198. const describeCall = vi.fn()
  199. const mutate = vi.fn()
  200. .mockResolvedValueOnce(rejected())
  201. .mockRejectedValueOnce(new Error('offline'))
  202. .mockResolvedValueOnce(ok(view({ preference: 'light' }, 3)))
  203. const scope = new SettingsScopeController<UiTestSettings>(
  204. { settings: { describe: describeCall, mutate } } as never,
  205. { namespace: 'ui-test' },
  206. )
  207. const published = trackValues(scope)
  208. await Promise.all([
  209. scope.set('preference', 'dark'),
  210. scope.set('preference', 'system'),
  211. scope.set('preference', 'light'),
  212. ])
  213. expect(describeCall).not.toHaveBeenCalled()
  214. expect(published.map(section => section?.preference)).toEqual([undefined, 'light'])
  215. })
  216. it('keeps the write queue usable when a subscriber throws', async () => {
  217. const describeCall = vi.fn()
  218. .mockResolvedValueOnce(described({ preference: 'dark' }, 1))
  219. .mockResolvedValueOnce(described({ preference: 'light' }, 2))
  220. const scope = new SettingsScopeController<UiTestSettings>(
  221. { settings: { describe: describeCall } } as never,
  222. { namespace: 'ui-test' },
  223. )
  224. let thrown = false
  225. scope.subscribe(() => {
  226. if (thrown) return
  227. thrown = true
  228. throw new Error('subscriber failed')
  229. })
  230. await expect(scope.load()).rejects.toThrow('subscriber failed')
  231. await expect(scope.load()).resolves.toBeUndefined()
  232. expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 2 })
  233. })
  234. it('cancels queued and post-dispose writes while draining the in-flight mutation', async () => {
  235. const first = deferred<RpcResponse<SettingsNamespaceView>>()
  236. const mutate = vi.fn().mockReturnValue(first.promise)
  237. const describeCall = vi.fn()
  238. const scope = new SettingsScopeController<UiTestSettings>(
  239. { settings: { describe: describeCall, mutate } } as never,
  240. { namespace: 'ui-test' },
  241. )
  242. const published = trackValues(scope)
  243. const dark = scope.set('preference', 'dark')
  244. await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
  245. const light = scope.set('preference', 'light')
  246. let stopped = false
  247. const stop = scope.dispose().then(() => { stopped = true })
  248. await Promise.resolve()
  249. expect(stopped).toBe(false)
  250. first.resolve(ok(view({ preference: 'dark' }, 1)))
  251. await Promise.all([dark, light, stop])
  252. await scope.set('preference', 'system')
  253. await scope.load()
  254. expect(mutate).toHaveBeenCalledOnce()
  255. expect(describeCall).not.toHaveBeenCalled()
  256. expect(published).toEqual([undefined])
  257. })
  258. it('keeps a remote browser in memory mode without Host calls', async () => {
  259. const describeCall = vi.fn()
  260. const mutate = vi.fn()
  261. const scope = new SettingsScopeController<UiTestSettings>(
  262. { settings: { describe: describeCall, mutate } } as never,
  263. { namespace: 'ui-test' },
  264. 'memory',
  265. )
  266. expect(scope.getSnapshot()).toEqual({
  267. status: 'unavailable', value: undefined, revision: undefined, writable: false, mode: 'memory',
  268. })
  269. await scope.load()
  270. await scope.set('preference', 'dark')
  271. await scope.dispose()
  272. expect(describeCall).not.toHaveBeenCalled()
  273. expect(mutate).not.toHaveBeenCalled()
  274. })
  275. })
  276. describe('bindSettingsScope', () => {
  277. it('subscribes before the initial read and converges to the latest queued invalidation', async () => {
  278. const initial = deferred<ReturnType<typeof described>>()
  279. const describeCall = vi.fn()
  280. .mockReturnValueOnce(initial.promise)
  281. .mockResolvedValueOnce(described({ preference: 'light' }, 2))
  282. .mockResolvedValueOnce(described({ preference: 'system' }, 3))
  283. const ctx = new Context()
  284. ctx.provide('connection', {
  285. api: { settings: { describe: describeCall } },
  286. isLoopback: true,
  287. } as never)
  288. let scope!: SettingsScope<UiTestSettings>
  289. const fiber = ctx.plugin({
  290. inject: ['connection'],
  291. apply: (plugin: Context) => {
  292. scope = bindSettingsScope<UiTestSettings>(plugin, { namespace: 'ui-test' })
  293. },
  294. })
  295. await fiber.await()
  296. await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledOnce() })
  297. ctx.emit('settings/changed', 'unrelated')
  298. ctx.emit('settings/changed', 'ui-test')
  299. ctx.emit('connection/reset')
  300. initial.resolve(described({ preference: 'dark' }, 1))
  301. await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(3) })
  302. await vi.waitFor(() => {
  303. expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'system' }, revision: 3 })
  304. })
  305. await fiber.dispose()
  306. ctx.emit('settings/changed', 'ui-test')
  307. await Promise.resolve()
  308. expect(describeCall).toHaveBeenCalledTimes(3)
  309. })
  310. it('binds a remote browser in memory mode without starting a settings read', async () => {
  311. const describeCall = vi.fn()
  312. const ctx = new Context()
  313. ctx.provide('connection', {
  314. api: { settings: { describe: describeCall } },
  315. isLoopback: false,
  316. } as never)
  317. let scope!: SettingsScope<UiTestSettings>
  318. const fiber = ctx.plugin({
  319. inject: ['connection'],
  320. apply: (plugin: Context) => {
  321. scope = bindSettingsScope<UiTestSettings>(plugin, { namespace: 'ui-test' })
  322. },
  323. })
  324. await fiber.await()
  325. expect(scope.getSnapshot()).toMatchObject({ status: 'unavailable', mode: 'memory', writable: false })
  326. await fiber.dispose()
  327. expect(describeCall).not.toHaveBeenCalled()
  328. })
  329. })