config-dump.spec.ts 7.5 KB

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