local.spec.ts 17 KB

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