app-boot.spec.ts 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  1. import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
  2. import { tmpdir } from 'node:os'
  3. import { join, resolve, sep } from 'node:path'
  4. import { describe, expect, it, vi } from 'vitest'
  5. import type { Context } from 'cordis'
  6. import {
  7. assertEntriesLoaded, boot, installFailLoud, loadEnv, resolveConfigPath,
  8. type FailLoudProcess,
  9. } from '../src/index.ts'
  10. const NAME = 'dsh-test-bin'
  11. const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-app-boot-'))
  12. describe('resolveConfigPath', () => {
  13. it('resolves relative to the given cwd outside replay mode', () => {
  14. expect(resolveConfigPath('./cordis.yml', undefined, `${sep}base`)).toBe(resolve(`${sep}base`, 'cordis.yml'))
  15. expect(resolveConfigPath('conf/app.yaml', 'record', `${sep}base`)).toBe(resolve(`${sep}base`, 'conf/app.yaml'))
  16. })
  17. it('swaps a cordis.yml/.yaml basename for cordis.snapshot.yml in replay mode', () => {
  18. expect(resolveConfigPath('./cordis.yml', 'replay', `${sep}base`)).toBe(resolve(`${sep}base`, 'cordis.snapshot.yml'))
  19. expect(resolveConfigPath('deep/cordis.yaml', 'replay', `${sep}base`)).toBe(resolve(`${sep}base`, 'deep/cordis.snapshot.yml'))
  20. })
  21. it('leaves a non-cordis basename alone in replay mode and defaults cwd to the process cwd', () => {
  22. expect(resolveConfigPath('custom.yml', 'replay', `${sep}base`)).toBe(resolve(`${sep}base`, 'custom.yml'))
  23. expect(resolveConfigPath('./x.yml', undefined)).toBe(resolve(process.cwd(), 'x.yml'))
  24. })
  25. })
  26. describe('loadEnv', () => {
  27. it('loads variables from .env in the given dir', () => {
  28. const dir = tmp()
  29. writeFileSync(join(dir, '.env'), 'DSH_APP_BOOT_SPEC_VAR=loaded\n')
  30. const warn = vi.fn()
  31. loadEnv(NAME, dir, warn)
  32. expect(process.env['DSH_APP_BOOT_SPEC_VAR']).toBe('loaded')
  33. expect(warn).not.toHaveBeenCalled()
  34. delete process.env['DSH_APP_BOOT_SPEC_VAR']
  35. })
  36. it('stays silent when no .env exists (ambient environment wins)', () => {
  37. const warn = vi.fn()
  38. loadEnv(NAME, tmp(), warn)
  39. expect(warn).not.toHaveBeenCalled()
  40. })
  41. it('warns (labelled, single line) when .env exists but cannot be loaded', () => {
  42. const dir = tmp()
  43. mkdirSync(join(dir, '.env')) // a directory named .env: present, unreadable as a file
  44. const warn = vi.fn()
  45. loadEnv(NAME, dir, warn)
  46. expect(warn).toHaveBeenCalledTimes(1)
  47. expect(warn.mock.calls[0]?.[0]).toMatch(new RegExp(`^${NAME}: failed to load \\.env: `))
  48. })
  49. it('defaults dir to the process cwd and warn to a stderr write', () => {
  50. const dir = tmp()
  51. writeFileSync(join(dir, '.env'), 'DSH_APP_BOOT_SPEC_DEFAULTS=yes\n')
  52. const previous = process.cwd()
  53. process.chdir(dir)
  54. try {
  55. loadEnv(NAME) // happy path: the default warn sink is never invoked
  56. } finally {
  57. process.chdir(previous)
  58. }
  59. expect(process.env['DSH_APP_BOOT_SPEC_DEFAULTS']).toBe('yes')
  60. delete process.env['DSH_APP_BOOT_SPEC_DEFAULTS']
  61. // The default warn sink itself: point it at a broken .env with stderr
  62. // spied, so the arrow body runs without polluting the test output.
  63. const broken = tmp()
  64. mkdirSync(join(broken, '.env'))
  65. const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
  66. let written: string[]
  67. try {
  68. loadEnv(NAME, broken)
  69. written = write.mock.calls.map(call => String(call[0]))
  70. } finally {
  71. write.mockRestore()
  72. }
  73. expect(written).toHaveLength(1)
  74. expect(written[0]).toContain(`${NAME}: failed to load .env: `)
  75. })
  76. })
  77. describe('installFailLoud', () => {
  78. function fakeProc(): FailLoudProcess & { handlers: Array<(err: unknown) => void>; written: string[]; exits: number[] } {
  79. const handlers: Array<(err: unknown) => void> = []
  80. const written: string[] = []
  81. const exits: number[] = []
  82. return {
  83. handlers, written, exits,
  84. on: (_event, handler) => { handlers.push(handler) },
  85. off: (_event, handler) => { handlers.splice(handlers.indexOf(handler), 1) },
  86. stderr: { write: (chunk: string) => { written.push(chunk) } },
  87. exit: (code: number) => { exits.push(code) },
  88. }
  89. }
  90. it('writes one labelled line with the stack and exits 1 on an Error rejection', () => {
  91. const proc = fakeProc()
  92. installFailLoud(NAME, proc)
  93. const error = new Error('boom')
  94. proc.handlers[0]!(error)
  95. expect(proc.written[0]).toContain(`${NAME}: fatal load failure: `)
  96. expect(proc.written[0]).toContain(error.stack)
  97. expect(proc.exits).toEqual([1])
  98. })
  99. it('stringifies a non-Error rejection and an Error without a stack falls back to its message', () => {
  100. const proc = fakeProc()
  101. installFailLoud(NAME, proc)
  102. proc.handlers[0]!('plain failure')
  103. expect(proc.written[0]).toContain('plain failure')
  104. const stackless = new Error('no stack')
  105. delete (stackless as { stack?: string }).stack
  106. proc.handlers[0]!(stackless)
  107. expect(proc.written[1]).toContain('no stack')
  108. expect(proc.exits).toEqual([1, 1])
  109. })
  110. it('returns an uninstaller that removes the handler (and defaults to the real process)', () => {
  111. const proc = fakeProc()
  112. const uninstall = installFailLoud(NAME, proc)
  113. expect(proc.handlers).toHaveLength(1)
  114. uninstall()
  115. expect(proc.handlers).toHaveLength(0)
  116. // Default-proc arm: install on the real process, then immediately uninstall
  117. // so the suite leaks no handler and can never exit the runner.
  118. const before = process.listenerCount('unhandledRejection')
  119. const uninstallReal = installFailLoud(NAME)
  120. expect(process.listenerCount('unhandledRejection')).toBe(before + 1)
  121. uninstallReal()
  122. expect(process.listenerCount('unhandledRejection')).toBe(before)
  123. })
  124. })
  125. describe('assertEntriesLoaded', () => {
  126. const ctxWith = (entries: Array<{ fiber?: unknown; disabled?: boolean; options: { name?: string } }>): Context =>
  127. ({ loader: { entries: () => entries } }) as unknown as Context
  128. it('passes when every enabled entry has a fiber', () => {
  129. expect(() => { assertEntriesLoaded(ctxWith([
  130. { fiber: {}, options: { name: 'a' } },
  131. { disabled: true, options: { name: 'off' } },
  132. ]), NAME) }).not.toThrow()
  133. })
  134. it('throws naming every enabled fiber-less entry', () => {
  135. expect(() => { assertEntriesLoaded(ctxWith([
  136. { fiber: {}, options: { name: 'ok' } },
  137. { options: { name: 'broken-a' } },
  138. { options: { name: 'broken-b' } },
  139. ]), NAME) }).toThrow(`${NAME}: plugin(s) failed to load: broken-a, broken-b`)
  140. })
  141. })
  142. describe('boot', () => {
  143. it('boots a leaf config through the real Loader and settles the tree', async () => {
  144. const dir = tmp()
  145. writeFileSync(join(dir, 'noop.mjs'), 'export const name = "noop"\nexport function apply() {}\n')
  146. writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n')
  147. const ctx = await boot(NAME, join(dir, 'cordis.yml'))
  148. try {
  149. const entries = [...ctx.loader.entries()]
  150. expect(entries.some(entry => entry.options.name === './noop.mjs' && entry.fiber !== undefined)).toBe(true)
  151. } finally {
  152. await ctx.fiber.dispose()
  153. }
  154. })
  155. it('rejects (never exits 0 half-empty) when a config names a plugin that cannot be imported', async () => {
  156. const dir = tmp()
  157. writeFileSync(join(dir, 'cordis.yml'), '- id: ghost\n name: ./missing.mjs\n')
  158. await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(`${NAME}: plugin(s) failed to load: ./missing.mjs`)
  159. })
  160. })