profile.spec.ts 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700
  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 { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
  7. import { tmpdir } from 'node:os'
  8. import { join } from 'node:path'
  9. import { withFileLock } from '@deepseek-ai/dsh-atomic-write'
  10. import { describe, expect, it } from 'vitest'
  11. import {
  12. composeEntries,
  13. healProfilesModuleFallback,
  14. initProfile,
  15. loadProfile,
  16. PROFILE_PATCH_FILENAME,
  17. PROFILE_TEMPLATES,
  18. readProfileManifest,
  19. resolveBundleDir,
  20. resolveProfileDir,
  21. writeProfileManifest,
  22. } from '../src/index.ts'
  23. const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-profile-'))
  24. /** Stage a fake installed app: package.json with deps and a node_modules holding bundles. */
  25. function stageInstallation(bundles: Record<string, { patch?: string; deps?: Record<string, string> }>): string {
  26. const root = tmp()
  27. const appDir = join(root, 'app')
  28. mkdirSync(join(appDir, 'node_modules'), { recursive: true })
  29. const appDeps: Record<string, string> = {}
  30. for (const [name, spec] of Object.entries(bundles)) {
  31. appDeps[name] = '0.0.0'
  32. const dir = join(appDir, 'node_modules', name)
  33. mkdirSync(dir, { recursive: true })
  34. writeFileSync(join(dir, 'package.json'), JSON.stringify({
  35. name,
  36. version: '0.0.0',
  37. type: 'module',
  38. main: './index.js',
  39. dependencies: spec.deps ?? {},
  40. ...spec.patch === undefined ? {} : { dsh: { bundle: { patch: './cordis.patch.yml' } } },
  41. }))
  42. writeFileSync(join(dir, 'index.js'), `export const packageName = ${JSON.stringify(name)}\n`)
  43. if (spec.patch !== undefined) writeFileSync(join(dir, 'cordis.patch.yml'), spec.patch)
  44. }
  45. writeFileSync(join(appDir, 'package.json'), JSON.stringify({
  46. name: 'dsh-app', version: '0.0.0', type: 'module', main: './index.js', dependencies: appDeps,
  47. }))
  48. writeFileSync(join(appDir, 'index.js'), 'export const packageName = "dsh-app"\n')
  49. return join(appDir, 'package.json')
  50. }
  51. describe('resolveProfileDir', () => {
  52. it('joins the home and rejects traversal-shaped names', () => {
  53. const home = tmp()
  54. expect(resolveProfileDir('tui', home)).toBe(join(home, 'profiles', 'tui'))
  55. for (const bad of ['', '.', '..', 'a/b', 'a\\b']) {
  56. expect(() => resolveProfileDir(bad, home)).toThrow('invalid profile name')
  57. }
  58. })
  59. })
  60. describe('initProfile', () => {
  61. it('creates manifest, user patch layer, and pnpm workspace once, never overwriting', () => {
  62. const home = tmp()
  63. const dir = resolveProfileDir('tui', home)
  64. initProfile(dir, ['@deepseek-ai/dsh-base'])
  65. const manifest = readProfileManifest('t', dir)
  66. expect(manifest.dsh?.profile?.bundles).toEqual(['@deepseek-ai/dsh-base'])
  67. expect(manifest.dsh?.profile?.patchReload).toBe('live')
  68. expect(readFileSync(join(dir, PROFILE_PATCH_FILENAME), 'utf8')).toContain('[]')
  69. expect(readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8')).toContain('nodeLinker: hoisted')
  70. // Re-init keeps user edits.
  71. writeFileSync(join(dir, PROFILE_PATCH_FILENAME), '- id: x\n config: {}\n')
  72. initProfile(dir, ['other'], 'startup')
  73. expect(readProfileManifest('t', dir).dsh?.profile?.bundles).toEqual(['@deepseek-ai/dsh-base'])
  74. expect(readProfileManifest('t', dir).dsh?.profile?.patchReload).toBe('live')
  75. expect(readFileSync(join(dir, PROFILE_PATCH_FILENAME), 'utf8')).toContain('- id: x')
  76. })
  77. })
  78. describe('manifest round-trip', () => {
  79. it('writes and reads back, and fails loud on a broken manifest', () => {
  80. const dir = tmp()
  81. writeProfileManifest(dir, { name: 'p', dsh: { profile: { bundles: ['a'] } } })
  82. expect(readProfileManifest('t', dir).dsh?.profile?.bundles).toEqual(['a'])
  83. writeFileSync(join(dir, 'package.json'), '[]')
  84. expect(() => readProfileManifest('t', dir)).toThrow('must hold a JSON object')
  85. expect(() => readProfileManifest('t', join(dir, 'nope'))).toThrow('failed to read profile manifest')
  86. })
  87. })
  88. describe('resolveBundleDir', () => {
  89. it('prefers the installation anchor, falls back to the profile, and fails loud', () => {
  90. const anchor = stageInstallation({ 'in-box': { patch: '[]\n' } })
  91. const profileDir = tmp()
  92. mkdirSync(join(profileDir, 'node_modules', 'local-only'), { recursive: true })
  93. writeFileSync(join(profileDir, 'package.json'), '{}')
  94. writeFileSync(join(profileDir, 'node_modules', 'local-only', 'package.json'), JSON.stringify({ name: 'local-only', version: '0.0.0' }))
  95. expect(resolveBundleDir('t', 'in-box', anchor, profileDir)).toContain('in-box')
  96. expect(resolveBundleDir('t', 'local-only', anchor, profileDir)).toContain('local-only')
  97. expect(() => resolveBundleDir('t', 'absent', anchor, profileDir)).toThrow('cannot resolve profile bundle')
  98. })
  99. it('resolves a package whose exports map omits ./package.json', () => {
  100. // Common on npm: an exports map without "./package.json" makes
  101. // require.resolve('<pkg>/package.json') throw ERR_PACKAGE_PATH_NOT_EXPORTED;
  102. // resolution must fall through to the paths probe instead of misreporting
  103. // the installed package as missing.
  104. const anchor = stageInstallation({})
  105. const profileDir = tmp()
  106. writeFileSync(join(profileDir, 'package.json'), '{}')
  107. const dir = join(profileDir, 'node_modules', 'sealed-bundle')
  108. mkdirSync(dir, { recursive: true })
  109. writeFileSync(join(dir, 'package.json'), JSON.stringify({
  110. name: 'sealed-bundle',
  111. version: '0.0.0',
  112. exports: { '.': './index.js' },
  113. dsh: { bundle: { patch: './cordis.patch.yml' } },
  114. }))
  115. writeFileSync(join(dir, 'index.js'), '')
  116. writeFileSync(join(dir, 'cordis.patch.yml'), '[]\n')
  117. expect(resolveBundleDir('t', 'sealed-bundle', anchor, profileDir)).toBe(dir)
  118. })
  119. })
  120. describe('loadProfile', () => {
  121. it('resolves each dsh.profile.bundles entry to its patch layer in order, plus the user layer', () => {
  122. const anchor = stageInstallation({
  123. 'bundle-a': { patch: '- insert:\n - id: a\n name: pkg-a\n' },
  124. 'bundle-b': { patch: '- id: a\n config:\n v: 2\n' },
  125. })
  126. const home = tmp()
  127. const dir = resolveProfileDir('demo', home)
  128. initProfile(dir, ['bundle-a', 'bundle-b'])
  129. writeFileSync(join(dir, PROFILE_PATCH_FILENAME), '- id: a\n config:\n v: 3\n')
  130. const profile = loadProfile('t', 'demo', anchor, home)
  131. expect(profile.layers.map(layer => layer.packageName)).toEqual(['bundle-a', 'bundle-b'])
  132. expect(profile.patches).toHaveLength(1)
  133. expect(profile.patchReload).toBe('live')
  134. const entries = composeEntries([
  135. ...profile.layers.map(layer => layer.patches),
  136. profile.patches,
  137. ])
  138. expect(entries).toEqual([{ id: 'a', name: 'pkg-a', config: { v: 3 } }])
  139. // A hand-made profile without the user layer file or dsh section: empty layers, no throw.
  140. rmSync(join(dir, PROFILE_PATCH_FILENAME))
  141. expect(loadProfile('t', 'demo', anchor, home).patches).toEqual([])
  142. writeProfileManifest(dir, { name: 'bare' })
  143. const bare = loadProfile('t', 'demo', anchor, home)
  144. expect(bare.layers).toEqual([])
  145. expect(bare.patchReload).toBe('live')
  146. })
  147. it('auto-initializes only shipped templates and fails loud otherwise', () => {
  148. const anchor = stageInstallation({})
  149. const home = tmp()
  150. expect(() => loadProfile('t', 'custom', anchor, home))
  151. .toThrow('profile "custom" does not exist')
  152. // The web template auto-initializes on first load. Bundle resolution
  153. // cannot be asserted to fail here: the source-plane test runner resolves
  154. // @deepseek-ai/* through tsconfig paths regardless of the staged anchor.
  155. expect(PROFILE_TEMPLATES.web?.bundles).toContain('@deepseek-ai/dsh-base')
  156. expect(PROFILE_TEMPLATES.web?.patchReload).toBe('live')
  157. expect(PROFILE_TEMPLATES.headless?.patchReload).toBe('startup')
  158. expect(PROFILE_TEMPLATES.acp).toEqual({
  159. bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-acp-app'],
  160. patchReload: 'startup',
  161. })
  162. expect(PROFILE_TEMPLATES.sdk).toEqual({
  163. bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-sdk-app'],
  164. patchReload: 'startup',
  165. })
  166. expect(PROFILE_TEMPLATES['sdk-minimal']).toEqual({
  167. bundles: ['@deepseek-ai/dsh-sdk-minimal'],
  168. patchReload: 'startup',
  169. })
  170. try {
  171. loadProfile('t', 'web', anchor, home)
  172. } catch {
  173. // Resolution failure is the plain-Node outcome for this empty anchor.
  174. }
  175. expect(readProfileManifest('t', resolveProfileDir('web', home)).dsh?.profile?.bundles)
  176. .toEqual([...PROFILE_TEMPLATES.web?.bundles ?? []])
  177. expect(readProfileManifest('t', resolveProfileDir('web', home)).dsh?.profile?.patchReload)
  178. .toBe('live')
  179. })
  180. it('normalizes only the exact installation-owned headless bundle tuple', () => {
  181. const anchor = stageInstallation({
  182. '@deepseek-ai/dsh-base': { patch: '[]\n' },
  183. '@deepseek-ai/dsh-web-app': { patch: '[]\n' },
  184. '@deepseek-ai/dsh-headless': { patch: '[]\n' },
  185. 'custom-bundle': { patch: '[]\n' },
  186. })
  187. const home = tmp()
  188. const stock = resolveProfileDir('headless', home)
  189. initProfile(stock, [
  190. '@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@deepseek-ai/dsh-headless',
  191. ])
  192. const retiredManifest = readProfileManifest('t', stock)
  193. delete retiredManifest.dsh!.profile!.patchReload
  194. writeProfileManifest(stock, retiredManifest)
  195. loadProfile('t', 'headless', anchor, home)
  196. expect(readProfileManifest('t', stock).dsh?.profile).toEqual({
  197. bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless'],
  198. patchReload: 'startup',
  199. })
  200. const customHome = tmp()
  201. const custom = resolveProfileDir('headless', customHome)
  202. initProfile(custom, [
  203. '@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@deepseek-ai/dsh-headless', 'custom-bundle',
  204. ])
  205. loadProfile('t', 'headless', anchor, customHome)
  206. expect(readProfileManifest('t', custom).dsh?.profile?.bundles).toEqual([
  207. '@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@deepseek-ai/dsh-headless', 'custom-bundle',
  208. ])
  209. })
  210. it('adds a shipped reload default only to an exact stock tuple and preserves explicit choices', () => {
  211. const anchor = stageInstallation({
  212. '@deepseek-ai/dsh-base': { patch: '[]\n' },
  213. '@deepseek-ai/dsh-web-app': { patch: '[]\n' },
  214. })
  215. const stockHome = tmp()
  216. const stock = resolveProfileDir('web', stockHome)
  217. initProfile(stock, PROFILE_TEMPLATES.web?.bundles ?? [])
  218. const stockManifest = readProfileManifest('t', stock)
  219. delete stockManifest.dsh!.profile!.patchReload
  220. writeProfileManifest(stock, stockManifest)
  221. expect(loadProfile('t', 'web', anchor, stockHome).patchReload).toBe('live')
  222. expect(readProfileManifest('t', stock).dsh?.profile?.patchReload).toBe('live')
  223. const explicitHome = tmp()
  224. const explicit = resolveProfileDir('web', explicitHome)
  225. initProfile(explicit, PROFILE_TEMPLATES.web?.bundles ?? [], 'startup')
  226. expect(loadProfile('t', 'web', anchor, explicitHome).patchReload).toBe('startup')
  227. })
  228. it('fails loud on an unknown patch reload value from disk', () => {
  229. const anchor = stageInstallation({})
  230. const home = tmp()
  231. const dir = resolveProfileDir('demo', home)
  232. initProfile(dir, [])
  233. const manifest = readProfileManifest('t', dir)
  234. const rawProfile = manifest.dsh!.profile as { patchReload?: string }
  235. rawProfile.patchReload = 'sometimes'
  236. writeProfileManifest(dir, manifest)
  237. expect(() => loadProfile('t', 'demo', anchor, home)).toThrow('patchReload must be "live" or "startup"')
  238. })
  239. it('fails loud when a listed bundle declares no dsh.bundle', () => {
  240. const anchor = stageInstallation({ 'not-a-bundle': {} })
  241. const home = tmp()
  242. const dir = resolveProfileDir('demo', home)
  243. initProfile(dir, ['not-a-bundle'])
  244. expect(() => loadProfile('t', 'demo', anchor, home)).toThrow('declares no dsh.bundle')
  245. })
  246. })
  247. describe('composeEntries', () => {
  248. it('applies layers over an empty root and reports skipped patches', () => {
  249. const warnings: string[] = []
  250. const entries = composeEntries([
  251. [{ insert: [{ id: 'x', name: 'pkg-x', config: { a: 1 } }] }],
  252. [{ id: 'x', config: { a: 2 } }, { id: 'missing', config: {} }],
  253. ], message => warnings.push(message))
  254. expect(entries).toEqual([{ id: 'x', name: 'pkg-x', config: { a: 2 } }])
  255. expect(warnings.join('\n')).toContain('"missing"')
  256. // Default warn sink: skipped patches are silently dropped (boot repeats them).
  257. expect(composeEntries([[{ id: 'missing', config: {} }]])).toEqual([])
  258. })
  259. })
  260. describe('healProfilesModuleFallback', () => {
  261. it('links the app and bundle dependency surface flat under profiles/node_modules', async () => {
  262. const anchor = stageInstallation({
  263. 'bundle-a': { patch: '[]\n', deps: { 'dep-of-a': '0.0.0', 'ghost-dep': '0.0.0' } },
  264. 'plain-lib': {},
  265. })
  266. // An app dependency that is declared but not installed: skipped, not fatal.
  267. const appManifest = JSON.parse(readFileSync(anchor, 'utf8')) as { dependencies: Record<string, string> }
  268. appManifest.dependencies['never-installed'] = '0.0.0'
  269. writeFileSync(anchor, JSON.stringify(appManifest))
  270. // dep-of-a lives in the installation's node_modules too.
  271. const modules = join(anchor, '..', 'node_modules')
  272. mkdirSync(join(modules, 'dep-of-a'), { recursive: true })
  273. writeFileSync(join(modules, 'dep-of-a', 'package.json'), JSON.stringify({ name: 'dep-of-a', version: '0.0.0' }))
  274. const home = tmp()
  275. await healProfilesModuleFallback(anchor, home)
  276. const fallback = join(home, 'profiles', 'node_modules')
  277. // App deps, the bundle's own deps, and the bundle itself are linked; the
  278. // plain library is linked as an app dep (harmless), the app itself too.
  279. for (const name of ['bundle-a', 'plain-lib', 'dep-of-a', 'dsh-app']) {
  280. expect(lstatSync(join(fallback, name)).isSymbolicLink(), name).toBe(true)
  281. }
  282. // Idempotent, and a moved target is re-pointed.
  283. await healProfilesModuleFallback(anchor, home)
  284. const before = readlinkSync(join(fallback, 'dep-of-a'))
  285. expect(before).toContain('dep-of-a')
  286. })
  287. it('throws when a fallback entry is a foreign file or directory', async () => {
  288. const anchor = stageInstallation({})
  289. for (const kind of ['file', 'directory']) {
  290. const home = tmp()
  291. const entry = join(home, 'profiles', 'node_modules', 'dsh-app')
  292. mkdirSync(join(entry, '..'), { recursive: true })
  293. if (kind === 'directory') mkdirSync(entry)
  294. else writeFileSync(entry, '')
  295. await expect(healProfilesModuleFallback(anchor, home)).rejects.toThrow('is not a symlink')
  296. }
  297. })
  298. it('replaces a wrong symlink', async () => {
  299. const anchor = stageInstallation({})
  300. const home = tmp()
  301. const fallback = join(home, 'profiles', 'node_modules')
  302. mkdirSync(fallback, { recursive: true })
  303. symlinkSync(tmp(), join(fallback, 'dsh-app'), 'junction')
  304. await healProfilesModuleFallback(anchor, home)
  305. expect(readlinkSync(join(fallback, 'dsh-app'))).toContain('app')
  306. })
  307. it('retains current links while repairing a missing sibling', async () => {
  308. const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } })
  309. const home = tmp()
  310. const fallback = join(home, 'profiles', 'node_modules')
  311. await healProfilesModuleFallback(anchor, home)
  312. const appTarget = readlinkSync(join(fallback, 'dsh-app'))
  313. unlinkSync(join(fallback, 'bundle-a'))
  314. await healProfilesModuleFallback(anchor, home)
  315. expect(readlinkSync(join(fallback, 'dsh-app'))).toBe(appTarget)
  316. expect(lstatSync(join(fallback, 'bundle-a')).isSymbolicLink()).toBe(true)
  317. })
  318. it('serializes concurrent healers and retains the identical link', async () => {
  319. const anchor = stageInstallation({})
  320. const home = tmp()
  321. await Promise.all([
  322. healProfilesModuleFallback(anchor, home),
  323. healProfilesModuleFallback(anchor, home),
  324. ])
  325. const fallback = join(home, 'profiles', 'node_modules')
  326. expect(lstatSync(join(fallback, 'dsh-app')).isSymbolicLink()).toBe(true)
  327. })
  328. it('does not acquire the writer lock for a complete generation', async () => {
  329. const anchor = stageInstallation({})
  330. const home = tmp()
  331. const modules = join(home, 'profiles', 'node_modules')
  332. await healProfilesModuleFallback(anchor, home)
  333. let releaseLock: (() => void) | undefined
  334. let reportLock: (() => void) | undefined
  335. const lockHeld = new Promise<void>((resolve) => { reportLock = resolve })
  336. const release = new Promise<void>((resolve) => { releaseLock = resolve })
  337. const holder = withFileLock(modules, async () => {
  338. reportLock?.()
  339. await release
  340. })
  341. await lockHeld
  342. const healer = healProfilesModuleFallback(anchor, home)
  343. const outcome = await Promise.race([
  344. healer.then(() => 'complete' as const),
  345. new Promise<'blocked'>(resolve => setTimeout(() => { resolve('blocked') }, 100)),
  346. ])
  347. releaseLock?.()
  348. await Promise.all([holder, healer])
  349. expect(outcome).toBe('complete')
  350. })
  351. it('waits for the module-fallback writer lock before publishing entries', async () => {
  352. const anchor = stageInstallation({})
  353. const home = tmp()
  354. const modules = join(home, 'profiles', 'node_modules')
  355. mkdirSync(modules, { recursive: true })
  356. let releaseLock: (() => void) | undefined
  357. let reportLock: (() => void) | undefined
  358. const lockHeld = new Promise<void>((resolve) => { reportLock = resolve })
  359. const release = new Promise<void>((resolve) => { releaseLock = resolve })
  360. const holder = withFileLock(modules, async () => {
  361. reportLock?.()
  362. await release
  363. })
  364. await lockHeld
  365. const healer = healProfilesModuleFallback(anchor, home)
  366. await new Promise(resolve => setTimeout(resolve, 20))
  367. expect(existsSync(join(modules, 'dsh-app'))).toBe(false)
  368. releaseLock?.()
  369. await Promise.all([holder, healer])
  370. expect(lstatSync(join(modules, 'dsh-app')).isSymbolicLink()).toBe(true)
  371. })
  372. it('writes real ESM proxies for a packaged executable', async () => {
  373. const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } })
  374. const bundleDir = join(anchor, '..', 'node_modules', 'bundle-a')
  375. const bundleManifest = JSON.parse(readFileSync(join(bundleDir, 'package.json'), 'utf8')) as Record<string, unknown>
  376. bundleManifest.exports = {
  377. '.': './index.js',
  378. './feature': './feature.js',
  379. './legacy/': './legacy/',
  380. './types': { types: './feature.d.ts' },
  381. }
  382. writeFileSync(join(bundleDir, 'package.json'), JSON.stringify(bundleManifest))
  383. writeFileSync(join(bundleDir, 'feature.js'), 'export const feature = "proxied"\n')
  384. const home = tmp()
  385. Object.defineProperty(process, 'pkg', { configurable: true, value: {} })
  386. try {
  387. await healProfilesModuleFallback(anchor, home)
  388. const fallback = join(home, 'profiles', 'node_modules')
  389. const proxy = join(fallback, 'bundle-a')
  390. expect(lstatSync(proxy).isDirectory()).toBe(true)
  391. const proxyManifest = JSON.parse(readFileSync(join(proxy, 'package.json'), 'utf8')) as {
  392. version: unknown
  393. exports: unknown
  394. dsh: { moduleFallback: { targets: Record<string, unknown> } }
  395. }
  396. expect(proxyManifest).toMatchObject({
  397. version: '0.0.0',
  398. exports: { '.': './entry-0.js', './feature': './entry-1.js' },
  399. })
  400. expect(proxyManifest.dsh.moduleFallback.targets['.']).toEqual(expect.stringContaining('/bundle-a/index.js'))
  401. await expect(import(join(proxy, 'entry-0.js'))).resolves.toMatchObject({ packageName: 'bundle-a' })
  402. await expect(import(join(proxy, 'entry-1.js'))).resolves.toMatchObject({ feature: 'proxied' })
  403. await healProfilesModuleFallback(anchor, home)
  404. } finally {
  405. delete (process as NodeJS.Process & { pkg?: unknown }).pkg
  406. }
  407. })
  408. it('resolves import-only exports from each package installation', async () => {
  409. const anchor = stageInstallation({
  410. 'bundle-a': { patch: '[]\n', deps: { 'nested-esm': '0.0.0' } },
  411. })
  412. const bundleDir = join(anchor, '..', 'node_modules', 'bundle-a')
  413. const bundleManifest = JSON.parse(readFileSync(join(bundleDir, 'package.json'), 'utf8')) as Record<string, unknown>
  414. bundleManifest.exports = { '.': { import: './index.js' } }
  415. writeFileSync(join(bundleDir, 'package.json'), JSON.stringify(bundleManifest))
  416. const nestedDir = join(bundleDir, 'node_modules', 'nested-esm')
  417. mkdirSync(nestedDir, { recursive: true })
  418. writeFileSync(join(nestedDir, 'package.json'), JSON.stringify({
  419. name: 'nested-esm',
  420. version: '0.0.0',
  421. type: 'module',
  422. exports: { import: './index.js' },
  423. }))
  424. writeFileSync(join(nestedDir, 'index.js'), 'export const nested = "proxied"\n')
  425. const home = tmp()
  426. Object.defineProperty(process, 'pkg', { configurable: true, value: {} })
  427. try {
  428. await healProfilesModuleFallback(anchor, home)
  429. const fallback = join(home, 'profiles', 'node_modules')
  430. await expect(import(join(fallback, 'bundle-a', 'entry-0.js'))).resolves.toMatchObject({ packageName: 'bundle-a' })
  431. await expect(import(join(fallback, 'nested-esm', 'entry-0.js'))).resolves.toMatchObject({ nested: 'proxied' })
  432. } finally {
  433. delete (process as NodeJS.Process & { pkg?: unknown }).pkg
  434. }
  435. })
  436. it('resolves explicit condition targets without filesystem package lookup', async () => {
  437. const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } })
  438. const bundleDir = join(anchor, '..', 'node_modules', 'bundle-a')
  439. const manifest = JSON.parse(readFileSync(join(bundleDir, 'package.json'), 'utf8')) as Record<string, unknown>
  440. manifest.exports = {
  441. '.': { import: './index.js', require: './index.cjs' },
  442. './mini': { types: './mini/index.d.ts', import: './mini/index.js', require: './mini/index.cjs' },
  443. './web': { types: './dist/web/web.d.ts', import: './dist/web/index.mjs', default: './dist/web/index.mjs' },
  444. }
  445. writeFileSync(join(bundleDir, 'package.json'), JSON.stringify(manifest))
  446. mkdirSync(join(bundleDir, 'mini'))
  447. writeFileSync(join(bundleDir, 'mini', 'index.js'), 'export const mini = true\n')
  448. mkdirSync(join(bundleDir, 'dist', 'web'), { recursive: true })
  449. writeFileSync(join(bundleDir, 'dist', 'web', 'index.mjs'), 'export const web = true\n')
  450. Object.defineProperty(process, 'pkg', { configurable: true, value: {} })
  451. try {
  452. const home = tmp()
  453. await healProfilesModuleFallback(anchor, home)
  454. const proxy = join(home, 'profiles', 'node_modules', 'bundle-a')
  455. await expect(import(join(proxy, 'entry-1.js'))).resolves.toMatchObject({ mini: true })
  456. await expect(import(join(proxy, 'entry-2.js'))).resolves.toMatchObject({ web: true })
  457. } finally {
  458. delete (process as NodeJS.Process & { pkg?: unknown }).pkg
  459. }
  460. })
  461. it('preserves the installation path while resolving packaged exports', async () => {
  462. const anchor = stageInstallation({})
  463. const appDir = join(anchor, '..')
  464. const physical = tmp()
  465. writeFileSync(join(physical, 'package.json'), JSON.stringify({
  466. name: 'linked-esm',
  467. version: '0.0.0',
  468. type: 'module',
  469. exports: { import: './index.js' },
  470. }))
  471. writeFileSync(join(physical, 'index.js'), 'export const linked = true\n')
  472. symlinkSync(physical, join(appDir, 'node_modules', 'linked-esm'), 'junction')
  473. const appManifest = JSON.parse(readFileSync(anchor, 'utf8')) as { dependencies: Record<string, string> }
  474. appManifest.dependencies['linked-esm'] = '0.0.0'
  475. writeFileSync(anchor, JSON.stringify(appManifest))
  476. Object.defineProperty(process, 'pkg', { configurable: true, value: {} })
  477. try {
  478. const home = tmp()
  479. await healProfilesModuleFallback(anchor, home)
  480. const proxyManifest = JSON.parse(readFileSync(
  481. join(home, 'profiles', 'node_modules', 'linked-esm', 'package.json'),
  482. 'utf8',
  483. )) as { dsh: { moduleFallback: { targets: Record<string, string> } } }
  484. expect(proxyManifest.dsh.moduleFallback.targets['.']).toContain('/app/node_modules/linked-esm/index.js')
  485. } finally {
  486. delete (process as NodeJS.Process & { pkg?: unknown }).pkg
  487. }
  488. })
  489. it('uses the legacy index fallback when a package has no exports or main', async () => {
  490. const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } })
  491. const bundleDir = join(anchor, '..', 'node_modules', 'bundle-a')
  492. const manifest = JSON.parse(readFileSync(join(bundleDir, 'package.json'), 'utf8')) as Record<string, unknown>
  493. delete manifest.main
  494. writeFileSync(join(bundleDir, 'package.json'), JSON.stringify(manifest))
  495. Object.defineProperty(process, 'pkg', { configurable: true, value: {} })
  496. try {
  497. const home = tmp()
  498. await healProfilesModuleFallback(anchor, home)
  499. await expect(import(join(home, 'profiles', 'node_modules', 'bundle-a', 'entry-0.js')))
  500. .resolves.toMatchObject({ packageName: 'bundle-a' })
  501. } finally {
  502. delete (process as NodeJS.Process & { pkg?: unknown }).pkg
  503. }
  504. })
  505. it('uses Node legacy resolution for an extensionless main entry', async () => {
  506. const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } })
  507. const bundleDir = join(anchor, '..', 'node_modules', 'bundle-a')
  508. const manifest = JSON.parse(readFileSync(join(bundleDir, 'package.json'), 'utf8')) as Record<string, unknown>
  509. manifest.main = './index'
  510. writeFileSync(join(bundleDir, 'package.json'), JSON.stringify(manifest))
  511. Object.defineProperty(process, 'pkg', { configurable: true, value: {} })
  512. try {
  513. const home = tmp()
  514. await healProfilesModuleFallback(anchor, home)
  515. await expect(import(join(home, 'profiles', 'node_modules', 'bundle-a', 'entry-0.js')))
  516. .resolves.toMatchObject({ packageName: 'bundle-a' })
  517. } finally {
  518. delete (process as NodeJS.Process & { pkg?: unknown }).pkg
  519. }
  520. })
  521. it('skips executable-only and declaration-only packages without import entries', async () => {
  522. for (const marker of ['bin', 'types', 'typings']) {
  523. const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } })
  524. const manifest = JSON.parse(readFileSync(anchor, 'utf8')) as Record<string, unknown>
  525. delete manifest.main
  526. manifest[marker] = marker === 'bin' ? { dsh: './lib/bin.js' } : './index.d.ts'
  527. if (marker === 'types') manifest.main = ''
  528. writeFileSync(anchor, JSON.stringify(manifest))
  529. rmSync(join(anchor, '..', 'index.js'))
  530. Object.defineProperty(process, 'pkg', { configurable: true, value: {} })
  531. try {
  532. const home = tmp()
  533. await healProfilesModuleFallback(anchor, home)
  534. const fallback = join(home, 'profiles', 'node_modules')
  535. expect(existsSync(join(fallback, 'dsh-app'))).toBe(false)
  536. expect(existsSync(join(fallback, 'bundle-a', 'entry-0.js'))).toBe(true)
  537. } finally {
  538. delete (process as NodeJS.Process & { pkg?: unknown }).pkg
  539. }
  540. }
  541. })
  542. it('fails loud on a missing legacy main entry', async () => {
  543. const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } })
  544. const bundleDir = join(anchor, '..', 'node_modules', 'bundle-a')
  545. const manifest = JSON.parse(readFileSync(join(bundleDir, 'package.json'), 'utf8')) as Record<string, unknown>
  546. delete manifest.main
  547. writeFileSync(join(bundleDir, 'package.json'), JSON.stringify(manifest))
  548. rmSync(join(bundleDir, 'index.js'))
  549. Object.defineProperty(process, 'pkg', { configurable: true, value: {} })
  550. try {
  551. await expect(healProfilesModuleFallback(anchor, tmp())).rejects.toThrow('main entry is missing')
  552. } finally {
  553. delete (process as NodeJS.Process & { pkg?: unknown }).pkg
  554. }
  555. })
  556. it('omits unavailable ESM exports and rejects malformed export targets', async () => {
  557. for (const mode of ['missing', 'directory', 'absent-map', 'invalid', 'escape', 'null', 'null-subpath']) {
  558. const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } })
  559. const bundleDir = join(anchor, '..', 'node_modules', 'bundle-a')
  560. const manifest = JSON.parse(readFileSync(join(bundleDir, 'package.json'), 'utf8')) as Record<string, unknown>
  561. const target = mode === 'missing' ? './missing.js'
  562. : mode === 'directory' ? './mini'
  563. : mode === 'escape' ? './../outside.js'
  564. : '../outside.js'
  565. manifest.exports = mode === 'absent-map' ? null
  566. : mode === 'null-subpath' ? { './bad': null }
  567. : { '.': mode === 'null' ? null : { import: target } }
  568. writeFileSync(join(bundleDir, 'package.json'), JSON.stringify(manifest))
  569. if (mode === 'directory') mkdirSync(join(bundleDir, 'mini'))
  570. Object.defineProperty(process, 'pkg', { configurable: true, value: {} })
  571. try {
  572. const home = tmp()
  573. if (mode === 'missing' || mode === 'directory' || mode === 'absent-map') {
  574. await healProfilesModuleFallback(anchor, home)
  575. expect(existsSync(join(home, 'profiles', 'node_modules', 'bundle-a'))).toBe(false)
  576. } else {
  577. await expect(healProfilesModuleFallback(anchor, home)).rejects.toThrow(
  578. mode === 'null' || mode === 'null-subpath'
  579. ? 'cannot resolve ESM export bundle-a'
  580. : 'resolves outside its package',
  581. )
  582. }
  583. } finally {
  584. delete (process as NodeJS.Process & { pkg?: unknown }).pkg
  585. }
  586. }
  587. })
  588. it('requires a package version before writing a packaged proxy', async () => {
  589. const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } })
  590. const bundleDir = join(anchor, '..', 'node_modules', 'bundle-a')
  591. const manifest = JSON.parse(readFileSync(join(bundleDir, 'package.json'), 'utf8')) as Record<string, unknown>
  592. manifest.version = ''
  593. writeFileSync(join(bundleDir, 'package.json'), JSON.stringify(manifest))
  594. Object.defineProperty(process, 'pkg', { configurable: true, value: {} })
  595. try {
  596. await expect(healProfilesModuleFallback(anchor, tmp())).rejects.toThrow(
  597. 'installed package bundle-a must declare a non-empty version',
  598. )
  599. } finally {
  600. delete (process as NodeJS.Process & { pkg?: unknown }).pkg
  601. }
  602. })
  603. it('replaces plain-node links and stale managed proxies in packaged mode', async () => {
  604. const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } })
  605. const home = tmp()
  606. await healProfilesModuleFallback(anchor, home)
  607. const proxy = join(home, 'profiles', 'node_modules', 'bundle-a')
  608. expect(lstatSync(proxy).isSymbolicLink()).toBe(true)
  609. Object.defineProperty(process, 'pkg', { configurable: true, value: {} })
  610. try {
  611. await healProfilesModuleFallback(anchor, home)
  612. expect(lstatSync(proxy).isDirectory()).toBe(true)
  613. const stale = JSON.parse(readFileSync(join(proxy, 'package.json'), 'utf8')) as {
  614. version: string
  615. }
  616. stale.version = 'stale'
  617. writeFileSync(join(proxy, 'package.json'), JSON.stringify(stale))
  618. await healProfilesModuleFallback(anchor, home)
  619. expect(JSON.parse(readFileSync(join(proxy, 'package.json'), 'utf8'))).toMatchObject({
  620. version: '0.0.0',
  621. })
  622. } finally {
  623. delete (process as NodeJS.Process & { pkg?: unknown }).pkg
  624. }
  625. })
  626. it('replaces a managed packaged proxy with a plain-node symlink', async () => {
  627. const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } })
  628. const home = tmp()
  629. const fallback = join(home, 'profiles', 'node_modules', 'bundle-a')
  630. Object.defineProperty(process, 'pkg', { configurable: true, value: {} })
  631. try {
  632. await healProfilesModuleFallback(anchor, home)
  633. expect(lstatSync(fallback).isDirectory()).toBe(true)
  634. } finally {
  635. delete (process as NodeJS.Process & { pkg?: unknown }).pkg
  636. }
  637. await healProfilesModuleFallback(anchor, home)
  638. expect(lstatSync(fallback).isSymbolicLink()).toBe(true)
  639. })
  640. it('rejects foreign packaged fallback directories with valid or invalid metadata', async () => {
  641. const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } })
  642. Object.defineProperty(process, 'pkg', { configurable: true, value: {} })
  643. try {
  644. for (const metadata of ['{}', '{']) {
  645. const home = tmp()
  646. const proxy = join(home, 'profiles', 'node_modules', 'bundle-a')
  647. mkdirSync(proxy, { recursive: true })
  648. writeFileSync(join(proxy, 'package.json'), metadata)
  649. await expect(healProfilesModuleFallback(anchor, home)).rejects.toThrow(
  650. 'exists and is not a dsh-managed module proxy',
  651. )
  652. }
  653. } finally {
  654. delete (process as NodeJS.Process & { pkg?: unknown }).pkg
  655. }
  656. })
  657. })