settings-mirror.client.spec.ts 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219
  1. import { describe, expect, it, vi } from 'vitest'
  2. import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
  3. import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
  4. import { SettingsDescribeMirror, type SettingsDescribeView } from '../src/client/settings-mirror.ts'
  5. /** What a Remote call answers with: no carrier envelope, and a typed failure. */
  6. type Answer<T> =
  7. | { ok: true; value: T }
  8. | { ok: false; error: RemoteError }
  9. function ok<T>(value: T): Answer<T> {
  10. return { ok: true, value }
  11. }
  12. function rejected<T>(message: string): Answer<T> {
  13. return { ok: false, error: new RemoteError('settings/rejected', message, { ns: 'theme' }) }
  14. }
  15. /** The providing plugin's context, scripted down to the one method the mirror calls. */
  16. function ctxWith(describeCall: unknown) {
  17. return { remote: { settings: { describe: describeCall } } } as never
  18. }
  19. function view(ns: string, revision = 0): SettingsNamespaceView {
  20. return { ns, schema: {}, value: { field: ns }, applies: 'live', secrets: [], revision }
  21. }
  22. function described(namespaces: SettingsNamespaceView[]): Answer<SettingsDescribeView> {
  23. return ok({ writable: true, hasDocument: true, namespaces })
  24. }
  25. function deferred<T>() {
  26. let resolve!: (value: T) => void
  27. const promise = new Promise<T>((res) => { resolve = res })
  28. return { promise, resolve }
  29. }
  30. describe('SettingsDescribeMirror', () => {
  31. it('folds loads before the wire read into it, and mid-flight loads into one rerun', async () => {
  32. const gate = deferred<Answer<SettingsDescribeView>>()
  33. const describeCall = vi.fn()
  34. .mockReturnValueOnce(gate.promise)
  35. .mockResolvedValue(described([view('theme', 1)]))
  36. const mirror = new SettingsDescribeMirror(ctxWith(describeCall))
  37. const first = mirror.load()
  38. // Issued before the wire read goes out: covered by that read, no rerun.
  39. const early = mirror.load()
  40. await Promise.resolve()
  41. expect(describeCall).toHaveBeenCalledTimes(1)
  42. // Issued while the read is on the wire: exactly one rerun, however many.
  43. const mid = mirror.load()
  44. const midToo = mirror.load()
  45. gate.resolve(described([view('theme', 0)]))
  46. await Promise.all([first, early, mid, midToo])
  47. expect(describeCall).toHaveBeenCalledTimes(2)
  48. expect(mirror.getSnapshot().status).toBe('ready')
  49. expect(mirror.namespace('theme')?.revision).toBe(1)
  50. })
  51. it('keeps the last good view when a later refresh fails, recording the failure', async () => {
  52. const describeCall = vi.fn()
  53. .mockResolvedValueOnce(described([view('theme', 2)]))
  54. .mockRejectedValueOnce(new Error('host gone'))
  55. .mockResolvedValueOnce(rejected('busy'))
  56. const mirror = new SettingsDescribeMirror(ctxWith(describeCall))
  57. await mirror.load()
  58. expect(mirror.getSnapshot()).toMatchObject({ status: 'ready', error: null })
  59. await mirror.load()
  60. expect(mirror.getSnapshot()).toMatchObject({ status: 'ready', error: 'host gone' })
  61. expect(mirror.namespace('theme')?.revision).toBe(2)
  62. await mirror.load()
  63. expect(mirror.getSnapshot()).toMatchObject({ status: 'ready', error: 'busy' })
  64. expect(mirror.getSnapshot().view?.namespaces).toHaveLength(1)
  65. })
  66. it('returns to idle after a first read that never succeeded, so ensure retries', async () => {
  67. const describeCall = vi.fn()
  68. .mockRejectedValueOnce(new Error('offline'))
  69. .mockResolvedValueOnce(described([view('theme', 1)]))
  70. const mirror = new SettingsDescribeMirror(ctxWith(describeCall))
  71. await mirror.ensure()
  72. expect(mirror.getSnapshot()).toMatchObject({ status: 'idle', view: undefined, error: 'offline' })
  73. await mirror.ensure()
  74. expect(mirror.getSnapshot()).toMatchObject({ status: 'ready', error: null })
  75. expect(describeCall).toHaveBeenCalledTimes(2)
  76. })
  77. it('treats ensure as a no-op once ready', async () => {
  78. const describeCall = vi.fn().mockResolvedValue(described([view('theme', 1)]))
  79. const mirror = new SettingsDescribeMirror(ctxWith(describeCall))
  80. await mirror.ensure()
  81. await mirror.ensure()
  82. await mirror.ensure()
  83. expect(describeCall).toHaveBeenCalledTimes(1)
  84. })
  85. it('memory persistence is terminally unavailable and never touches the wire', async () => {
  86. const describeCall = vi.fn()
  87. const mirror = new SettingsDescribeMirror(ctxWith(describeCall), 'memory')
  88. await mirror.ensure()
  89. await mirror.load()
  90. expect(mirror.getSnapshot()).toEqual({ status: 'unavailable', view: undefined, error: null })
  91. expect(describeCall).not.toHaveBeenCalled()
  92. })
  93. it('acceptView folds one write answer into the held view without a wire read', async () => {
  94. const describeCall = vi.fn()
  95. .mockResolvedValueOnce(described([view('theme', 1), view('locale', 4)]))
  96. const mirror = new SettingsDescribeMirror(ctxWith(describeCall))
  97. await mirror.load()
  98. const seen: number[] = []
  99. mirror.subscribe(() => { seen.push(mirror.namespace('theme')?.revision ?? -1) })
  100. mirror.acceptView(view('theme', 9))
  101. expect(mirror.namespace('theme')?.revision).toBe(9)
  102. expect(mirror.namespace('locale')?.revision).toBe(4)
  103. expect(seen).toEqual([9])
  104. expect(describeCall).toHaveBeenCalledTimes(1)
  105. })
  106. it('acceptView before any answer is a no-op instead of inventing a document', () => {
  107. const describeCall = vi.fn()
  108. const mirror = new SettingsDescribeMirror(ctxWith(describeCall))
  109. mirror.acceptView(view('theme', 1))
  110. expect(mirror.getSnapshot()).toEqual({ status: 'idle', view: undefined, error: null })
  111. })
  112. it('acceptView appends a namespace the held view has not seen yet', async () => {
  113. const describeCall = vi.fn().mockResolvedValueOnce(described([view('theme', 1)]))
  114. const mirror = new SettingsDescribeMirror(ctxWith(describeCall))
  115. await mirror.load()
  116. mirror.acceptView(view('fresh-ns', 0))
  117. expect(mirror.namespace('fresh-ns')).toBeDefined()
  118. expect(mirror.getSnapshot().view?.namespaces).toHaveLength(2)
  119. })
  120. it('never loses a load landing between a run settling and its slot clearing', async () => {
  121. // Regression: with the in-flight slot cleared by a promise .finally(),
  122. // a load() in the one-microtask gap after the rerun check marked a rerun
  123. // nobody read, and that refresh never reached the wire.
  124. const describeCall = vi.fn().mockResolvedValue(described([view('theme', 1)]))
  125. const mirror = new SettingsDescribeMirror(ctxWith(describeCall))
  126. void mirror.load()
  127. await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(1) })
  128. void mirror.load()
  129. await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(2) })
  130. void mirror.load()
  131. await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(3) })
  132. })
  133. it('starts no second run for a load issued inside the loading publish', async () => {
  134. const gate = deferred<Answer<SettingsDescribeView>>()
  135. const describeCall = vi.fn().mockReturnValue(gate.promise)
  136. const mirror = new SettingsDescribeMirror(ctxWith(describeCall))
  137. let reentered = false
  138. const unsubscribe = mirror.subscribe(() => {
  139. if (reentered) return
  140. reentered = true
  141. void mirror.load()
  142. })
  143. const loading = mirror.load()
  144. await Promise.resolve()
  145. expect(describeCall).toHaveBeenCalledTimes(1)
  146. gate.resolve(described([view('theme', 1)]))
  147. await loading
  148. unsubscribe()
  149. // The reentrant load folded into the first run rather than racing it.
  150. expect(describeCall).toHaveBeenCalledTimes(1)
  151. expect(mirror.getSnapshot().status).toBe('ready')
  152. })
  153. it('lets the first read cover a write folded inside the loading publish', async () => {
  154. const describeCall = vi.fn().mockResolvedValue(described([view('theme', 2)]))
  155. const mirror = new SettingsDescribeMirror(ctxWith(describeCall))
  156. const unsubscribe = mirror.subscribe(() => {
  157. unsubscribe()
  158. mirror.acceptView(view('theme', 2))
  159. })
  160. await mirror.load()
  161. expect(describeCall).toHaveBeenCalledTimes(1)
  162. expect(mirror.getSnapshot().status).toBe('ready')
  163. expect(mirror.namespace('theme')?.revision).toBe(2)
  164. })
  165. it('re-reads after a folded write invalidates an in-flight document', async () => {
  166. const slow = deferred<Answer<SettingsDescribeView>>()
  167. const describeCall = vi.fn()
  168. .mockResolvedValueOnce(described([view('theme', 4), view('locale', 1)]))
  169. .mockReturnValueOnce(slow.promise)
  170. .mockResolvedValueOnce(described([view('theme', 5), view('locale', 2)]))
  171. const mirror = new SettingsDescribeMirror(ctxWith(describeCall))
  172. await mirror.load()
  173. expect(describeCall).toHaveBeenCalledTimes(1)
  174. const stale = mirror.load()
  175. await Promise.resolve()
  176. mirror.acceptView(view('theme', 5))
  177. slow.resolve(described([view('theme', 4), view('locale', 2)]))
  178. await stale
  179. expect(describeCall).toHaveBeenCalledTimes(3)
  180. expect(mirror.namespace('theme')?.revision).toBe(5)
  181. expect(mirror.namespace('locale')?.revision).toBe(2)
  182. })
  183. it('re-reads after a pre-answer write invalidates the in-flight document', async () => {
  184. const slow = deferred<Answer<SettingsDescribeView>>()
  185. const describeCall = vi.fn()
  186. .mockReturnValueOnce(slow.promise)
  187. .mockResolvedValueOnce(described([view('theme', 2)]))
  188. const mirror = new SettingsDescribeMirror(ctxWith(describeCall))
  189. const loading = mirror.load()
  190. await Promise.resolve()
  191. mirror.acceptView(view('theme', 2))
  192. slow.resolve(described([view('theme', 1)]))
  193. await loading
  194. expect(describeCall).toHaveBeenCalledTimes(2)
  195. expect(mirror.namespace('theme')?.revision).toBe(2)
  196. })
  197. })