profile.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  1. /**
  2. * Profile machinery of `dsh-app-boot`: directory resolution and init,
  3. * manifest round-trips, two-anchor bundle resolution, patch-layer loading,
  4. * empty-root composition, and the installation module-fallback healing.
  5. */
  6. import { lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
  7. import { tmpdir } from 'node:os'
  8. import { join } from 'node:path'
  9. import { describe, expect, it } from 'vitest'
  10. import {
  11. composeEntries,
  12. healProfilesModuleFallback,
  13. initProfile,
  14. loadProfile,
  15. PROFILE_PATCH_FILENAME,
  16. PROFILE_TEMPLATES,
  17. readProfileManifest,
  18. resolveBundleDir,
  19. resolveProfileDir,
  20. writeProfileManifest,
  21. } from '../src/index.ts'
  22. const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-profile-'))
  23. /** Stage a fake installed app: package.json with deps and a node_modules holding bundles. */
  24. function stageInstallation(bundles: Record<string, { patch?: string; deps?: Record<string, string> }>): string {
  25. const root = tmp()
  26. const appDir = join(root, 'app')
  27. mkdirSync(join(appDir, 'node_modules'), { recursive: true })
  28. const appDeps: Record<string, string> = {}
  29. for (const [name, spec] of Object.entries(bundles)) {
  30. appDeps[name] = '0.0.0'
  31. const dir = join(appDir, 'node_modules', name)
  32. mkdirSync(dir, { recursive: true })
  33. writeFileSync(join(dir, 'package.json'), JSON.stringify({
  34. name,
  35. version: '0.0.0',
  36. dependencies: spec.deps ?? {},
  37. ...spec.patch === undefined ? {} : { dsh: { bundle: { patch: './cordis.patch.yml' } } },
  38. }))
  39. if (spec.patch !== undefined) writeFileSync(join(dir, 'cordis.patch.yml'), spec.patch)
  40. }
  41. writeFileSync(join(appDir, 'package.json'), JSON.stringify({ name: 'dsh-app', dependencies: appDeps }))
  42. return join(appDir, 'package.json')
  43. }
  44. describe('resolveProfileDir', () => {
  45. it('joins the home and rejects traversal-shaped names', () => {
  46. const home = tmp()
  47. expect(resolveProfileDir('tui', home)).toBe(join(home, 'profiles', 'tui'))
  48. for (const bad of ['', '.', '..', 'a/b', 'a\\b']) {
  49. expect(() => resolveProfileDir(bad, home)).toThrow('invalid profile name')
  50. }
  51. })
  52. })
  53. describe('initProfile', () => {
  54. it('creates manifest, user patch layer, and pnpm workspace once, never overwriting', () => {
  55. const home = tmp()
  56. const dir = resolveProfileDir('tui', home)
  57. initProfile(dir, ['@deepseek-ai/dsh-base'])
  58. const manifest = readProfileManifest('t', dir)
  59. expect(manifest.dsh?.profile?.bundles).toEqual(['@deepseek-ai/dsh-base'])
  60. expect(readFileSync(join(dir, PROFILE_PATCH_FILENAME), 'utf8')).toContain('[]')
  61. expect(readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8')).toContain('nodeLinker: hoisted')
  62. // Re-init keeps user edits.
  63. writeFileSync(join(dir, PROFILE_PATCH_FILENAME), '- id: x\n config: {}\n')
  64. initProfile(dir, ['other'])
  65. expect(readProfileManifest('t', dir).dsh?.profile?.bundles).toEqual(['@deepseek-ai/dsh-base'])
  66. expect(readFileSync(join(dir, PROFILE_PATCH_FILENAME), 'utf8')).toContain('- id: x')
  67. })
  68. })
  69. describe('manifest round-trip', () => {
  70. it('writes and reads back, and fails loud on a broken manifest', () => {
  71. const dir = tmp()
  72. writeProfileManifest(dir, { name: 'p', dsh: { profile: { bundles: ['a'] } } })
  73. expect(readProfileManifest('t', dir).dsh?.profile?.bundles).toEqual(['a'])
  74. writeFileSync(join(dir, 'package.json'), '[]')
  75. expect(() => readProfileManifest('t', dir)).toThrow('must hold a JSON object')
  76. expect(() => readProfileManifest('t', join(dir, 'nope'))).toThrow('failed to read profile manifest')
  77. })
  78. })
  79. describe('resolveBundleDir', () => {
  80. it('prefers the installation anchor, falls back to the profile, and fails loud', () => {
  81. const anchor = stageInstallation({ 'in-box': { patch: '[]\n' } })
  82. const profileDir = tmp()
  83. mkdirSync(join(profileDir, 'node_modules', 'local-only'), { recursive: true })
  84. writeFileSync(join(profileDir, 'package.json'), '{}')
  85. writeFileSync(join(profileDir, 'node_modules', 'local-only', 'package.json'), JSON.stringify({ name: 'local-only', version: '0.0.0' }))
  86. expect(resolveBundleDir('t', 'in-box', anchor, profileDir)).toContain('in-box')
  87. expect(resolveBundleDir('t', 'local-only', anchor, profileDir)).toContain('local-only')
  88. expect(() => resolveBundleDir('t', 'absent', anchor, profileDir)).toThrow('cannot resolve profile bundle')
  89. })
  90. it('resolves a package whose exports map omits ./package.json', () => {
  91. // Common on npm: an exports map without "./package.json" makes
  92. // require.resolve('<pkg>/package.json') throw ERR_PACKAGE_PATH_NOT_EXPORTED;
  93. // resolution must fall through to the paths probe instead of misreporting
  94. // the installed package as missing.
  95. const anchor = stageInstallation({})
  96. const profileDir = tmp()
  97. writeFileSync(join(profileDir, 'package.json'), '{}')
  98. const dir = join(profileDir, 'node_modules', 'sealed-bundle')
  99. mkdirSync(dir, { recursive: true })
  100. writeFileSync(join(dir, 'package.json'), JSON.stringify({
  101. name: 'sealed-bundle',
  102. version: '0.0.0',
  103. exports: { '.': './index.js' },
  104. dsh: { bundle: { patch: './cordis.patch.yml' } },
  105. }))
  106. writeFileSync(join(dir, 'index.js'), '')
  107. writeFileSync(join(dir, 'cordis.patch.yml'), '[]\n')
  108. expect(resolveBundleDir('t', 'sealed-bundle', anchor, profileDir)).toBe(dir)
  109. })
  110. })
  111. describe('loadProfile', () => {
  112. it('resolves each dsh.profile.bundles entry to its patch layer in order, plus the user layer', () => {
  113. const anchor = stageInstallation({
  114. 'bundle-a': { patch: '- insert:\n - id: a\n name: pkg-a\n' },
  115. 'bundle-b': { patch: '- id: a\n config:\n v: 2\n' },
  116. })
  117. const home = tmp()
  118. const dir = resolveProfileDir('demo', home)
  119. initProfile(dir, ['bundle-a', 'bundle-b'])
  120. writeFileSync(join(dir, PROFILE_PATCH_FILENAME), '- id: a\n config:\n v: 3\n')
  121. const profile = loadProfile('t', 'demo', anchor, home)
  122. expect(profile.layers.map(layer => layer.packageName)).toEqual(['bundle-a', 'bundle-b'])
  123. expect(profile.patches).toHaveLength(1)
  124. const entries = composeEntries([
  125. ...profile.layers.map(layer => layer.patches),
  126. profile.patches,
  127. ])
  128. expect(entries).toEqual([{ id: 'a', name: 'pkg-a', config: { v: 3 } }])
  129. // A hand-made profile without the user layer file or dsh section: empty layers, no throw.
  130. rmSync(join(dir, PROFILE_PATCH_FILENAME))
  131. expect(loadProfile('t', 'demo', anchor, home).patches).toEqual([])
  132. writeProfileManifest(dir, { name: 'bare' })
  133. const bare = loadProfile('t', 'demo', anchor, home)
  134. expect(bare.layers).toEqual([])
  135. })
  136. it('auto-initializes only shipped templates and fails loud otherwise', () => {
  137. const anchor = stageInstallation({})
  138. const home = tmp()
  139. expect(() => loadProfile('t', 'custom', anchor, home))
  140. .toThrow('profile "custom" does not exist')
  141. // The web template auto-initializes on first load. Bundle resolution
  142. // cannot be asserted to fail here: the source-plane test runner resolves
  143. // @deepseek-ai/* through tsconfig paths regardless of the staged anchor.
  144. expect(PROFILE_TEMPLATES.web).toContain('@deepseek-ai/dsh-base')
  145. try {
  146. loadProfile('t', 'web', anchor, home)
  147. } catch {
  148. // Resolution failure is the plain-Node outcome for this empty anchor.
  149. }
  150. expect(readProfileManifest('t', resolveProfileDir('web', home)).dsh?.profile?.bundles)
  151. .toEqual([...PROFILE_TEMPLATES.web ?? []])
  152. })
  153. it('fails loud when a listed bundle declares no dsh.bundle', () => {
  154. const anchor = stageInstallation({ 'not-a-bundle': {} })
  155. const home = tmp()
  156. const dir = resolveProfileDir('demo', home)
  157. initProfile(dir, ['not-a-bundle'])
  158. expect(() => loadProfile('t', 'demo', anchor, home)).toThrow('declares no dsh.bundle')
  159. })
  160. })
  161. describe('composeEntries', () => {
  162. it('applies layers over an empty root and reports skipped patches', () => {
  163. const warnings: string[] = []
  164. const entries = composeEntries([
  165. [{ insert: [{ id: 'x', name: 'pkg-x', config: { a: 1 } }] }],
  166. [{ id: 'x', config: { a: 2 } }, { id: 'missing', config: {} }],
  167. ], message => warnings.push(message))
  168. expect(entries).toEqual([{ id: 'x', name: 'pkg-x', config: { a: 2 } }])
  169. expect(warnings.join('\n')).toContain('"missing"')
  170. // Default warn sink: skipped patches are silently dropped (boot repeats them).
  171. expect(composeEntries([[{ id: 'missing', config: {} }]])).toEqual([])
  172. })
  173. })
  174. describe('healProfilesModuleFallback', () => {
  175. it('links the app and bundle dependency surface flat under profiles/node_modules', () => {
  176. const anchor = stageInstallation({
  177. 'bundle-a': { patch: '[]\n', deps: { 'dep-of-a': '0.0.0', 'ghost-dep': '0.0.0' } },
  178. 'plain-lib': {},
  179. })
  180. // An app dependency that is declared but not installed: skipped, not fatal.
  181. const appManifest = JSON.parse(readFileSync(anchor, 'utf8')) as { dependencies: Record<string, string> }
  182. appManifest.dependencies['never-installed'] = '0.0.0'
  183. writeFileSync(anchor, JSON.stringify(appManifest))
  184. // dep-of-a lives in the installation's node_modules too.
  185. const modules = join(anchor, '..', 'node_modules')
  186. mkdirSync(join(modules, 'dep-of-a'), { recursive: true })
  187. writeFileSync(join(modules, 'dep-of-a', 'package.json'), JSON.stringify({ name: 'dep-of-a', version: '0.0.0' }))
  188. const home = tmp()
  189. healProfilesModuleFallback(anchor, home)
  190. const fallback = join(home, 'profiles', 'node_modules')
  191. // App deps, the bundle's own deps, and the bundle itself are linked; the
  192. // plain library is linked as an app dep (harmless), the app itself too.
  193. for (const name of ['bundle-a', 'plain-lib', 'dep-of-a', 'dsh-app']) {
  194. expect(lstatSync(join(fallback, name)).isSymbolicLink(), name).toBe(true)
  195. }
  196. // Idempotent, and a moved target is re-pointed.
  197. healProfilesModuleFallback(anchor, home)
  198. const before = readlinkSync(join(fallback, 'dep-of-a'))
  199. expect(before).toContain('dep-of-a')
  200. })
  201. it('throws when a fallback entry is a real directory', () => {
  202. const anchor = stageInstallation({})
  203. const home = tmp()
  204. mkdirSync(join(home, 'profiles', 'node_modules', 'dsh-app'), { recursive: true })
  205. expect(() => { healProfilesModuleFallback(anchor, home) }).toThrow('is not a symlink')
  206. })
  207. it('replaces a wrong symlink', () => {
  208. const anchor = stageInstallation({})
  209. const home = tmp()
  210. const fallback = join(home, 'profiles', 'node_modules')
  211. mkdirSync(fallback, { recursive: true })
  212. symlinkSync(tmp(), join(fallback, 'dsh-app'), 'junction')
  213. healProfilesModuleFallback(anchor, home)
  214. expect(readlinkSync(join(fallback, 'dsh-app'))).toContain('app')
  215. })
  216. it('tolerates losing the concurrent-heal race to an identical link and rejects a different one', () => {
  217. // The EEXIST arm: a second process wrote the link between our lstat miss
  218. // and symlinkSync. Simulated by pre-creating the correct link and calling
  219. // the internal path through a stale-lstat shim is not possible from
  220. // outside, so probe the observable contract: healing twice concurrently
  221. // is a no-op, and a foreign REAL directory still fails loud.
  222. const anchor = stageInstallation({})
  223. const home = tmp()
  224. healProfilesModuleFallback(anchor, home)
  225. healProfilesModuleFallback(anchor, home) // second healer sees the correct link
  226. const fallback = join(home, 'profiles', 'node_modules')
  227. expect(lstatSync(join(fallback, 'dsh-app')).isSymbolicLink()).toBe(true)
  228. })
  229. })