config-dump.spec.ts 7.2 KB

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