patch-file.spec.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251
  1. import { mkdtemp, readFile, stat, writeFile } from 'node:fs/promises'
  2. import { existsSync } from 'node:fs'
  3. import { tmpdir } from 'node:os'
  4. import { join, resolve } from 'node:path'
  5. import { pathToFileURL } from 'node:url'
  6. import { describe, expect, it } from 'vitest'
  7. import { isJsExpr } from '@deepseek-ai/cordis-plugin-loader'
  8. import {
  9. anchorInsertedPluginNames,
  10. mutatePatchFile,
  11. parsePatchList,
  12. PatchDocument,
  13. readPatchListFile,
  14. } from '../src/index.ts'
  15. const SAMPLE = `# top comment
  16. - id: message-feedback # trailing
  17. disabled: true
  18. # insert block
  19. - insert:
  20. - id: tool-foo
  21. name: dsh-tool-foo
  22. config: {}
  23. - id: group-a
  24. name: cordis:group
  25. group: true
  26. config:
  27. - id: nested-row
  28. name: dsh-nested
  29. - id: tool-bash
  30. disabled: !!js process.platform === 'win32'
  31. config:
  32. a: 1 # keep me
  33. `
  34. async function tempDir(): Promise<string> {
  35. return await mkdtemp(join(tmpdir(), 'dsh-patch-file-'))
  36. }
  37. describe('PatchDocument', () => {
  38. it('reads an empty or comment-only file as an empty list', () => {
  39. expect(PatchDocument.parse('', 'x.yml').toString()).toBe('[]\n')
  40. expect(PatchDocument.parse('# only a comment\n', 'x.yml').toString()).toContain('[]')
  41. })
  42. it('refuses a root that is not a sequence, a non-map item, and invalid YAML', () => {
  43. expect(() => PatchDocument.parse('a: 1\n', 'x.yml')).toThrow('must be a top-level YAML array')
  44. expect(() => PatchDocument.parse('- just-a-string\n', 'x.yml')).toThrow('entry 1 in x.yml must be a mapping')
  45. expect(() => PatchDocument.parse('- id: [\n', 'x.yml')).toThrow('x.yml is not valid YAML')
  46. })
  47. it('reads and sets id-targeted patch keys while keeping comments and !!js scalars', () => {
  48. const document = PatchDocument.parse(SAMPLE, 'x.yml')
  49. expect(document.hasRow('message-feedback')).toBe(true)
  50. expect(document.hasRow('tool-foo')).toBe(false)
  51. expect(document.rowField('message-feedback', 'disabled')).toBe(true)
  52. expect(document.rowField('message-feedback', 'config')).toBeUndefined()
  53. expect(document.rowField('absent', 'disabled')).toBeUndefined()
  54. // A `!!js` scalar reads as its source text; the writer never evaluates it.
  55. expect(document.rowField('tool-bash', 'disabled')).toBe("process.platform === 'win32'")
  56. document.setRowField('tool-bash', 'config', { a: 1, b: 'x' })
  57. document.setRowField('new-row', 'disabled', true)
  58. const text = document.toString()
  59. expect(text).toContain('# top comment')
  60. expect(text).toContain('- id: message-feedback # trailing')
  61. expect(text).toContain("disabled: !!js process.platform === 'win32'")
  62. expect(text).toContain('- id: new-row\n disabled: true')
  63. expect(text).toMatch(/config:\n\s+a: 1\n\s+b: x/)
  64. // The rewritten text is what the boot parser loads.
  65. const rows = parsePatchList('spec', 'x.yml', text, 'patches')
  66. expect(rows.find(patch => patch.id === 'tool-bash')?.config).toEqual({ a: 1, b: 'x' })
  67. expect(isJsExpr(rows.find(patch => patch.id === 'tool-bash')?.disabled)).toBe(true)
  68. })
  69. it('refuses to set id or insert through the key setter', () => {
  70. const document = PatchDocument.parse(SAMPLE, 'x.yml')
  71. expect(() => { document.setRowField('tool-bash', 'id', 'other') }).toThrow('id is not a settable key')
  72. expect(() => { document.setRowField('tool-bash', 'insert', []) }).toThrow('insert is not a settable key')
  73. })
  74. it('deletes keys and removes a patch reduced to its id', () => {
  75. const document = PatchDocument.parse(SAMPLE, 'x.yml')
  76. expect(document.deleteRowField('absent', 'disabled')).toBe(false)
  77. expect(document.deleteRowField('tool-bash', 'absent')).toBe(false)
  78. expect(document.deleteRowField('tool-bash', 'config')).toBe(true)
  79. expect(document.hasRow('tool-bash')).toBe(true)
  80. expect(document.deleteRowField('message-feedback', 'disabled')).toBe(true)
  81. expect(document.hasRow('message-feedback')).toBe(false)
  82. const text = document.toString()
  83. expect(text).not.toContain('message-feedback')
  84. expect(text).toContain('# top comment')
  85. expect(text).not.toContain('keep me')
  86. })
  87. it('appends inserted rows to the root list, to a group, and creates the patch when absent', () => {
  88. const document = PatchDocument.parse(SAMPLE, 'x.yml')
  89. document.appendInsert({ id: 'tool-bar', name: 'dsh-tool-bar' })
  90. document.appendInsert({ id: 'in-group', name: 'dsh-in-group', config: { k: 1 } }, 'agents')
  91. document.appendInsert({ id: 'in-group-2', name: 'dsh-in-group-2' }, 'agents')
  92. const rows = parsePatchList('spec', 'x.yml', document.toString(), 'patches')
  93. const root = rows.find(patch => patch.insert !== undefined && patch.id === undefined)
  94. expect(root?.insert?.map(row => row.id)).toEqual(['tool-foo', 'group-a', 'tool-bar'])
  95. const grouped = rows.find(patch => patch.id === 'agents')
  96. expect(grouped?.insert?.map(row => row.id)).toEqual(['in-group', 'in-group-2'])
  97. expect(grouped?.insert?.[0]?.config).toEqual({ k: 1 })
  98. })
  99. it('refuses to insert a row id the file already inserts', () => {
  100. const document = PatchDocument.parse(SAMPLE, 'x.yml')
  101. expect(() => { document.appendInsert({ id: 'nested-row', name: 'x' }) }).toThrow('"nested-row" is already inserted')
  102. })
  103. it('finds and removes inserted rows, including inside inserted groups', () => {
  104. const document = PatchDocument.parse(SAMPLE, 'x.yml')
  105. expect(document.insertedRow('tool-foo')).toEqual({ id: 'tool-foo', name: 'dsh-tool-foo', config: {} })
  106. expect(document.insertedRow('nested-row')).toEqual({ id: 'nested-row', name: 'dsh-nested' })
  107. expect(document.insertedRow('absent')).toBeUndefined()
  108. expect(document.removeInsert('absent')).toBe(false)
  109. expect(document.removeInsert('nested-row')).toBe(true)
  110. expect(document.insertedRow('nested-row')).toBeUndefined()
  111. expect(document.removeInsert('tool-foo')).toBe(true)
  112. expect(document.removeInsert('group-a')).toBe(true)
  113. // The insert patch emptied by the removals is gone with its comment block.
  114. expect(document.toString()).not.toContain('insert:')
  115. expect(document.toString()).toContain('- id: tool-bash')
  116. })
  117. it('skips non-map items and non-group rows while walking insert lists', () => {
  118. const document = PatchDocument.parse(
  119. '- insert:\n - plain\n - id: leaf\n name: x\n group: true\n config: notalist\n', 'x.yml',
  120. )
  121. expect(document.insertedRow('leaf')).toEqual({ id: 'leaf', name: 'x', group: true, config: 'notalist' })
  122. expect(document.insertedRow('missing')).toBeUndefined()
  123. })
  124. it('refuses an insert patch whose list is not a sequence', () => {
  125. const document = PatchDocument.parse('- insert: 1\n', 'x.yml')
  126. expect(() => document.insertedRow('x')).toThrow('insert patch must hold a list of rows')
  127. })
  128. })
  129. describe('parsePatchList', () => {
  130. it('reads empty text as an empty layer and anchors relative names to the file', () => {
  131. expect(parsePatchList('spec', '/tmp/x.yml', '', 'patches')).toEqual([])
  132. const rows = parsePatchList('spec', '/tmp/dir/x.yml', [
  133. '- insert:',
  134. ' - id: rel',
  135. ' name: ./plugin.js',
  136. ' - id: bare',
  137. ' name: dsh-bare',
  138. ' - id: g',
  139. ' name: cordis:group',
  140. ' group: true',
  141. ' config:',
  142. ' - id: up',
  143. ' name: ../up.js',
  144. '- id: other',
  145. ' disabled: true',
  146. ].join('\n'), 'overlay')
  147. const insert = rows[0]?.insert ?? []
  148. expect(insert[0]?.name).toBe(pathToFileURL(resolve('/tmp/dir', './plugin.js')).href)
  149. expect(insert[1]?.name).toBe('dsh-bare')
  150. expect((insert[2]?.config as { name: string }[])[0]?.name).toBe(pathToFileURL(resolve('/tmp/dir', '../up.js')).href)
  151. expect(rows[1]).toEqual({ id: 'other', disabled: true })
  152. })
  153. it('fails loud on invalid YAML, a non-array root, and a non-map entry', () => {
  154. expect(() => parsePatchList('spec', 'x.yml', '- id: [\n', 'overlay')).toThrow('spec: failed to parse overlay x.yml')
  155. expect(() => parsePatchList('spec', 'x.yml', 'a: 1\n', 'patches')).toThrow('patches x.yml must be a top-level YAML array')
  156. expect(() => parsePatchList('spec', 'x.yml', '- 1\n', 'patches')).toThrow('patches entry 1 in x.yml must be a mapping')
  157. })
  158. it('anchors names in place and returns the same list', () => {
  159. const patches = [{ insert: [{ id: 'a', name: './a.js' }] }]
  160. expect(anchorInsertedPluginNames(patches, '/tmp/dir/x.yml')).toBe(patches)
  161. expect(patches[0]?.insert[0]?.name).toBe(pathToFileURL('/tmp/dir/a.js').href)
  162. })
  163. })
  164. describe('readPatchListFile', () => {
  165. it('reads undefined for an absent file, the list for a present one, and throws for an unreadable one', async () => {
  166. const dir = await tempDir()
  167. expect(await readPatchListFile('spec', join(dir, 'absent.yml'), 'patches')).toBeUndefined()
  168. await writeFile(join(dir, 'x.yml'), '- id: a\n disabled: true\n')
  169. expect(await readPatchListFile('spec', join(dir, 'x.yml'), 'patches')).toEqual([{ id: 'a', disabled: true }])
  170. await expect(readPatchListFile('spec', dir, 'patches')).rejects.toThrow('spec: failed to read patches')
  171. })
  172. })
  173. describe('mutatePatchFile', () => {
  174. it('creates the file under the lock, replaces it atomically, and reads it back', async () => {
  175. const dir = await tempDir()
  176. const file = join(dir, 'nested', 'cordis.patch.yml')
  177. const rows = await mutatePatchFile(file, (document) => {
  178. document.setRowField('tool-web', 'disabled', true)
  179. document.appendInsert({ id: 'tool-foo', name: 'dsh-tool-foo' })
  180. }, { binName: 'spec', mode: 0o600, dirMode: 0o700 })
  181. expect(rows).toEqual([{ id: 'tool-web', disabled: true }, { insert: [{ id: 'tool-foo', name: 'dsh-tool-foo' }] }])
  182. expect(await readFile(file, 'utf8')).toBe('- id: tool-web\n disabled: true\n- insert:\n - id: tool-foo\n name: dsh-tool-foo\n')
  183. expect(existsSync(`${file}.lock`)).toBe(false)
  184. if (process.platform !== 'win32') expect((await stat(file)).mode & 0o777).toBe(0o600)
  185. // A second edit keeps the author's comment and reverts only its own key.
  186. await writeFile(file, `# mine\n${await readFile(file, 'utf8')}`)
  187. const reverted = await mutatePatchFile(file, (document) => {
  188. document.deleteRowField('tool-web', 'disabled')
  189. }, { binName: 'spec', mode: 0o600, waitMs: 500 })
  190. expect(reverted).toEqual([{ insert: [{ id: 'tool-foo', name: 'dsh-tool-foo' }] }])
  191. expect(await readFile(file, 'utf8')).toContain('# mine')
  192. })
  193. it('writes nothing when the edit changes nothing', async () => {
  194. const dir = await tempDir()
  195. const file = join(dir, 'cordis.patch.yml')
  196. expect(await mutatePatchFile(file, () => {}, { binName: 'spec', mode: 0o600 })).toEqual([])
  197. expect(existsSync(file)).toBe(false)
  198. })
  199. it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)('reports a file that exists but cannot be read', async () => {
  200. const dir = await tempDir()
  201. const file = join(dir, 'cordis.patch.yml')
  202. await writeFile(file, '[]\n', { mode: 0o000 })
  203. await expect(mutatePatchFile(file, () => {}, { binName: 'spec', mode: 0o600 }))
  204. .rejects.toThrow('spec: failed to read patches')
  205. expect(existsSync(`${file}.lock`)).toBe(false)
  206. })
  207. it('keeps the comment above a removed row', async () => {
  208. const dir = await tempDir()
  209. const file = join(dir, 'cordis.patch.yml')
  210. await writeFile(file, '# first\n- id: a\n disabled: true\n# second\n- id: b\n disabled: true\n# third\n- id: c\n disabled: true\n')
  211. await mutatePatchFile(file, (document) => { document.deleteRowField('a', 'disabled') }, { binName: 'spec', mode: 0o600 })
  212. expect(await readFile(file, 'utf8')).toBe('# first\n# second\n- id: b\n disabled: true\n# third\n- id: c\n disabled: true\n')
  213. // Removing from the end twice keeps the file's own comment order.
  214. await mutatePatchFile(file, (document) => {
  215. document.deleteRowField('c', 'disabled')
  216. document.deleteRowField('b', 'disabled')
  217. }, { binName: 'spec', mode: 0o600 })
  218. expect(await readFile(file, 'utf8')).toBe('[]\n\n# first\n# second\n# third\n')
  219. })
  220. it('reports a file that does not parse', async () => {
  221. const dir = await tempDir()
  222. const file = join(dir, 'cordis.patch.yml')
  223. await writeFile(file, 'a: 1\n')
  224. await expect(mutatePatchFile(file, () => {}, { binName: 'spec', mode: 0o600 }))
  225. .rejects.toThrow('must be a top-level YAML array')
  226. })
  227. })