local.spec.ts 18 KB

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