loader-composition.spec.ts 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145
  1. /**
  2. * Real-composition guard: the provider and a consumer plugin boot from a
  3. * test-only cordis.yml through the actual Loader + Include path, an external
  4. * edit of settings.yaml hot-publishes into the consumer's scope, and the same
  5. * consumer booted WITHOUT a settings entry keeps its entry-config resolution —
  6. * the documented optional-inject fallback.
  7. */
  8. import { mkdtemp, rm, writeFile } from 'node:fs/promises'
  9. import { tmpdir } from 'node:os'
  10. import { join } from 'node:path'
  11. import { pathToFileURL } from 'node:url'
  12. import { afterEach, describe, expect, it, vi } from 'vitest'
  13. import { Context } from '@deepseek-ai/cordis'
  14. import Loader from '@deepseek-ai/cordis-plugin-loader'
  15. import Include from '@deepseek-ai/cordis-plugin-include'
  16. import z from '@deepseek-ai/schemastery'
  17. import { type SettingsScope } from '@deepseek-ai/dsh-settings'
  18. import FileSettingsProvider from '../src/index.ts'
  19. interface ThemeConfig {
  20. theme: 'dark' | 'light'
  21. fontSize: number
  22. }
  23. const ThemeSchema: z<ThemeConfig> = z.object({
  24. theme: z.union(['dark', 'light']).default('dark'),
  25. fontSize: z.number().default(14),
  26. })
  27. let root: string | undefined
  28. let context: Context | undefined
  29. afterEach(async () => {
  30. await context?.fiber.dispose()
  31. context = undefined
  32. if (root !== undefined) await rm(root, { recursive: true, force: true })
  33. root = undefined
  34. })
  35. interface ConsumerState {
  36. scope: SettingsScope<ThemeConfig> | undefined
  37. seen: ThemeConfig[]
  38. /** What the consumer is actually running with, settings or not. */
  39. applied: ThemeConfig | undefined
  40. }
  41. async function loadComposition(
  42. options?: { withSettings?: boolean },
  43. ): Promise<{ ctx: Context; state: ConsumerState; settingsPath: string }> {
  44. const withSettings = options?.withSettings ?? true
  45. root = await mkdtemp(join(tmpdir(), 'dsh-settings-composition-'))
  46. const settingsPath = join(root, 'settings.yaml')
  47. await writeFile(settingsPath, 'ui-theme:\n theme: light\n')
  48. const state: ConsumerState = { scope: undefined, seen: [], applied: undefined }
  49. const consumer = {
  50. name: 'settings-consumer',
  51. apply: (ctx: Context) => {
  52. // The documented consumer shape: no hard dependency — entry config alone
  53. // is the running state, and the scoped inject overlays the user layer
  54. // only while a settings service exists.
  55. const base: Partial<ThemeConfig> = { fontSize: 16 }
  56. state.applied = ThemeSchema(base as ThemeConfig)
  57. ctx.inject(['settings'], (child: Context) => {
  58. const scope = child.settings.register('ui-theme', ThemeSchema, { base })
  59. state.scope = scope
  60. state.applied = scope.get()
  61. scope.watch((next) => {
  62. state.seen.push(next)
  63. state.applied = next
  64. })
  65. })
  66. },
  67. }
  68. const configPath = join(root, 'cordis.yml')
  69. await writeFile(configPath, [
  70. ...withSettings
  71. ? [
  72. '- id: settings',
  73. " name: '@deepseek-ai/dsh-settings-file'",
  74. ' config:',
  75. ` path: ${JSON.stringify(settingsPath)}`,
  76. ' debounceMs: 10',
  77. ]
  78. : [],
  79. '- id: consumer',
  80. ' name: test-settings-consumer',
  81. '',
  82. ].join('\n'))
  83. const ctx = new Context()
  84. context = ctx
  85. ctx.baseUrl = pathToFileURL(root).href + '/'
  86. await ctx.plugin(Loader)
  87. ctx.loader.builtins.include = Include
  88. const modules = new Map<string, unknown>([
  89. ['@deepseek-ai/dsh-settings-file', FileSettingsProvider],
  90. ['test-settings-consumer', consumer],
  91. ])
  92. ctx.loader.internal = {
  93. version: 'v2',
  94. async import(specifier: string) {
  95. if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
  96. return modules.get(specifier)
  97. },
  98. } as unknown as NonNullable<typeof ctx.loader.internal>
  99. await ctx.loader.create({
  100. name: 'cordis:include',
  101. config: { path: pathToFileURL(configPath).href },
  102. })
  103. await ctx.loader.await()
  104. return { ctx, state, settingsPath }
  105. }
  106. describe('settings-file real composition', () => {
  107. it('boots from cordis.yml and hot-publishes an external settings edit', async () => {
  108. const { ctx, state, settingsPath } = await loadComposition()
  109. // Composition resolution: user layer over the consumer's composition base.
  110. await vi.waitFor(() => {
  111. expect(state.scope!.get()).toEqual({ theme: 'light', fontSize: 16 })
  112. })
  113. expect(ctx.get('settings')!.describe().map(entry => entry.ns)).toEqual(['ui-theme'])
  114. await writeFile(settingsPath, 'ui-theme:\n theme: dark\n fontSize: 20\n')
  115. await vi.waitFor(() => {
  116. expect(state.scope!.get()).toEqual({ theme: 'dark', fontSize: 20 })
  117. }, { timeout: 5000 })
  118. expect(state.seen.at(-1)).toEqual({ theme: 'dark', fontSize: 20 })
  119. })
  120. it('boots the same consumer without a settings entry and keeps entry-config resolution', async () => {
  121. const { ctx, state } = await loadComposition({ withSettings: false })
  122. // No settings service anywhere in the composition…
  123. expect(ctx.get('settings')).toBeUndefined()
  124. // …so the consumer runs on schema defaults plus its composition base, and
  125. // never receives a scope.
  126. expect(state.applied).toEqual({ theme: 'dark', fontSize: 16 })
  127. expect(state.scope).toBeUndefined()
  128. expect(state.seen).toEqual([])
  129. })
  130. })