settings-scope.client.spec.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508
  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-api-remotes/client'
  5. import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
  6. import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client'
  7. import { SettingsSchemaService } from '../src/client/schema.ts'
  8. import { SettingsScopeController, SettingsScopeBinder } from '../src/client/settings-scope.ts'
  9. import { SettingsDescribeMirror } from '../src/client/settings-mirror.ts'
  10. const settingsSchema = new SettingsSchemaService(new Context())
  11. interface UiTestSettings {
  12. preference: 'light' | 'dark' | 'system'
  13. }
  14. const ENVELOPE = z.object({
  15. preference: z.union(['light', 'dark', 'system']).default('system'),
  16. }).toJSON()
  17. let rpc = 0
  18. function ok<T>(value: T): RpcResponse<T> {
  19. return { rpcId: `scope-${rpc++}` as never, result: { ok: true, value } }
  20. }
  21. function rejected<T>(): RpcResponse<T> {
  22. return {
  23. rpcId: `scope-${rpc++}` as never,
  24. result: {
  25. ok: false,
  26. error: { code: 'settings-rejected', message: 'conflict', details: { ns: 'ui-test' } },
  27. },
  28. }
  29. }
  30. function view(value: unknown, revision = 0): SettingsNamespaceView {
  31. return {
  32. ns: 'ui-test',
  33. schema: ENVELOPE,
  34. value,
  35. applies: 'live',
  36. secrets: [],
  37. revision,
  38. }
  39. }
  40. function described(value: unknown, revision = 0) {
  41. return ok({ writable: true, hasDocument: true, namespaces: [view(value, revision)] })
  42. }
  43. function deferred<T>() {
  44. let resolve!: (value: T) => void
  45. let reject!: (reason: unknown) => void
  46. const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
  47. return { promise, resolve, reject }
  48. }
  49. /** A host-mode mirror plus a controller derived from it, over one fake wire. */
  50. function derivedScope(
  51. api: { describe?: ReturnType<typeof vi.fn>; mutate?: ReturnType<typeof vi.fn> },
  52. spec: { namespace: string; decode?: (section: unknown) => UiTestSettings | undefined } = { namespace: 'ui-test' },
  53. ) {
  54. const wire = { settings: api } as never
  55. const mirror = new SettingsDescribeMirror(wire)
  56. const scope = new SettingsScopeController<UiTestSettings>(wire, spec, mirror, 'host', settingsSchema)
  57. return { mirror, scope }
  58. }
  59. /** Record each distinct published section, starting from the current one. */
  60. function trackValues(scope: SettingsScope<UiTestSettings>): Array<UiTestSettings | undefined> {
  61. const seen: Array<UiTestSettings | undefined> = [scope.getSnapshot().value]
  62. scope.subscribe(() => {
  63. const value = scope.getSnapshot().value
  64. if (value !== seen[seen.length - 1]) seen.push(value)
  65. })
  66. return seen
  67. }
  68. describe('SettingsScopeController', () => {
  69. it('starts loading and derives a schema-valid section with revision and writability', async () => {
  70. const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'dark' }, 3))
  71. const { mirror, scope } = derivedScope({ describe: describeCall })
  72. expect(scope.getSnapshot()).toEqual({
  73. status: 'loading', value: undefined, revision: undefined, writable: false, mode: 'host',
  74. })
  75. await mirror.load()
  76. expect(scope.getSnapshot()).toEqual({
  77. status: 'ready', value: { preference: 'dark' }, revision: 3, writable: true, mode: 'host',
  78. })
  79. })
  80. it('keeps the last good value across invalid, rejected, and failed reads while tracking revisions', async () => {
  81. const describeCall = vi.fn()
  82. .mockResolvedValueOnce(described({ preference: 'dark' }, 3))
  83. .mockResolvedValueOnce(described({ preference: 'sepia' }, 4))
  84. .mockResolvedValueOnce(described(null, 5))
  85. .mockResolvedValueOnce(described('scalar', 6))
  86. .mockResolvedValueOnce(described(['queue'], 7))
  87. .mockResolvedValueOnce(rejected())
  88. .mockRejectedValueOnce(new Error('offline'))
  89. const { mirror, scope } = derivedScope({ describe: describeCall })
  90. const good = trackValues(scope)
  91. for (let i = 0; i < 7; i++) await mirror.load()
  92. expect(scope.getSnapshot()).toMatchObject({
  93. status: 'ready', value: { preference: 'dark' }, revision: 7,
  94. })
  95. expect(good).toEqual([undefined, { preference: 'dark' }])
  96. })
  97. it('treats a schema envelope it cannot rehydrate as vouching for no section', async () => {
  98. const broken = { ...view({ preference: 'dark' }, 2), schema: null }
  99. const describeCall = vi.fn()
  100. .mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [broken] }))
  101. const { mirror, scope } = derivedScope({ describe: describeCall })
  102. await mirror.load()
  103. expect(scope.getSnapshot()).toMatchObject({ status: 'loading', value: undefined, revision: 2 })
  104. })
  105. it('reports an unexposed namespace as unavailable and recovers when it reappears', async () => {
  106. const describeCall = vi.fn()
  107. .mockResolvedValueOnce(described({ preference: 'light' }, 1))
  108. .mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [] }))
  109. .mockResolvedValueOnce(described({ preference: 'system' }, 2))
  110. const { mirror, scope } = derivedScope({ describe: describeCall })
  111. await mirror.load()
  112. expect(scope.getSnapshot().status).toBe('ready')
  113. await mirror.load()
  114. expect(scope.getSnapshot()).toMatchObject({ status: 'unavailable', value: { preference: 'light' } })
  115. await mirror.load()
  116. expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'system' }, revision: 2 })
  117. })
  118. it('applies a custom decode override in place of the wire schema', async () => {
  119. const describeCall = vi.fn()
  120. .mockResolvedValueOnce(described({ preference: 'light' }, 1))
  121. .mockResolvedValueOnce(described({ preference: 'dark' }, 2))
  122. const { mirror, scope } = derivedScope({ describe: describeCall }, {
  123. namespace: 'ui-test',
  124. decode: section => (section as UiTestSettings).preference === 'dark'
  125. ? section as UiTestSettings
  126. : undefined,
  127. })
  128. await mirror.load()
  129. expect(scope.getSnapshot()).toMatchObject({ status: 'loading', value: undefined, revision: 1 })
  130. await mirror.load()
  131. expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'dark' }, revision: 2 })
  132. })
  133. it('serializes rapid set writes, carries revisions, and publishes only the latest settlement', async () => {
  134. const first = deferred<RpcResponse<SettingsNamespaceView>>()
  135. const describeCall = vi.fn().mockResolvedValue(described({ preference: 'system' }, 4))
  136. const mutate = vi.fn()
  137. .mockReturnValueOnce(first.promise)
  138. .mockResolvedValueOnce(ok(view({ preference: 'light' }, 6)))
  139. const { mirror, scope } = derivedScope({ describe: describeCall, mutate })
  140. const published = trackValues(scope)
  141. await mirror.load()
  142. const dark = scope.set('preference', 'dark')
  143. const light = scope.set('preference', 'light')
  144. await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
  145. first.resolve(ok(view({ preference: 'dark' }, 5)))
  146. await Promise.all([dark, light])
  147. expect(published.map(section => section?.preference)).toEqual([undefined, 'system', 'light'])
  148. expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 6 })
  149. expect(mutate).toHaveBeenNthCalledWith(1, {
  150. ns: 'ui-test',
  151. ops: [{ op: 'set', path: ['preference'], value: 'dark' }],
  152. expectedRevision: 4,
  153. })
  154. expect(mutate).toHaveBeenNthCalledWith(2, {
  155. ns: 'ui-test',
  156. ops: [{ op: 'set', path: ['preference'], value: 'light' }],
  157. expectedRevision: 5,
  158. })
  159. })
  160. it('folds the latest write answer into the mirror so a sibling scope sees it', async () => {
  161. const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'system' }, 4))
  162. const mutate = vi.fn().mockResolvedValueOnce(ok(view({ preference: 'dark' }, 5)))
  163. const wire = { settings: { describe: describeCall, mutate } } as never
  164. const mirror = new SettingsDescribeMirror(wire)
  165. const writer = new SettingsScopeController<UiTestSettings>(wire, { namespace: 'ui-test' }, mirror, 'host', settingsSchema)
  166. const sibling = new SettingsScopeController<UiTestSettings>(wire, { namespace: 'ui-test' }, mirror, 'host', settingsSchema)
  167. await mirror.load()
  168. await writer.set('preference', 'dark')
  169. expect(describeCall).toHaveBeenCalledTimes(1)
  170. expect(sibling.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, revision: 5 })
  171. })
  172. it('re-reads after a revisionless first write lands during the initial read', async () => {
  173. const initial = deferred<ReturnType<typeof described>>()
  174. const describeCall = vi.fn()
  175. .mockReturnValueOnce(initial.promise)
  176. .mockResolvedValueOnce(described({ preference: 'dark' }, 2))
  177. const mutate = vi.fn().mockResolvedValueOnce(ok(view({ preference: 'dark' }, 2)))
  178. const { mirror, scope } = derivedScope({ describe: describeCall, mutate })
  179. const loading = mirror.load()
  180. await Promise.resolve()
  181. await scope.set('preference', 'dark')
  182. initial.resolve(described({ preference: 'system' }, 1))
  183. await loading
  184. expect(mutate).toHaveBeenCalledWith({
  185. ns: 'ui-test',
  186. ops: [{ op: 'set', path: ['preference'], value: 'dark' }],
  187. })
  188. expect(describeCall).toHaveBeenCalledTimes(2)
  189. expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, revision: 2 })
  190. })
  191. it('recovers the latest rejected or thrown write from Host state', async () => {
  192. const describeCall = vi.fn()
  193. .mockResolvedValueOnce(described({ preference: 'system' }, 2))
  194. .mockResolvedValueOnce(described({ preference: 'light' }, 3))
  195. const mutate = vi.fn()
  196. .mockResolvedValueOnce(rejected())
  197. .mockRejectedValueOnce(new Error('offline'))
  198. const { mirror, scope } = derivedScope({ describe: describeCall, mutate })
  199. const published = trackValues(scope)
  200. await mirror.load()
  201. await scope.set('preference', 'dark')
  202. await scope.set('preference', 'system')
  203. expect(published.map(section => section?.preference)).toEqual([undefined, 'system', 'light'])
  204. })
  205. it('does not recover superseded rejected or thrown writes', async () => {
  206. const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'system' }, 2))
  207. const mutate = vi.fn()
  208. .mockResolvedValueOnce(rejected())
  209. .mockRejectedValueOnce(new Error('offline'))
  210. .mockResolvedValueOnce(ok(view({ preference: 'light' }, 3)))
  211. const { mirror, scope } = derivedScope({ describe: describeCall, mutate })
  212. const published = trackValues(scope)
  213. await mirror.load()
  214. await Promise.all([
  215. scope.set('preference', 'dark'),
  216. scope.set('preference', 'system'),
  217. scope.set('preference', 'light'),
  218. ])
  219. expect(describeCall).toHaveBeenCalledTimes(1)
  220. expect(published.map(section => section?.preference)).toEqual([undefined, 'system', 'light'])
  221. })
  222. it('keeps the write queue usable when a subscriber throws', async () => {
  223. const report = vi.spyOn(console, 'error').mockImplementation(() => {})
  224. const describeCall = vi.fn()
  225. .mockResolvedValueOnce(described({ preference: 'dark' }, 1))
  226. .mockResolvedValueOnce(described({ preference: 'light' }, 2))
  227. const { mirror, scope } = derivedScope({ describe: describeCall })
  228. let thrown = false
  229. scope.subscribe(() => {
  230. if (thrown) return
  231. thrown = true
  232. throw new Error('subscriber failed')
  233. })
  234. await expect(mirror.load()).resolves.toBeUndefined()
  235. await expect(mirror.load()).resolves.toBeUndefined()
  236. expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 2 })
  237. expect(report).toHaveBeenCalledWith('[client-store] subscriber failed:', expect.objectContaining({
  238. message: 'subscriber failed',
  239. }))
  240. report.mockRestore()
  241. })
  242. it('keeps the write queue usable when a write publication listener throws', async () => {
  243. const report = vi.spyOn(console, 'error').mockImplementation(() => {})
  244. const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'system' }, 1))
  245. const mutate = vi.fn()
  246. .mockResolvedValueOnce(ok(view({ preference: 'dark' }, 2)))
  247. .mockResolvedValueOnce(ok(view({ preference: 'light' }, 3)))
  248. const { mirror, scope } = derivedScope({ describe: describeCall, mutate })
  249. await mirror.load()
  250. let shouldThrow = true
  251. mirror.subscribe(() => {
  252. if (!shouldThrow) return
  253. shouldThrow = false
  254. throw new Error('write subscriber failed')
  255. })
  256. await expect(scope.set('preference', 'dark')).resolves.toBeUndefined()
  257. await expect(scope.set('preference', 'light')).resolves.toBeUndefined()
  258. expect(mutate).toHaveBeenCalledTimes(2)
  259. expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 3 })
  260. expect(report).toHaveBeenCalledWith('[client-store] subscriber failed:', expect.objectContaining({
  261. message: 'write subscriber failed',
  262. }))
  263. report.mockRestore()
  264. })
  265. it('keeps the write queue usable after a failed mirror fold', async () => {
  266. const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'system' }, 1))
  267. const mutate = vi.fn()
  268. .mockResolvedValueOnce(ok(view({ preference: 'dark' }, 2)))
  269. .mockResolvedValueOnce(ok(view({ preference: 'light' }, 3)))
  270. const { mirror, scope } = derivedScope({ describe: describeCall, mutate })
  271. await mirror.load()
  272. vi.spyOn(mirror, 'acceptView').mockImplementationOnce(() => {
  273. throw new Error('mirror fold failed')
  274. })
  275. await expect(scope.set('preference', 'dark')).rejects.toThrow('mirror fold failed')
  276. await expect(scope.set('preference', 'light')).resolves.toBeUndefined()
  277. expect(mutate).toHaveBeenCalledTimes(2)
  278. expect(mutate).toHaveBeenNthCalledWith(2, {
  279. ns: 'ui-test',
  280. ops: [{ op: 'set', path: ['preference'], value: 'light' }],
  281. expectedRevision: 1,
  282. })
  283. expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 3 })
  284. })
  285. it('cancels queued and post-dispose writes while draining the in-flight mutation', async () => {
  286. const first = deferred<RpcResponse<SettingsNamespaceView>>()
  287. const mutate = vi.fn().mockReturnValue(first.promise)
  288. const describeCall = vi.fn()
  289. const { scope } = derivedScope({ describe: describeCall, mutate })
  290. const published = trackValues(scope)
  291. const dark = scope.set('preference', 'dark')
  292. await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
  293. const light = scope.set('preference', 'light')
  294. let stopped = false
  295. const stop = scope.dispose().then(() => { stopped = true })
  296. await Promise.resolve()
  297. expect(stopped).toBe(false)
  298. first.resolve(ok(view({ preference: 'dark' }, 1)))
  299. await Promise.all([dark, light, stop])
  300. await scope.set('preference', 'system')
  301. expect(mutate).toHaveBeenCalledOnce()
  302. expect(describeCall).not.toHaveBeenCalled()
  303. expect(published).toEqual([undefined])
  304. })
  305. it('stops deriving from the mirror after dispose', async () => {
  306. const describeCall = vi.fn()
  307. .mockResolvedValueOnce(described({ preference: 'dark' }, 1))
  308. .mockResolvedValueOnce(described({ preference: 'light' }, 2))
  309. const { mirror, scope } = derivedScope({ describe: describeCall })
  310. await mirror.load()
  311. expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'dark' } })
  312. await scope.dispose()
  313. await mirror.load()
  314. expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, revision: 1 })
  315. })
  316. it('ignores a mirror notification already queued when disposal starts', async () => {
  317. let notify = (): void => {}
  318. let snapshot = {
  319. status: 'ready' as const,
  320. view: {
  321. writable: true, hasDocument: true,
  322. namespaces: [view({ preference: 'dark' }, 1)],
  323. },
  324. error: null,
  325. }
  326. const mirror = {
  327. getSnapshot: () => snapshot,
  328. subscribe: (listener: () => void) => {
  329. notify = listener
  330. return () => {}
  331. },
  332. } as never
  333. const wire = { settings: {} } as never
  334. const scope = new SettingsScopeController<UiTestSettings>(
  335. wire, { namespace: 'ui-test' }, mirror, 'host', settingsSchema)
  336. expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, revision: 1 })
  337. await scope.dispose()
  338. snapshot = {
  339. ...snapshot,
  340. view: { ...snapshot.view, namespaces: [view({ preference: 'light' }, 2)] },
  341. }
  342. notify()
  343. expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, revision: 1 })
  344. })
  345. it('keeps a remote browser in memory mode without Host calls', async () => {
  346. const describeCall = vi.fn()
  347. const mutate = vi.fn()
  348. const wire = { settings: { describe: describeCall, mutate } } as never
  349. const mirror = new SettingsDescribeMirror(wire, 'memory')
  350. const scope = new SettingsScopeController<UiTestSettings>(
  351. wire, { namespace: 'ui-test' }, mirror, 'memory', settingsSchema)
  352. expect(scope.getSnapshot()).toEqual({
  353. status: 'unavailable', value: undefined, revision: undefined, writable: false, mode: 'memory',
  354. })
  355. await mirror.load()
  356. await scope.set('preference', 'dark')
  357. await scope.dispose()
  358. expect(describeCall).not.toHaveBeenCalled()
  359. expect(mutate).not.toHaveBeenCalled()
  360. })
  361. it('carries the composition base and the user layer into the snapshot', async () => {
  362. const layered: SettingsNamespaceView = {
  363. ...view({ preference: 'dark' }, 3),
  364. base: { preference: 'system' },
  365. user: { preference: 'dark' },
  366. }
  367. const describeCall = vi.fn()
  368. .mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [layered] }))
  369. const { mirror, scope } = derivedScope({ describe: describeCall })
  370. await mirror.load()
  371. expect(scope.getSnapshot()).toMatchObject({
  372. value: { preference: 'dark' },
  373. base: { preference: 'system' },
  374. user: { preference: 'dark' },
  375. })
  376. })
  377. it('reports an inherited field as absent from the user layer', async () => {
  378. const inherited: SettingsNamespaceView = { ...view({ preference: 'system' }, 1), base: { preference: 'system' } }
  379. const describeCall = vi.fn()
  380. .mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [inherited] }))
  381. const { mirror, scope } = derivedScope({ describe: describeCall })
  382. await mirror.load()
  383. expect(scope.getSnapshot().user).toBeUndefined()
  384. })
  385. it('clears one field through an unset op fenced by the held revision', async () => {
  386. const mutate = vi.fn().mockResolvedValueOnce(ok(view({ preference: 'system' }, 4)))
  387. const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'dark' }, 3))
  388. const { mirror, scope } = derivedScope({ describe: describeCall, mutate })
  389. await mirror.load()
  390. await scope.unset('preference')
  391. expect(mutate).toHaveBeenCalledWith({
  392. ns: 'ui-test',
  393. ops: [{ op: 'unset', path: ['preference'] }],
  394. expectedRevision: 3,
  395. })
  396. expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'system' }, revision: 4 })
  397. })
  398. it('recovers the Host state when the latest clear is refused', async () => {
  399. const mutate = vi.fn().mockResolvedValueOnce(rejected())
  400. const describeCall = vi.fn()
  401. .mockResolvedValueOnce(described({ preference: 'dark' }, 3))
  402. .mockResolvedValueOnce(described({ preference: 'light' }, 5))
  403. const { mirror, scope } = derivedScope({ describe: describeCall, mutate })
  404. await mirror.load()
  405. await scope.unset('preference')
  406. expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 5 })
  407. })
  408. })
  409. describe('SettingsScopeBinder.bind', () => {
  410. it('shares one mirror read across bound scopes and disposes each with its fiber', async () => {
  411. const describeCall = vi.fn().mockResolvedValue(described({ preference: 'dark' }, 1))
  412. const wire = { settings: { describe: describeCall } }
  413. const mirror = new SettingsDescribeMirror(wire as never)
  414. const ctx = new Context()
  415. ctx.provide('connection', { api: wire, isLoopback: true } as never)
  416. let theme!: SettingsScope<UiTestSettings>
  417. let locale!: SettingsScope<UiTestSettings>
  418. new TestRemote(ctx)
  419. await ctx.plugin(SettingsScopeBinder, { mirror, schema: settingsSchema }).await()
  420. expect(ctx.settingsScope.describe()).toBe(mirror)
  421. const fiber = ctx.plugin({
  422. inject: ['connection', 'remote', 'settingsScope'],
  423. apply: (plugin: Context) => {
  424. theme = plugin.settingsScope.bind<UiTestSettings>({ namespace: 'ui-test' })
  425. locale = plugin.settingsScope.bind<UiTestSettings>({ namespace: 'ui-test' })
  426. },
  427. })
  428. await fiber.await()
  429. await vi.waitFor(() => {
  430. expect(theme.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'dark' } })
  431. expect(locale.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'dark' } })
  432. })
  433. expect(describeCall).toHaveBeenCalledTimes(1)
  434. await fiber.dispose()
  435. await mirror.load()
  436. expect(theme.getSnapshot()).toMatchObject({ revision: 1 })
  437. })
  438. it('binds a remote browser in memory mode without starting a settings read', async () => {
  439. const describeCall = vi.fn()
  440. const wire = { settings: { describe: describeCall } }
  441. const mirror = new SettingsDescribeMirror(wire as never, 'memory')
  442. const ctx = new Context()
  443. ctx.provide('connection', { api: wire, isLoopback: false } as never)
  444. let scope!: SettingsScope<UiTestSettings>
  445. new TestRemote(ctx)
  446. await ctx.plugin(SettingsScopeBinder, { mirror, schema: settingsSchema }).await()
  447. const fiber = ctx.plugin({
  448. inject: ['connection', 'remote', 'settingsScope'],
  449. apply: (plugin: Context) => {
  450. scope = plugin.settingsScope.bind<UiTestSettings>({ namespace: 'ui-test' })
  451. },
  452. })
  453. await fiber.await()
  454. expect(scope.getSnapshot()).toMatchObject({ status: 'unavailable', mode: 'memory', writable: false })
  455. await fiber.dispose()
  456. expect(describeCall).not.toHaveBeenCalled()
  457. })
  458. })