config-dump.spec.ts 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  1. /**
  2. * `renderConfigDump` behavior: the offline composition must equal what
  3. * `boot()` mounts (same parser, same patch algorithm), print `!!js`
  4. * expressions verbatim, separate source-file runs with comment lines while
  5. * staying one loadable YAML document, and report skipped patches through
  6. * `warn` instead of failing — mirroring the Loader's boot-time warning for a
  7. * shared overlay whose row exists only on another surface.
  8. */
  9. import { mkdtempSync, writeFileSync } from 'node:fs'
  10. import { tmpdir } from 'node:os'
  11. import { join } from 'node:path'
  12. import { pathToFileURL } from 'node:url'
  13. import { describe, expect, it, vi } from 'vitest'
  14. import * as yaml from 'js-yaml'
  15. import { entryListSchema } from '@deepseek-ai/cordis-plugin-include'
  16. import { loadOverlayPatches, renderConfigDump } from '../src/index.ts'
  17. const NAME = 'dsh-test-bin'
  18. const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-config-dump-'))
  19. function writeBase(dir: string): string {
  20. const base = join(dir, 'base.yml')
  21. writeFileSync(base, [
  22. '- id: shared',
  23. ' name: ./noop.mjs',
  24. ' config:',
  25. ' value: base',
  26. ' key: !!js process.env.DSH_DUMP_SPEC',
  27. '- id: untouched',
  28. ' name: ./noop.mjs',
  29. '',
  30. ].join('\n'))
  31. return base
  32. }
  33. describe('renderConfigDump', () => {
  34. it('composes overlay layers in order, prints !!js verbatim, and labels each section with its source and patches', () => {
  35. const dir = tmp()
  36. const base = writeBase(dir)
  37. const surface = join(dir, 'surface.yml')
  38. writeFileSync(surface, [
  39. '- id: shared',
  40. ' config:',
  41. ' value: surface',
  42. ' key: !!js process.env.DSH_DUMP_SPEC',
  43. '- insert:',
  44. ' - id: surface-extra',
  45. ' name: ./noop.mjs',
  46. '',
  47. ].join('\n'))
  48. const user = join(dir, 'user.yml')
  49. writeFileSync(user, [
  50. '- id: surface-extra',
  51. ' config:',
  52. ' value: user',
  53. '',
  54. ].join('\n'))
  55. const dump = renderConfigDump(NAME, base, [
  56. { label: 'surface.yml', patches: loadOverlayPatches(NAME, surface) },
  57. { label: 'user.yml', patches: loadOverlayPatches(NAME, user) },
  58. ], () => {})
  59. // Comments do not break loadability: the dump parses as one document
  60. // equal to what boot() would mount.
  61. const parsed = yaml.load(dump, { schema: entryListSchema }) as {
  62. id: string
  63. config?: Record<string, unknown>
  64. }[]
  65. expect(parsed).toEqual([
  66. {
  67. id: 'shared',
  68. name: './noop.mjs',
  69. config: { value: 'surface', key: { __jsExpr: 'process.env.DSH_DUMP_SPEC' } },
  70. },
  71. { id: 'untouched', name: './noop.mjs' },
  72. {
  73. id: 'surface-extra',
  74. name: pathToFileURL(join(dir, 'noop.mjs')).href,
  75. config: { value: 'user' },
  76. },
  77. ])
  78. // Unevaluated: the expression text round-trips as a !!js scalar.
  79. expect(dump).toContain('!!js process.env.DSH_DUMP_SPEC')
  80. // Source separators: origin file, plus every layer that changed the
  81. // row; an inserted row carries the inserting layer as its origin.
  82. expect(dump).toContain('# == base.yml, patched by surface.yml')
  83. expect(dump).toContain('# == base.yml\n- id: untouched')
  84. expect(dump).toContain('# == surface.yml, patched by user.yml\n- id: surface-extra')
  85. expect(dump.indexOf('# == base.yml, patched by surface.yml')).toBeLessThan(dump.indexOf('# == base.yml\n- id: untouched'))
  86. })
  87. it('groups contiguous rows with the same origin and patches under one separator', () => {
  88. const dir = tmp()
  89. const base = join(dir, 'base.yml')
  90. writeFileSync(base, [
  91. '- id: a',
  92. ' name: ./noop.mjs',
  93. '- id: b',
  94. ' name: ./noop.mjs',
  95. '',
  96. ].join('\n'))
  97. const dump = renderConfigDump(NAME, base, [], () => {})
  98. expect(dump.match(/# == base\.yml/g)).toHaveLength(1)
  99. expect(dump).toContain('# == base.yml\n- id: a')
  100. })
  101. it('composes all layers as one flattened patch list, exactly like boot()', () => {
  102. // boot() flattens every layer into ONE applyEntryPatches call, whose id
  103. // index sees inserted rows but NOT children introduced by a plain group
  104. // `config` replacement. A per-layer composition would rebuild the index
  105. // between layers and let the second layer patch that child — a tree the
  106. // real boot never mounts. Pin the single-call semantics: the child patch
  107. // is skipped (with the layer-labeled warning), matching boot.
  108. const dir = tmp()
  109. const base = join(dir, 'base.yml')
  110. writeFileSync(base, [
  111. '- id: g',
  112. ' name: ./group.mjs',
  113. ' group: true',
  114. ' config: []',
  115. '',
  116. ].join('\n'))
  117. const warnings: string[] = []
  118. const dump = renderConfigDump(NAME, base, [
  119. {
  120. label: 'a.yml',
  121. patches: [{ id: 'g', config: [{ id: 'child', name: './noop.mjs', config: { v: 1 } }] }],
  122. },
  123. { label: 'b.yml', patches: [{ id: 'child', config: { v: 2 } }] },
  124. ], line => void warnings.push(line))
  125. expect(warnings).toEqual([`${NAME}: [b.yml] patch: entry "child" not found`])
  126. const parsed = yaml.load(dump, { schema: entryListSchema }) as {
  127. config?: { config?: { v?: number } }[]
  128. }[]
  129. expect(parsed[0]?.config?.[0]?.config?.v).toBe(1)
  130. // The skipped layer did not change the row, so the comment does not list it.
  131. expect(dump).toContain('# == base.yml, patched by a.yml\n- id: g')
  132. expect(dump).not.toContain('b.yml\n- id: g')
  133. })
  134. it('reports a patch whose target row is absent through warn with its layer label and keeps composing', () => {
  135. const dir = tmp()
  136. const base = writeBase(dir)
  137. const overlay = join(dir, 'overlay.yml')
  138. writeFileSync(overlay, [
  139. '- id: only-on-another-surface',
  140. ' config:',
  141. ' value: ignored',
  142. '- id: shared',
  143. ' config:',
  144. ' value: patched',
  145. '',
  146. ].join('\n'))
  147. const warnings: string[] = []
  148. const dump = renderConfigDump(
  149. NAME, base,
  150. [{ label: 'overlay.yml', patches: loadOverlayPatches(NAME, overlay) }],
  151. line => void warnings.push(line),
  152. )
  153. expect(warnings).toEqual([`${NAME}: [overlay.yml] patch: entry "only-on-another-surface" not found`])
  154. const parsed = yaml.load(dump, { schema: entryListSchema }) as { config?: { value?: string } }[]
  155. expect(parsed[0]?.config?.value).toBe('patched')
  156. })
  157. it('defaults its warn sink to one stderr line per skipped patch', () => {
  158. const dir = tmp()
  159. const base = writeBase(dir)
  160. const write = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
  161. try {
  162. renderConfigDump(NAME, base, [{ label: 'x.yml', patches: [{ id: 'absent', config: {} }] }])
  163. expect(write).toHaveBeenCalledWith(`${NAME}: [x.yml] patch: entry "absent" not found\n`)
  164. } finally {
  165. write.mockRestore()
  166. }
  167. })
  168. it('fails loud on a missing, unparsable, or non-array base config', () => {
  169. const dir = tmp()
  170. expect(() => renderConfigDump(NAME, join(dir, 'absent.yml'), [], () => {}))
  171. .toThrow(new RegExp(`^${NAME}: failed to read config `))
  172. const invalid = join(dir, 'invalid.yml')
  173. writeFileSync(invalid, 'invalid: [unclosed\n')
  174. expect(() => renderConfigDump(NAME, invalid, [], () => {}))
  175. .toThrow(new RegExp(`^${NAME}: failed to parse config `))
  176. const scalar = join(dir, 'scalar.yml')
  177. writeFileSync(scalar, 'id: not-a-list\n')
  178. expect(() => renderConfigDump(NAME, scalar, [], () => {}))
  179. .toThrow('must be a top-level YAML array of entries')
  180. })
  181. })