personal-config.spec.ts 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141
  1. /**
  2. * Personal-config behavior of `dsh-app-boot`: the Harness home (`~/.dsh`)
  3. * `config.yaml` overlay loader and `boot()` applying the personal overlay over
  4. * a real Loader tree.
  5. */
  6. import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
  7. import { tmpdir } from 'node:os'
  8. import { join } from 'node:path'
  9. import { afterEach, describe, expect, it } from 'vitest'
  10. import type { Context } from 'cordis'
  11. import {
  12. boot,
  13. loadPersonalPatches,
  14. PERSONAL_CONFIG_FILENAME,
  15. } from '../src/index.ts'
  16. const NAME = 'dsh-test-bin'
  17. const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-personal-config-'))
  18. describe('loadPersonalPatches', () => {
  19. afterEach(() => {
  20. delete process.env.DSH_HOME
  21. })
  22. it('returns undefined when no personal patches file exists', () => {
  23. expect(loadPersonalPatches(NAME, tmp())).toBeUndefined()
  24. })
  25. it('parses a patch list and preserves !!js expressions as loader expression nodes', () => {
  26. const dir = tmp()
  27. writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), [
  28. '- id: tui-agent',
  29. " name: '@deepseek-ai/dsh-tui-demo'",
  30. ' config:',
  31. ' model: !!js process.env.DSH_SPEC_MODEL',
  32. '- insert:',
  33. ' - id: llm',
  34. " name: '@deepseek-ai/dsh-llm-pi-ai'",
  35. '',
  36. ].join('\n'))
  37. const patches = loadPersonalPatches(NAME, dir)
  38. expect(patches).toHaveLength(2)
  39. expect(patches?.[0]).toMatchObject({
  40. id: 'tui-agent',
  41. config: { model: { __jsExpr: 'process.env.DSH_SPEC_MODEL' } },
  42. })
  43. expect(patches?.[1]?.insert).toHaveLength(1)
  44. })
  45. it('defaults its directory to the Harness home ($DSH_HOME)', () => {
  46. const dir = tmp()
  47. writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), '- id: x\n config:\n a: 1\n')
  48. process.env.DSH_HOME = dir
  49. expect(loadPersonalPatches(NAME)).toHaveLength(1)
  50. })
  51. it('fails loud on an unreadable file (a present personal config is never skipped)', () => {
  52. const dir = tmp()
  53. mkdirSync(join(dir, PERSONAL_CONFIG_FILENAME)) // a directory: present, unreadable as a file
  54. expect(() => loadPersonalPatches(NAME, dir))
  55. .toThrow(new RegExp(`^${NAME}: failed to read personal patches `))
  56. })
  57. it('fails loud on unparsable YAML and on a !!js tag with no expression body', () => {
  58. const dir = tmp()
  59. writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), 'invalid: [unclosed\n')
  60. expect(() => loadPersonalPatches(NAME, dir))
  61. .toThrow(new RegExp(`^${NAME}: failed to parse personal patches `))
  62. writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), '- id: x\n config:\n a: !!js\n')
  63. expect(() => loadPersonalPatches(NAME, dir))
  64. .toThrow(new RegExp(`^${NAME}: failed to parse personal patches `))
  65. })
  66. it('fails loud when the file is not a top-level array or an entry is not an object', () => {
  67. const dir = tmp()
  68. writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), 'id: not-a-list\n')
  69. expect(() => loadPersonalPatches(NAME, dir))
  70. .toThrow('must be a top-level YAML array of loader patch entries')
  71. writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), '- just-a-string\n')
  72. expect(() => loadPersonalPatches(NAME, dir))
  73. .toThrow(`${NAME}: personal patches entry 1 in`)
  74. })
  75. })
  76. describe('boot with personal patches', () => {
  77. function writeTree(dir: string): string {
  78. writeFileSync(join(dir, 'noop.mjs'), 'export const name = "noop"\nexport function apply() {}\n')
  79. writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: base\n')
  80. return join(dir, 'cordis.yml')
  81. }
  82. function entryConfig(ctx: Context, id: string): unknown {
  83. return [...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.config
  84. }
  85. it('applies id-targeted overrides, inserts, and interpolates !!js from the environment', async () => {
  86. const dir = tmp()
  87. const personal = tmp()
  88. writeFileSync(join(personal, PERSONAL_CONFIG_FILENAME), [
  89. '- id: noop',
  90. ' name: ./noop.mjs',
  91. ' config:',
  92. ' value: !!js process.env.DSH_APP_BOOT_PERSONAL_SPEC',
  93. '- insert:',
  94. ' - id: personal-extra',
  95. ' name: ./noop.mjs',
  96. '',
  97. ].join('\n'))
  98. process.env['DSH_APP_BOOT_PERSONAL_SPEC'] = 'personal-value'
  99. const ctx = await boot(NAME, writeTree(dir), loadPersonalPatches(NAME, personal))
  100. try {
  101. const noop = [...ctx.loader.entries()].find(entry => entry.options.id === 'noop')
  102. // The mounted plugin received the interpolated environment value.
  103. expect(noop?.fiber?.config).toEqual({ value: 'personal-value' })
  104. expect([...ctx.loader.entries()].some(entry => entry.options.id === 'personal-extra')).toBe(true)
  105. } finally {
  106. await ctx.fiber.dispose()
  107. delete process.env['DSH_APP_BOOT_PERSONAL_SPEC']
  108. }
  109. })
  110. it('mounts no patch layer for an absent or empty personal overlay', async () => {
  111. const dir = tmp()
  112. const ctx = await boot(NAME, writeTree(dir), loadPersonalPatches(NAME, tmp()))
  113. try {
  114. expect(entryConfig(ctx, 'noop')).toEqual({ value: 'base' })
  115. } finally {
  116. await ctx.fiber.dispose()
  117. }
  118. const empty = tmp()
  119. writeFileSync(join(empty, PERSONAL_CONFIG_FILENAME), '[]\n')
  120. const ctxEmpty = await boot(NAME, writeTree(tmp()), loadPersonalPatches(NAME, empty))
  121. try {
  122. expect(entryConfig(ctxEmpty, 'noop')).toEqual({ value: 'base' })
  123. } finally {
  124. await ctxEmpty.fiber.dispose()
  125. }
  126. })
  127. })