local.spec.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401
  1. import { afterEach, describe, expect, it, vi } from 'vitest'
  2. import { Context } from 'cordis'
  3. import z from 'schemastery'
  4. import { chmod, lstat, mkdtemp, readFile, readdir, rm, stat, symlink, writeFile } from 'node:fs/promises'
  5. import { tmpdir } from 'node:os'
  6. import { join } from 'node:path'
  7. import { settingsNamespace } from '@deepseek-ai/dsh-settings'
  8. import { SettingsLocal, resolveSpec } from '../src/index.ts'
  9. interface ThemeConfig {
  10. theme: 'dark' | 'light'
  11. fontSize: number
  12. }
  13. const ThemeSchema: z<ThemeConfig> = z.object({
  14. theme: z.union(['dark', 'light']).default('dark'),
  15. fontSize: z.number().default(14),
  16. })
  17. const cleanups: Array<() => Promise<void>> = []
  18. afterEach(async () => {
  19. while (cleanups.length > 0) await cleanups.pop()!()
  20. })
  21. async function tempDir(): Promise<string> {
  22. const dir = await mkdtemp(join(tmpdir(), 'dsh-settings-local-'))
  23. cleanups.push(() => rm(dir, { recursive: true, force: true }))
  24. return dir
  25. }
  26. async function boot(config: ConstructorParameters<typeof SettingsLocal>[1]): Promise<Context> {
  27. const ctx = new Context()
  28. const fiber = ctx.plugin(SettingsLocal, config)
  29. cleanups.push(async () => { await fiber.dispose() })
  30. await fiber
  31. return ctx
  32. }
  33. describe('resolveSpec', () => {
  34. it('defaults watch and debounce when construction bypasses schema normalization', () => {
  35. const spec = resolveSpec({ path: '/tmp/anywhere/settings.yaml' })
  36. expect(spec.watch).toBe(true)
  37. expect(spec.debounceMs).toBe(100)
  38. })
  39. })
  40. describe('boot and reads', () => {
  41. it('resolves defaults over an absent file and reports writable', async () => {
  42. const dir = await tempDir()
  43. const ctx = await boot({ path: join(dir, 'settings.yaml'), watch: false })
  44. const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, {
  45. base: { fontSize: 16 },
  46. })
  47. expect(scope.get()).toEqual({ theme: 'dark', fontSize: 16 })
  48. expect(ctx.settings.writable).toBe(true)
  49. })
  50. it('reads sections from an existing yaml document', async () => {
  51. const dir = await tempDir()
  52. const path = join(dir, 'settings.yaml')
  53. await writeFile(path, 'ui-theme:\n theme: light\n')
  54. const ctx = await boot({ path, watch: false })
  55. const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
  56. expect(scope.get()).toEqual({ theme: 'light', fontSize: 14 })
  57. })
  58. it('reads sections from a json document', async () => {
  59. const dir = await tempDir()
  60. const path = join(dir, 'settings.json')
  61. await writeFile(path, JSON.stringify({ 'ui-theme': { fontSize: 18 } }))
  62. const ctx = await boot({ path, watch: false })
  63. const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
  64. expect(scope.get()).toEqual({ theme: 'dark', fontSize: 18 })
  65. })
  66. it('defaults the file location under the configured harness home', async () => {
  67. const dir = await tempDir()
  68. const ctx = await boot({ dshHome: dir, watch: false })
  69. const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
  70. await scope.update({ theme: 'light' })
  71. const written = await readFile(join(dir, 'settings.yaml'), 'utf8')
  72. expect(written).toContain('theme: light')
  73. })
  74. it('reads an empty yaml document as no sections', async () => {
  75. const dir = await tempDir()
  76. const path = join(dir, 'settings.yaml')
  77. await writeFile(path, '')
  78. const ctx = await boot({ path, watch: false })
  79. const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
  80. expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 })
  81. })
  82. it('reads an empty json document as no sections', async () => {
  83. const dir = await tempDir()
  84. const path = join(dir, 'settings.json')
  85. await writeFile(path, '')
  86. const ctx = await boot({ path, watch: false })
  87. const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
  88. expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 })
  89. })
  90. it('fails loud at boot when the document exists but is unreadable', async () => {
  91. const dir = await tempDir()
  92. const path = join(dir, 'settings.yaml')
  93. await writeFile(path, 'ui-theme:\n theme: light\n')
  94. await chmod(path, 0o000)
  95. cleanups.push(() => chmod(path, 0o600))
  96. await expect(boot({ path, watch: false })).rejects.toThrow(/EACCES|permission/i)
  97. })
  98. it('fails loud on an unsupported extension', async () => {
  99. const dir = await tempDir()
  100. await expect(boot({ path: join(dir, 'settings.toml'), watch: false }))
  101. .rejects.toThrow(/not supported/)
  102. })
  103. it('fails loud at boot on unparsable yaml', async () => {
  104. const dir = await tempDir()
  105. const path = join(dir, 'settings.yaml')
  106. await writeFile(path, 'ui-theme: [unclosed\n')
  107. await expect(boot({ path, watch: false })).rejects.toThrow()
  108. })
  109. it('fails loud at boot when the root is not a map of sections', async () => {
  110. const dir = await tempDir()
  111. const path = join(dir, 'settings.yaml')
  112. await writeFile(path, '- just\n- a list\n')
  113. await expect(boot({ path, watch: false })).rejects.toThrow(/map of namespace sections/)
  114. })
  115. })
  116. describe('persist', () => {
  117. it('writes the merged section, creating the file with owner-only permissions', async () => {
  118. const dir = await tempDir()
  119. const path = join(dir, 'settings.yaml')
  120. const ctx = await boot({ path, watch: false })
  121. const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
  122. await scope.update({ theme: 'light' })
  123. const written = await readFile(path, 'utf8')
  124. expect(written).toContain('theme: light')
  125. expect((await stat(path)).mode & 0o777).toBe(0o600)
  126. // Atomic replace leaves no temp artifact behind.
  127. expect((await readdir(dir)).sort()).toEqual(['settings.yaml'])
  128. })
  129. it('serializes cross-namespace writes into one on-disk document', async () => {
  130. const dir = await tempDir()
  131. const path = join(dir, 'settings.yaml')
  132. const ctx = await boot({ path, watch: false })
  133. const alpha = ctx.settings.register(settingsNamespace('alpha'), ThemeSchema)
  134. const beta = ctx.settings.register(settingsNamespace('beta'), ThemeSchema)
  135. await Promise.all([
  136. alpha.update({ theme: 'light' }),
  137. beta.update({ fontSize: 20 }),
  138. ])
  139. const text = await readFile(path, 'utf8')
  140. expect(text).toContain('alpha:')
  141. expect(text).toContain('beta:')
  142. expect(alpha.get().theme).toBe('light')
  143. expect(beta.get().fontSize).toBe(20)
  144. })
  145. it('never follows a planted symlink at a temp path and never leaves the document a symlink', async () => {
  146. const dir = await tempDir()
  147. const path = join(dir, 'settings.yaml')
  148. const victim = join(dir, 'victim.txt')
  149. await writeFile(victim, 'precious')
  150. // A hostile sibling plants the historic fixed temp name as a symlink.
  151. await symlink(victim, `${path}.tmp`)
  152. const ctx = await boot({ path, watch: false })
  153. const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
  154. await scope.update({ theme: 'light' })
  155. expect(await readFile(victim, 'utf8')).toBe('precious')
  156. expect((await lstat(path)).isSymbolicLink()).toBe(false)
  157. expect((await stat(path)).mode & 0o777).toBe(0o600)
  158. expect(await readFile(path, 'utf8')).toContain('theme: light')
  159. })
  160. it('preserves comments and unregistered sections across updates', async () => {
  161. const dir = await tempDir()
  162. const path = join(dir, 'settings.yaml')
  163. await writeFile(path, [
  164. '# personal settings',
  165. 'ui-theme:',
  166. ' theme: light',
  167. '# owned by a plugin that is not loaded right now',
  168. 'future-plugin:',
  169. ' keep: me',
  170. '',
  171. ].join('\n'))
  172. const ctx = await boot({ path, watch: false })
  173. const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
  174. await scope.update({ fontSize: 18 })
  175. const written = await readFile(path, 'utf8')
  176. expect(written).toContain('# personal settings')
  177. expect(written).toContain('# owned by a plugin that is not loaded right now')
  178. expect(written).toContain('keep: me')
  179. expect(written).toContain('fontSize: 18')
  180. expect(written).toContain('theme: light')
  181. })
  182. it('keeps comments inside the section when a sibling key changes', async () => {
  183. const dir = await tempDir()
  184. const path = join(dir, 'settings.yaml')
  185. await writeFile(path, [
  186. 'ui-theme:',
  187. ' # chosen during onboarding',
  188. ' theme: light',
  189. ' fontSize: 12',
  190. '',
  191. ].join('\n'))
  192. const ctx = await boot({ path, watch: false })
  193. const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
  194. await scope.update({ fontSize: 18 })
  195. const written = await readFile(path, 'utf8')
  196. expect(written).toContain('# chosen during onboarding')
  197. expect(written).toContain('theme: light')
  198. expect(written).toContain('fontSize: 18')
  199. })
  200. it('keeps a changed key\'s own-line comment while replacing its value', async () => {
  201. const dir = await tempDir()
  202. const path = join(dir, 'settings.yaml')
  203. await writeFile(path, [
  204. 'ui-theme:',
  205. ' # chosen during onboarding',
  206. ' theme: light',
  207. '',
  208. ].join('\n'))
  209. const ctx = await boot({ path, watch: false })
  210. const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
  211. await scope.update({ theme: 'dark' })
  212. const written = await readFile(path, 'utf8')
  213. expect(written).toContain('# chosen during onboarding')
  214. expect(written).toContain('theme: dark')
  215. })
  216. it('deletes only the removed key on replace, keeping sibling comments', async () => {
  217. const dir = await tempDir()
  218. const path = join(dir, 'settings.yaml')
  219. await writeFile(path, [
  220. 'ui-theme:',
  221. ' # chosen during onboarding',
  222. ' theme: light',
  223. ' fontSize: 12',
  224. '',
  225. ].join('\n'))
  226. const ctx = await boot({ path, watch: false })
  227. const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
  228. await scope.replace({ theme: 'light' })
  229. const written = await readFile(path, 'utf8')
  230. expect(written).toContain('# chosen during onboarding')
  231. expect(written).toContain('theme: light')
  232. expect(written).not.toContain('fontSize')
  233. })
  234. it('keeps an unchanged array\'s comments and replaces a changed array wholesale', async () => {
  235. const dir = await tempDir()
  236. const path = join(dir, 'settings.yaml')
  237. const TagsSchema: z<{ tags: string[]; label: string }> = z.object({
  238. tags: z.array(z.string()).default([]),
  239. label: z.string().default(''),
  240. })
  241. await writeFile(path, [
  242. 'workspace:',
  243. ' tags:',
  244. ' # pinned by hand',
  245. ' - alpha',
  246. ' label: draft',
  247. '',
  248. ].join('\n'))
  249. const ctx = await boot({ path, watch: false })
  250. const scope = ctx.settings.register(settingsNamespace('workspace'), TagsSchema)
  251. await scope.update({ label: 'final' })
  252. const untouched = await readFile(path, 'utf8')
  253. expect(untouched).toContain('# pinned by hand')
  254. expect(untouched).toContain('label: final')
  255. // A changed array replaces wholesale; comments inside it go with it.
  256. await scope.update({ tags: ['beta'] })
  257. const replaced = await readFile(path, 'utf8')
  258. expect(replaced).not.toContain('# pinned by hand')
  259. expect(replaced).toContain('- beta')
  260. })
  261. it('keeps a comment-only document\'s comment when the first section lands', async () => {
  262. const dir = await tempDir()
  263. const path = join(dir, 'settings.yaml')
  264. // Parses to a null root: the document exists but holds no sections yet.
  265. await writeFile(path, '# reserved for future settings\n')
  266. const ctx = await boot({ path, watch: false })
  267. const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
  268. await scope.update({ theme: 'light' })
  269. const written = await readFile(path, 'utf8')
  270. expect(written).toContain('# reserved for future settings')
  271. expect(written).toContain('theme: light')
  272. })
  273. it('creates a json document from scratch', async () => {
  274. const dir = await tempDir()
  275. const path = join(dir, 'settings.json')
  276. const ctx = await boot({ path, watch: false })
  277. const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
  278. await scope.update({ theme: 'light' })
  279. const written = JSON.parse(await readFile(path, 'utf8')) as Record<string, unknown>
  280. expect(written).toEqual({ 'ui-theme': { theme: 'light' } })
  281. })
  282. it('rejects and leaves no temp residue when the directory turns unwritable', async () => {
  283. const dir = await tempDir()
  284. const path = join(dir, 'settings.yaml')
  285. await writeFile(path, 'ui-theme:\n theme: light\n')
  286. const ctx = await boot({ path, watch: false })
  287. const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
  288. await chmod(dir, 0o500)
  289. cleanups.push(() => chmod(dir, 0o700))
  290. await expect(scope.update({ theme: 'dark' })).rejects.toThrow()
  291. await chmod(dir, 0o700)
  292. expect((await readdir(dir)).sort()).toEqual(['settings.yaml'])
  293. expect(scope.get().theme).toBe('light')
  294. // The failed persist must not poison the document write chain.
  295. await scope.update({ theme: 'dark' })
  296. expect(scope.get().theme).toBe('dark')
  297. })
  298. it('round-trips a json document', async () => {
  299. const dir = await tempDir()
  300. const path = join(dir, 'settings.json')
  301. await writeFile(path, JSON.stringify({ other: { keep: true } }, null, 2))
  302. const ctx = await boot({ path, watch: false })
  303. const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
  304. await scope.update({ theme: 'light' })
  305. const written = JSON.parse(await readFile(path, 'utf8')) as Record<string, unknown>
  306. expect(written).toEqual({ other: { keep: true }, 'ui-theme': { theme: 'light' } })
  307. })
  308. })
  309. describe('watch', () => {
  310. it('publishes an external edit to registered scopes', async () => {
  311. const dir = await tempDir()
  312. const path = join(dir, 'settings.yaml')
  313. await writeFile(path, 'ui-theme:\n theme: light\n')
  314. const ctx = await boot({ path, debounceMs: 10 })
  315. const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
  316. expect(scope.get().theme).toBe('light')
  317. await writeFile(path, 'ui-theme:\n theme: dark\n fontSize: 20\n')
  318. await vi.waitFor(() => {
  319. expect(scope.get()).toEqual({ theme: 'dark', fontSize: 20 })
  320. }, { timeout: 5000 })
  321. })
  322. it('keeps the last good document over an invalid edit, then recovers', async () => {
  323. const dir = await tempDir()
  324. const path = join(dir, 'settings.yaml')
  325. await writeFile(path, 'ui-theme:\n theme: light\n')
  326. const ctx = await boot({ path, debounceMs: 10 })
  327. const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
  328. await writeFile(path, 'ui-theme: [unclosed\n')
  329. // The bad edit must never take the live tree down or reset the value.
  330. await new Promise(resolve => setTimeout(resolve, 300))
  331. expect(scope.get()).toEqual({ theme: 'light', fontSize: 14 })
  332. await writeFile(path, 'ui-theme:\n theme: dark\n')
  333. await vi.waitFor(() => {
  334. expect(scope.get().theme).toBe('dark')
  335. }, { timeout: 5000 })
  336. })
  337. it('treats file removal as an empty document', async () => {
  338. const dir = await tempDir()
  339. const path = join(dir, 'settings.yaml')
  340. await writeFile(path, 'ui-theme:\n theme: light\n')
  341. const ctx = await boot({ path, debounceMs: 10 })
  342. const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
  343. await rm(path)
  344. await vi.waitFor(() => {
  345. expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 })
  346. }, { timeout: 5000 })
  347. })
  348. it('does not republish its own persisted write', async () => {
  349. const dir = await tempDir()
  350. const path = join(dir, 'settings.yaml')
  351. const ctx = await boot({ path, debounceMs: 10 })
  352. const events: unknown[] = []
  353. ctx.on('settings/updated', (ns, _next, _prev, source) => {
  354. events.push({ ns, source })
  355. })
  356. const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
  357. await scope.update({ theme: 'light' })
  358. await new Promise(resolve => setTimeout(resolve, 300))
  359. expect(events).toEqual([{ ns: 'ui-theme', source: 'update' }])
  360. })
  361. })