config-reload.spec.ts 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128
  1. /**
  2. * Config hot-reload resilience of the booted include tree. `dsh-app-boot`
  3. * installs a fail-loud unhandled-rejection handler, so a `refresh()` that
  4. * rethrows a config-file parse error would kill a live app on one bad
  5. * `cordis.yml` edit (the HMR watcher awaits `refresh()` in an async event
  6. * callback nobody else catches). These tests pin the vendored
  7. * `@cordisjs/plugin-include` contract that boot relies on: an invalid file
  8. * keeps the last good tree, and a valid re-read re-applies overlay patches
  9. * exactly like the initial load.
  10. */
  11. import { mkdtempSync, writeFileSync } from 'node:fs'
  12. import { tmpdir } from 'node:os'
  13. import { join } from 'node:path'
  14. import { describe, expect, it } from 'vitest'
  15. import type { Context } from 'cordis'
  16. import type { Include } from '@cordisjs/plugin-include'
  17. import { boot } from '../src/index.ts'
  18. const NAME = 'dsh-test-bin'
  19. const NOOP_PLUGIN = 'export const name = "noop"\nexport function apply() {}\n'
  20. interface TreeFixture {
  21. ctx: Context
  22. dir: string
  23. include: Include
  24. }
  25. async function bootTree(configBody: string): Promise<TreeFixture> {
  26. const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-'))
  27. writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN)
  28. writeFileSync(join(dir, 'cordis.yml'), configBody)
  29. const ctx = await boot(NAME, join(dir, 'cordis.yml'))
  30. const entry = [...ctx.loader.entries()].find(candidate => candidate.subtree !== undefined)
  31. if (entry?.subtree === undefined) throw new Error('booted tree has no include entry')
  32. return { ctx, dir, include: entry.subtree as Include }
  33. }
  34. function entryConfig(ctx: Context, id: string): unknown {
  35. return [...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.config
  36. }
  37. describe('include refresh with an invalid file', () => {
  38. it('keeps the last good tree instead of throwing, then applies the next valid edit', async () => {
  39. const { ctx, dir, include } = await bootTree('- id: noop\n name: ./noop.mjs\n config:\n value: 1\n')
  40. try {
  41. expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 })
  42. writeFileSync(join(dir, 'cordis.yml'), 'invalid: [unclosed\n')
  43. await expect(include.refresh()).resolves.toBeUndefined()
  44. expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 })
  45. // An empty file parses to `undefined` without a YAML error; it must be
  46. // treated exactly like a parse failure, not crash the entry walk.
  47. writeFileSync(join(dir, 'cordis.yml'), '')
  48. await expect(include.refresh()).resolves.toBeUndefined()
  49. expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 })
  50. writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: 2\n')
  51. await include.refresh()
  52. await ctx.loader.await()
  53. expect(entryConfig(ctx, 'noop')).toEqual({ value: 2 })
  54. } finally {
  55. await ctx.fiber.dispose()
  56. }
  57. })
  58. })
  59. describe('include refresh with overlay patches', () => {
  60. it('re-applies entry patches and inserted entries on every re-read (parity with initial load)', async () => {
  61. const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-overlay-'))
  62. writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN)
  63. writeFileSync(join(dir, 'base.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: base\n')
  64. writeFileSync(join(dir, 'cordis.yml'), [
  65. '- id: base',
  66. " name: 'cordis:include'",
  67. ' config:',
  68. ' path: ./base.yml',
  69. ' patches:',
  70. ' - id: noop',
  71. ' name: ./noop.mjs',
  72. ' config:',
  73. ' value: patched',
  74. ' - insert:',
  75. ' - id: extra',
  76. ' name: ./noop.mjs',
  77. '',
  78. ].join('\n'))
  79. const ctx = await boot(NAME, join(dir, 'cordis.yml'))
  80. try {
  81. const entry = [...ctx.loader.entries()].find(candidate => candidate.options.id === 'base')
  82. if (entry?.subtree === undefined) throw new Error('overlay tree has no base include entry')
  83. const include = entry.subtree as Include
  84. expect(entryConfig(ctx, 'noop')).toEqual({ value: 'patched' })
  85. expect(entryConfig(ctx, 'extra')).toBeUndefined()
  86. expect([...ctx.loader.entries()].some(candidate => candidate.options.id === 'extra')).toBe(true)
  87. writeFileSync(join(dir, 'base.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: edited\n')
  88. await include.refresh()
  89. await ctx.loader.await()
  90. expect(entryConfig(ctx, 'noop')).toEqual({ value: 'patched' })
  91. expect([...ctx.loader.entries()].some(candidate => candidate.options.id === 'extra')).toBe(true)
  92. // Hot-update of the include entry's own config (the `internal/update`
  93. // path): the new patches must apply now AND stick for later re-reads —
  94. // the listener vetoes the fiber restart, so it must persist the new
  95. // config itself or the next refresh() re-applies the old overlay.
  96. await entry.update({ config: { path: './base.yml', patches: [{ id: 'noop', name: './noop.mjs', config: { value: 'patched-v2' } }] } })
  97. await ctx.loader.await()
  98. expect(entryConfig(ctx, 'noop')).toEqual({ value: 'patched-v2' })
  99. expect([...ctx.loader.entries()].some(candidate => candidate.options.id === 'extra')).toBe(false)
  100. writeFileSync(join(dir, 'base.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: edited-2\n')
  101. await include.refresh()
  102. await ctx.loader.await()
  103. expect(entryConfig(ctx, 'noop')).toEqual({ value: 'patched-v2' })
  104. // Removing every patch must revert to the file's own values: patching
  105. // may not bake earlier patch results into the cached parse.
  106. await entry.update({ config: { path: './base.yml', patches: [] } })
  107. await ctx.loader.await()
  108. expect(entryConfig(ctx, 'noop')).toEqual({ value: 'edited-2' })
  109. } finally {
  110. await ctx.fiber.dispose()
  111. }
  112. })
  113. })