local.spec.ts 17 KB

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