profile.spec.ts 45 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978
  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 {
  7. existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, realpathSync, rmSync, symlinkSync,
  8. unlinkSync, writeFileSync,
  9. } from 'node:fs'
  10. import { tmpdir } from 'node:os'
  11. import { join } from 'node:path'
  12. import { withFileLock } from '@deepseek-ai/dsh-atomic-write'
  13. import { afterAll, describe, expect, it } from 'vitest'
  14. import {
  15. composeEntries,
  16. healProfilesModuleFallback,
  17. initProfile,
  18. loadProfile,
  19. loadProfileDirectory,
  20. PROFILE_PATCH_FILENAME,
  21. PROFILE_TEMPLATES,
  22. readProfileManifest,
  23. resolveBundleDir,
  24. resolveProfileDir,
  25. writeProfileManifest,
  26. type Profile,
  27. } from '../src/index.ts'
  28. const tempRoots: string[] = []
  29. afterAll(() => {
  30. for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true })
  31. })
  32. const tmp = (): string => {
  33. const dir = mkdtempSync(join(tmpdir(), 'dsh-profile-'))
  34. tempRoots.push(dir)
  35. return dir
  36. }
  37. /** Stage a fake installed app: package.json with deps and a node_modules holding bundles. */
  38. function stageInstallation(
  39. bundles: Record<string, { patch?: string; deps?: Record<string, string> }>,
  40. appName = 'dsh-app',
  41. ): string {
  42. const root = tmp()
  43. const appDir = join(root, 'app')
  44. mkdirSync(join(appDir, 'node_modules'), { recursive: true })
  45. const appDeps: Record<string, string> = {}
  46. for (const [name, spec] of Object.entries(bundles)) {
  47. appDeps[name] = '0.0.0'
  48. const dir = join(appDir, 'node_modules', name)
  49. mkdirSync(dir, { recursive: true })
  50. writeFileSync(join(dir, 'package.json'), JSON.stringify({
  51. name,
  52. version: '0.0.0',
  53. type: 'module',
  54. main: './index.js',
  55. dependencies: spec.deps ?? {},
  56. ...spec.patch === undefined ? {} : { dsh: { bundle: { patch: './cordis.patch.yml' } } },
  57. }))
  58. writeFileSync(join(dir, 'index.js'), `export const packageName = ${JSON.stringify(name)}\n`)
  59. if (spec.patch !== undefined) writeFileSync(join(dir, 'cordis.patch.yml'), spec.patch)
  60. }
  61. writeFileSync(join(appDir, 'package.json'), JSON.stringify({
  62. name: appName, version: '0.0.0', type: 'module', main: './index.js', dependencies: appDeps,
  63. }))
  64. writeFileSync(join(appDir, 'index.js'), `export const packageName = ${JSON.stringify(appName)}\n`)
  65. return join(appDir, 'package.json')
  66. }
  67. /** Represent one resolved external bundle as a loaded profile layer. */
  68. function stageProfile(home: string, name: string, bundleAnchor: string): Profile {
  69. const dir = resolveProfileDir(name, home)
  70. mkdirSync(dir, { recursive: true })
  71. const packageName = (JSON.parse(readFileSync(bundleAnchor, 'utf8')) as { name: string }).name
  72. return {
  73. name,
  74. dir,
  75. layers: [{
  76. packageName,
  77. packageDir: join(bundleAnchor, '..'),
  78. patchPath: join(bundleAnchor, '..', 'cordis.patch.yml'),
  79. patches: [],
  80. }],
  81. patchPath: join(dir, PROFILE_PATCH_FILENAME),
  82. patches: [],
  83. patchReload: 'live',
  84. }
  85. }
  86. describe('resolveProfileDir', () => {
  87. it('joins the home and rejects traversal-shaped names', () => {
  88. const home = tmp()
  89. expect(resolveProfileDir('tui', home)).toBe(join(home, 'profiles', 'tui'))
  90. for (const bad of ['', '.', '..', 'a/b', 'a\\b']) {
  91. expect(() => resolveProfileDir(bad, home)).toThrow('invalid profile name')
  92. }
  93. })
  94. })
  95. describe('initProfile', () => {
  96. it('creates manifest, user patch layer, and pnpm workspace once, never overwriting', () => {
  97. const home = tmp()
  98. const dir = resolveProfileDir('tui', home)
  99. initProfile(dir, ['@deepseek-ai/dsh-base'])
  100. const manifest = readProfileManifest('t', dir)
  101. expect(manifest.dsh?.profile?.bundles).toEqual(['@deepseek-ai/dsh-base'])
  102. expect(manifest.dsh?.profile?.patchReload).toBe('live')
  103. expect(readFileSync(join(dir, PROFILE_PATCH_FILENAME), 'utf8')).toContain('[]')
  104. expect(readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8')).toContain('nodeLinker: hoisted')
  105. // Re-init keeps user edits.
  106. writeFileSync(join(dir, PROFILE_PATCH_FILENAME), '- id: x\n config: {}\n')
  107. initProfile(dir, ['other'], 'startup')
  108. expect(readProfileManifest('t', dir).dsh?.profile?.bundles).toEqual(['@deepseek-ai/dsh-base'])
  109. expect(readProfileManifest('t', dir).dsh?.profile?.patchReload).toBe('live')
  110. expect(readFileSync(join(dir, PROFILE_PATCH_FILENAME), 'utf8')).toContain('- id: x')
  111. })
  112. })
  113. describe('manifest round-trip', () => {
  114. it('writes and reads back, and fails loud on a broken manifest', () => {
  115. const dir = tmp()
  116. writeProfileManifest(dir, { name: 'p', dsh: { profile: { bundles: ['a'] } } })
  117. expect(readProfileManifest('t', dir).dsh?.profile?.bundles).toEqual(['a'])
  118. writeFileSync(join(dir, 'package.json'), '[]')
  119. expect(() => readProfileManifest('t', dir)).toThrow('must hold a JSON object')
  120. expect(() => readProfileManifest('t', join(dir, 'nope'))).toThrow('failed to read profile manifest')
  121. })
  122. })
  123. describe('resolveBundleDir', () => {
  124. it('prefers the installation anchor, falls back to the profile, and fails loud', () => {
  125. const anchor = stageInstallation({ 'in-box': { patch: '[]\n' } })
  126. const profileDir = tmp()
  127. mkdirSync(join(profileDir, 'node_modules', 'local-only'), { recursive: true })
  128. writeFileSync(join(profileDir, 'package.json'), '{}')
  129. writeFileSync(join(profileDir, 'node_modules', 'local-only', 'package.json'), JSON.stringify({ name: 'local-only', version: '0.0.0' }))
  130. expect(resolveBundleDir('t', 'in-box', anchor, profileDir)).toContain('in-box')
  131. expect(resolveBundleDir('t', 'local-only', anchor, profileDir)).toContain('local-only')
  132. expect(() => resolveBundleDir('t', 'absent', anchor, profileDir)).toThrow('cannot resolve profile bundle')
  133. })
  134. it('resolves a package whose exports map omits ./package.json', () => {
  135. // Common on npm: an exports map without "./package.json" makes
  136. // require.resolve('<pkg>/package.json') throw ERR_PACKAGE_PATH_NOT_EXPORTED;
  137. // resolution must fall through to the paths probe instead of misreporting
  138. // the installed package as missing.
  139. const anchor = stageInstallation({})
  140. const profileDir = tmp()
  141. writeFileSync(join(profileDir, 'package.json'), '{}')
  142. const dir = join(profileDir, 'node_modules', 'sealed-bundle')
  143. mkdirSync(dir, { recursive: true })
  144. writeFileSync(join(dir, 'package.json'), JSON.stringify({
  145. name: 'sealed-bundle',
  146. version: '0.0.0',
  147. exports: { '.': './index.js' },
  148. dsh: { bundle: { patch: './cordis.patch.yml' } },
  149. }))
  150. writeFileSync(join(dir, 'index.js'), '')
  151. writeFileSync(join(dir, 'cordis.patch.yml'), '[]\n')
  152. expect(resolveBundleDir('t', 'sealed-bundle', anchor, profileDir)).toBe(dir)
  153. })
  154. })
  155. describe('loadProfile', () => {
  156. it('loads an explicitly owned profile directory outside CLI discovery', () => {
  157. const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } })
  158. const dir = join(tmp(), 'managed', 'desktop')
  159. initProfile(dir, ['bundle-a'])
  160. const profile = loadProfileDirectory('managed app', dir, anchor)
  161. expect(profile.dir).toBe(dir)
  162. expect(profile.name).toBe('desktop')
  163. expect(profile.layers.map(layer => layer.packageName)).toEqual(['bundle-a'])
  164. })
  165. it('resolves each dsh.profile.bundles entry to its patch layer in order, plus the user layer', () => {
  166. const anchor = stageInstallation({
  167. 'bundle-a': { patch: '- insert:\n - id: a\n name: pkg-a\n' },
  168. 'bundle-b': { patch: '- id: a\n config:\n v: 2\n' },
  169. })
  170. const home = tmp()
  171. const dir = resolveProfileDir('demo', home)
  172. initProfile(dir, ['bundle-a', 'bundle-b'])
  173. writeFileSync(join(dir, PROFILE_PATCH_FILENAME), '- id: a\n config:\n v: 3\n')
  174. const profile = loadProfile('t', 'demo', anchor, home)
  175. expect(profile.layers.map(layer => layer.packageName)).toEqual(['bundle-a', 'bundle-b'])
  176. expect(profile.patches).toHaveLength(1)
  177. expect(profile.patchReload).toBe('live')
  178. const entries = composeEntries([
  179. ...profile.layers.map(layer => layer.patches),
  180. profile.patches,
  181. ])
  182. expect(entries).toEqual([{ id: 'a', name: 'pkg-a', config: { v: 3 } }])
  183. // A hand-made profile without the user layer file or dsh section: empty layers, no throw.
  184. rmSync(join(dir, PROFILE_PATCH_FILENAME))
  185. expect(loadProfile('t', 'demo', anchor, home).patches).toEqual([])
  186. writeProfileManifest(dir, { name: 'bare' })
  187. const bare = loadProfile('t', 'demo', anchor, home)
  188. expect(bare.layers).toEqual([])
  189. expect(bare.patchReload).toBe('live')
  190. })
  191. it('auto-initializes only shipped templates and fails loud otherwise', () => {
  192. const anchor = stageInstallation({})
  193. const home = tmp()
  194. expect(() => loadProfile('t', 'custom', anchor, home))
  195. .toThrow('profile "custom" does not exist')
  196. // The web template auto-initializes on first load. Bundle resolution
  197. // cannot be asserted to fail here: the source-plane test runner resolves
  198. // @deepseek-ai/* through tsconfig paths regardless of the staged anchor.
  199. expect(PROFILE_TEMPLATES.web?.bundles).toContain('@deepseek-ai/dsh-base')
  200. expect(PROFILE_TEMPLATES.web?.patchReload).toBe('live')
  201. expect(PROFILE_TEMPLATES.headless?.patchReload).toBe('startup')
  202. expect(PROFILE_TEMPLATES.acp).toEqual({
  203. bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-acp-app'],
  204. patchReload: 'startup',
  205. })
  206. expect(PROFILE_TEMPLATES.sdk).toEqual({
  207. bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-sdk-app'],
  208. patchReload: 'startup',
  209. })
  210. expect(PROFILE_TEMPLATES['sdk-minimal']).toEqual({
  211. bundles: ['@deepseek-ai/dsh-sdk-minimal'],
  212. patchReload: 'startup',
  213. })
  214. try {
  215. loadProfile('t', 'web', anchor, home)
  216. } catch {
  217. // Resolution failure is the plain-Node outcome for this empty anchor.
  218. }
  219. expect(readProfileManifest('t', resolveProfileDir('web', home)).dsh?.profile?.bundles)
  220. .toEqual([...PROFILE_TEMPLATES.web?.bundles ?? []])
  221. expect(readProfileManifest('t', resolveProfileDir('web', home)).dsh?.profile?.patchReload)
  222. .toBe('live')
  223. })
  224. it('normalizes only the exact installation-owned headless bundle tuple', () => {
  225. const anchor = stageInstallation({
  226. '@deepseek-ai/dsh-base': { patch: '[]\n' },
  227. '@deepseek-ai/dsh-web-app': { patch: '[]\n' },
  228. '@deepseek-ai/dsh-headless': { patch: '[]\n' },
  229. 'custom-bundle': { patch: '[]\n' },
  230. })
  231. const home = tmp()
  232. const stock = resolveProfileDir('headless', home)
  233. initProfile(stock, [
  234. '@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@deepseek-ai/dsh-headless',
  235. ])
  236. const retiredManifest = readProfileManifest('t', stock)
  237. delete retiredManifest.dsh!.profile!.patchReload
  238. writeProfileManifest(stock, retiredManifest)
  239. loadProfile('t', 'headless', anchor, home)
  240. expect(readProfileManifest('t', stock).dsh?.profile).toEqual({
  241. bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless'],
  242. patchReload: 'startup',
  243. })
  244. const customHome = tmp()
  245. const custom = resolveProfileDir('headless', customHome)
  246. initProfile(custom, [
  247. '@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@deepseek-ai/dsh-headless', 'custom-bundle',
  248. ])
  249. loadProfile('t', 'headless', anchor, customHome)
  250. expect(readProfileManifest('t', custom).dsh?.profile?.bundles).toEqual([
  251. '@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@deepseek-ai/dsh-headless', 'custom-bundle',
  252. ])
  253. })
  254. it('adds a shipped reload default only to an exact stock tuple and preserves explicit choices', () => {
  255. const anchor = stageInstallation({
  256. '@deepseek-ai/dsh-base': { patch: '[]\n' },
  257. '@deepseek-ai/dsh-web-app': { patch: '[]\n' },
  258. })
  259. const stockHome = tmp()
  260. const stock = resolveProfileDir('web', stockHome)
  261. initProfile(stock, PROFILE_TEMPLATES.web?.bundles ?? [])
  262. const stockManifest = readProfileManifest('t', stock)
  263. delete stockManifest.dsh!.profile!.patchReload
  264. writeProfileManifest(stock, stockManifest)
  265. expect(loadProfile('t', 'web', anchor, stockHome).patchReload).toBe('live')
  266. expect(readProfileManifest('t', stock).dsh?.profile?.patchReload).toBe('live')
  267. const explicitHome = tmp()
  268. const explicit = resolveProfileDir('web', explicitHome)
  269. initProfile(explicit, PROFILE_TEMPLATES.web?.bundles ?? [], 'startup')
  270. expect(loadProfile('t', 'web', anchor, explicitHome).patchReload).toBe('startup')
  271. })
  272. it('fails loud on an unknown patch reload value from disk', () => {
  273. const anchor = stageInstallation({})
  274. const home = tmp()
  275. const dir = resolveProfileDir('demo', home)
  276. initProfile(dir, [])
  277. const manifest = readProfileManifest('t', dir)
  278. const rawProfile = manifest.dsh!.profile as { patchReload?: string }
  279. rawProfile.patchReload = 'sometimes'
  280. writeProfileManifest(dir, manifest)
  281. expect(() => loadProfile('t', 'demo', anchor, home)).toThrow('patchReload must be "live" or "startup"')
  282. })
  283. it('fails loud when a listed bundle declares no dsh.bundle', () => {
  284. const anchor = stageInstallation({ 'not-a-bundle': {} })
  285. const home = tmp()
  286. const dir = resolveProfileDir('demo', home)
  287. initProfile(dir, ['not-a-bundle'])
  288. expect(() => loadProfile('t', 'demo', anchor, home)).toThrow('declares no dsh.bundle')
  289. })
  290. })
  291. describe('composeEntries', () => {
  292. it('applies layers over an empty root and reports skipped patches', () => {
  293. const warnings: string[] = []
  294. const entries = composeEntries([
  295. [{ insert: [{ id: 'x', name: 'pkg-x', config: { a: 1 } }] }],
  296. [{ id: 'x', config: { a: 2 } }, { id: 'missing', config: {} }],
  297. ], message => warnings.push(message))
  298. expect(entries).toEqual([{ id: 'x', name: 'pkg-x', config: { a: 2 } }])
  299. expect(warnings.join('\n')).toContain('"missing"')
  300. // Default warn sink: skipped patches are silently dropped (boot repeats them).
  301. expect(composeEntries([[{ id: 'missing', config: {} }]])).toEqual([])
  302. })
  303. })
  304. describe('healProfilesModuleFallback', () => {
  305. it('links the app and bundle dependency surface flat under profiles/node_modules', async () => {
  306. const anchor = stageInstallation({
  307. 'bundle-a': { patch: '[]\n', deps: { 'dep-of-a': '0.0.0', 'ghost-dep': '0.0.0' } },
  308. 'plain-lib': {},
  309. })
  310. // An app dependency that is declared but not installed: skipped, not fatal.
  311. const appManifest = JSON.parse(readFileSync(anchor, 'utf8')) as { dependencies: Record<string, string> }
  312. appManifest.dependencies['never-installed'] = '0.0.0'
  313. writeFileSync(anchor, JSON.stringify(appManifest))
  314. // dep-of-a lives in the installation's node_modules too.
  315. const modules = join(anchor, '..', 'node_modules')
  316. mkdirSync(join(modules, 'dep-of-a'), { recursive: true })
  317. writeFileSync(join(modules, 'dep-of-a', 'package.json'), JSON.stringify({ name: 'dep-of-a', version: '0.0.0' }))
  318. const home = tmp()
  319. await healProfilesModuleFallback({ installAnchor: anchor, home })
  320. const fallback = join(home, 'profiles', 'node_modules')
  321. // App deps, the bundle's own deps, and the bundle itself are linked; the
  322. // plain library is linked as an app dep (harmless), the app itself too.
  323. for (const name of ['bundle-a', 'plain-lib', 'dep-of-a', 'dsh-app']) {
  324. expect(lstatSync(join(fallback, name)).isSymbolicLink(), name).toBe(true)
  325. }
  326. // Idempotent, and a moved target is re-pointed.
  327. await healProfilesModuleFallback({ installAnchor: anchor, home })
  328. const before = readlinkSync(join(fallback, 'dep-of-a'))
  329. expect(before).toContain('dep-of-a')
  330. })
  331. it('throws when a fallback entry is a foreign file or directory', async () => {
  332. const anchor = stageInstallation({})
  333. for (const kind of ['file', 'directory']) {
  334. const home = tmp()
  335. const entry = join(home, 'profiles', 'node_modules', 'dsh-app')
  336. mkdirSync(join(entry, '..'), { recursive: true })
  337. if (kind === 'directory') mkdirSync(entry)
  338. else writeFileSync(entry, '')
  339. await expect(healProfilesModuleFallback({ installAnchor: anchor, home })).rejects.toThrow('is not a symlink')
  340. }
  341. })
  342. it('keeps selected bundle closures profile-local without overriding installation packages', async () => {
  343. const installationAnchor = stageInstallation({ shared: {} })
  344. const bundleA = stageInstallation({ shared: {}, '@scope/bundle-only': {} }, 'selected-bundle-a')
  345. const bundleB = stageInstallation({ shared: {}, '@scope/bundle-only': {} }, 'selected-bundle-b')
  346. const home = tmp()
  347. const profileA = stageProfile(home, 'a', bundleA)
  348. const profileB = stageProfile(home, 'b', bundleB)
  349. await healProfilesModuleFallback({ installAnchor: installationAnchor, profile: profileA, home })
  350. await healProfilesModuleFallback({ installAnchor: installationAnchor, profile: profileA, home })
  351. await healProfilesModuleFallback({ installAnchor: installationAnchor, profile: profileB, home })
  352. const sharedFallback = join(home, 'profiles', 'node_modules')
  353. const ownedA = join(profileA.dir, '.dsh-module-fallback', 'node_modules', '@scope', 'bundle-only')
  354. const ownedB = join(profileB.dir, '.dsh-module-fallback', 'node_modules', '@scope', 'bundle-only')
  355. expect(realpathSync.native(readlinkSync(join(sharedFallback, 'shared'))))
  356. .toBe(realpathSync.native(join(installationAnchor, '..', 'node_modules', 'shared')))
  357. expect(existsSync(join(sharedFallback, '@scope', 'bundle-only'))).toBe(false)
  358. expect(existsSync(join(profileA.dir, 'node_modules', 'shared'))).toBe(false)
  359. expect(existsSync(join(profileB.dir, 'node_modules', 'shared'))).toBe(false)
  360. expect(readlinkSync(join(profileA.dir, 'node_modules', '@scope', 'bundle-only'))).toBe(ownedA)
  361. expect(readlinkSync(ownedA))
  362. .toBe(realpathSync.native(join(bundleA, '..', 'node_modules', '@scope', 'bundle-only')))
  363. expect(readlinkSync(join(profileB.dir, 'node_modules', '@scope', 'bundle-only'))).toBe(ownedB)
  364. expect(readlinkSync(ownedB))
  365. .toBe(realpathSync.native(join(bundleB, '..', 'node_modules', '@scope', 'bundle-only')))
  366. await healProfilesModuleFallback({
  367. installAnchor: installationAnchor,
  368. profile: { ...profileA, layers: [] },
  369. home,
  370. })
  371. expect(existsSync(join(profileA.dir, 'node_modules', '@scope', 'bundle-only'))).toBe(false)
  372. expect(existsSync(ownedA)).toBe(false)
  373. expect(existsSync(join(profileB.dir, 'node_modules', '@scope', 'bundle-only'))).toBe(true)
  374. })
  375. it('combines packaged installation proxies with profile-local bundle links', async () => {
  376. const installationAnchor = stageInstallation({ shared: {} })
  377. const bundleAnchor = stageInstallation({ shared: {}, 'bundle-only': {} }, 'selected-bundle')
  378. const home = tmp()
  379. const profile = stageProfile(home, 'packaged', bundleAnchor)
  380. Object.defineProperty(process, 'pkg', { configurable: true, value: {} })
  381. try {
  382. await healProfilesModuleFallback({ installAnchor: installationAnchor, profile, home })
  383. expect(lstatSync(join(home, 'profiles', 'node_modules', 'shared')).isDirectory()).toBe(true)
  384. expect(lstatSync(join(profile.dir, 'node_modules', 'bundle-only')).isSymbolicLink()).toBe(true)
  385. } finally {
  386. delete (process as NodeJS.Process & { pkg?: unknown }).pkg
  387. }
  388. })
  389. it('discovers dependencies beside a symlinked bundle real path', async () => {
  390. const installationAnchor = stageInstallation({})
  391. const home = tmp()
  392. const dir = resolveProfileDir('symlinked', home)
  393. const profileModules = join(dir, 'node_modules')
  394. const storeModules = join(tmp(), 'node_modules', '.pnpm', 'selected-bundle@0.0.0', 'node_modules')
  395. const realBundle = join(storeModules, 'selected-bundle')
  396. const realDependency = join(storeModules, 'bundle-only')
  397. mkdirSync(realBundle, { recursive: true })
  398. mkdirSync(realDependency)
  399. writeFileSync(join(realBundle, 'package.json'), JSON.stringify({
  400. name: 'selected-bundle',
  401. dependencies: { 'bundle-only': '0.0.0' },
  402. }))
  403. writeFileSync(join(realDependency, 'package.json'), JSON.stringify({ name: 'bundle-only' }))
  404. mkdirSync(profileModules, { recursive: true })
  405. const bundleLink = join(profileModules, 'selected-bundle')
  406. symlinkSync(realBundle, bundleLink, 'junction')
  407. const profile: Profile = {
  408. name: 'symlinked',
  409. dir,
  410. layers: [{
  411. packageName: 'selected-bundle',
  412. packageDir: bundleLink,
  413. patchPath: join(bundleLink, 'cordis.patch.yml'),
  414. patches: [],
  415. }],
  416. patchPath: join(dir, PROFILE_PATCH_FILENAME),
  417. patches: [],
  418. patchReload: 'live',
  419. }
  420. await healProfilesModuleFallback({ installAnchor: installationAnchor, profile, home })
  421. expect(readlinkSync(join(dir, '.dsh-module-fallback', 'node_modules', 'bundle-only')))
  422. .toBe(realpathSync.native(realDependency))
  423. })
  424. it('traverses every explicit bundle root even when a nested package has the same name', async () => {
  425. const installationAnchor = stageInstallation({})
  426. const home = tmp()
  427. const root = tmp()
  428. const bundleA = join(root, 'bundle-a')
  429. const nestedBundleB = join(bundleA, 'node_modules', 'bundle-b')
  430. const nestedOnly = join(nestedBundleB, 'node_modules', 'nested-only')
  431. const bundleB = join(root, 'bundle-b')
  432. const explicitOnly = join(bundleB, 'node_modules', 'explicit-only')
  433. for (const dir of [bundleA, nestedBundleB, nestedOnly, bundleB, explicitOnly]) mkdirSync(dir, { recursive: true })
  434. writeFileSync(join(bundleA, 'package.json'), JSON.stringify({
  435. name: 'bundle-a',
  436. dependencies: { 'bundle-b': '0.0.0' },
  437. }))
  438. writeFileSync(join(nestedBundleB, 'package.json'), JSON.stringify({
  439. name: 'bundle-b',
  440. dependencies: { 'nested-only': '0.0.0' },
  441. }))
  442. writeFileSync(join(nestedOnly, 'package.json'), JSON.stringify({ name: 'nested-only' }))
  443. writeFileSync(join(bundleB, 'package.json'), JSON.stringify({
  444. name: 'bundle-b',
  445. dependencies: { 'explicit-only': '0.0.0' },
  446. }))
  447. writeFileSync(join(explicitOnly, 'package.json'), JSON.stringify({ name: 'explicit-only' }))
  448. const dir = resolveProfileDir('explicit-roots', home)
  449. const profile: Profile = {
  450. name: 'explicit-roots',
  451. dir,
  452. layers: ([['bundle-a', bundleA], ['bundle-b', bundleB]] as const).map(([packageName, packageDir]) => ({
  453. packageName,
  454. packageDir,
  455. patchPath: join(packageDir, 'cordis.patch.yml'),
  456. patches: [],
  457. })),
  458. patchPath: join(dir, PROFILE_PATCH_FILENAME),
  459. patches: [],
  460. patchReload: 'live',
  461. }
  462. await healProfilesModuleFallback({ installAnchor: installationAnchor, profile, home })
  463. const ownedModules = join(dir, '.dsh-module-fallback', 'node_modules')
  464. expect(readlinkSync(join(ownedModules, 'nested-only'))).toBe(realpathSync.native(nestedOnly))
  465. expect(readlinkSync(join(ownedModules, 'explicit-only'))).toBe(realpathSync.native(explicitOnly))
  466. })
  467. it('ignores owned projections while recomputing an ordered bundle closure', async () => {
  468. const installationAnchor = stageInstallation({})
  469. const home = tmp()
  470. const dir = resolveProfileDir('ordered', home)
  471. const profileModules = join(dir, 'node_modules')
  472. const bundleA = join(profileModules, 'bundle-a')
  473. const bundleB = join(profileModules, 'bundle-b')
  474. const nested = join(bundleB, 'node_modules', 'bundle-only')
  475. mkdirSync(bundleA, { recursive: true })
  476. mkdirSync(nested, { recursive: true })
  477. writeFileSync(join(bundleA, 'package.json'), JSON.stringify({
  478. name: 'bundle-a',
  479. peerDependencies: { 'bundle-only': '0.0.0' },
  480. }))
  481. writeFileSync(join(bundleB, 'package.json'), JSON.stringify({
  482. name: 'bundle-b',
  483. dependencies: { 'bundle-only': '0.0.0' },
  484. }))
  485. writeFileSync(join(nested, 'package.json'), JSON.stringify({ name: 'bundle-only' }))
  486. const profile: Profile = {
  487. name: 'ordered',
  488. dir,
  489. layers: ([['bundle-a', bundleA], ['bundle-b', bundleB]] as const).map(([packageName, packageDir]) => ({
  490. packageDir,
  491. packageName,
  492. patchPath: join(packageDir, 'cordis.patch.yml'),
  493. patches: [],
  494. })),
  495. patchPath: join(dir, PROFILE_PATCH_FILENAME),
  496. patches: [],
  497. patchReload: 'live',
  498. }
  499. await healProfilesModuleFallback({ installAnchor: installationAnchor, profile, home })
  500. await healProfilesModuleFallback({ installAnchor: installationAnchor, profile, home })
  501. const owned = join(dir, '.dsh-module-fallback', 'node_modules', 'bundle-only')
  502. expect(readlinkSync(owned)).toBe(realpathSync.native(nested))
  503. expect(JSON.parse(readFileSync(join(profileModules, 'bundle-only', 'package.json'), 'utf8')))
  504. .toMatchObject({ name: 'bundle-only' })
  505. })
  506. it('cleans owned projections without removing profile-managed entries', async () => {
  507. const installationAnchor = stageInstallation({})
  508. const bundleAnchor = stageInstallation({ fallback: {}, 'managed-dir': {}, 'managed-link': {} }, 'selected-bundle')
  509. const home = tmp()
  510. const profile = stageProfile(home, 'managed', bundleAnchor)
  511. await healProfilesModuleFallback({ installAnchor: installationAnchor, profile, home })
  512. const ownedModules = join(profile.dir, '.dsh-module-fallback', 'node_modules')
  513. const profileModules = join(profile.dir, 'node_modules')
  514. const foreignTarget = tmp()
  515. unlinkSync(join(profileModules, 'managed-dir'))
  516. mkdirSync(join(profileModules, 'managed-dir'))
  517. unlinkSync(join(profileModules, 'managed-link'))
  518. symlinkSync(foreignTarget, join(profileModules, 'managed-link'), 'junction')
  519. mkdirSync(join(ownedModules, 'foreign-directory'))
  520. mkdirSync(join(ownedModules, '@foreign', 'directory'), { recursive: true })
  521. await healProfilesModuleFallback({
  522. installAnchor: installationAnchor,
  523. profile: { ...profile, layers: [] },
  524. home,
  525. })
  526. expect(existsSync(join(profileModules, 'fallback'))).toBe(false)
  527. expect(lstatSync(join(profileModules, 'managed-dir')).isDirectory()).toBe(true)
  528. expect(readlinkSync(join(profileModules, 'managed-link'))).toBe(foreignTarget)
  529. expect(existsSync(join(ownedModules, 'fallback'))).toBe(false)
  530. expect(existsSync(join(ownedModules, 'managed-dir'))).toBe(false)
  531. expect(existsSync(join(ownedModules, 'managed-link'))).toBe(false)
  532. })
  533. it('cleans owned projections whose junction target uses a canonical parent path', async () => {
  534. const installationAnchor = stageInstallation({})
  535. const realHome = tmp()
  536. const aliasRoot = tmp()
  537. const home = join(aliasRoot, 'home')
  538. symlinkSync(realHome, home, 'junction')
  539. const bundleAnchor = stageInstallation({ fallback: {} }, 'selected-bundle')
  540. const profile = stageProfile(home, 'canonical', bundleAnchor)
  541. await healProfilesModuleFallback({ installAnchor: installationAnchor, profile, home })
  542. const profileLink = join(profile.dir, 'node_modules', 'fallback')
  543. const ownedModules = join(profile.dir, '.dsh-module-fallback', 'node_modules')
  544. unlinkSync(profileLink)
  545. symlinkSync(join(realpathSync(ownedModules), 'fallback'), profileLink, 'junction')
  546. await healProfilesModuleFallback({
  547. installAnchor: installationAnchor,
  548. profile: { ...profile, layers: [] },
  549. home,
  550. })
  551. expect(existsSync(profileLink)).toBe(false)
  552. expect(existsSync(join(ownedModules, 'fallback'))).toBe(false)
  553. })
  554. it('replaces a wrong symlink', async () => {
  555. const anchor = stageInstallation({})
  556. const home = tmp()
  557. const fallback = join(home, 'profiles', 'node_modules')
  558. mkdirSync(fallback, { recursive: true })
  559. symlinkSync(tmp(), join(fallback, 'dsh-app'), 'junction')
  560. await healProfilesModuleFallback({ installAnchor: anchor, home })
  561. expect(readlinkSync(join(fallback, 'dsh-app'))).toContain('app')
  562. })
  563. it('retains current links while repairing a missing sibling', async () => {
  564. const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } })
  565. const home = tmp()
  566. const fallback = join(home, 'profiles', 'node_modules')
  567. await healProfilesModuleFallback({ installAnchor: anchor, home })
  568. const appTarget = readlinkSync(join(fallback, 'dsh-app'))
  569. unlinkSync(join(fallback, 'bundle-a'))
  570. await healProfilesModuleFallback({ installAnchor: anchor, home })
  571. expect(readlinkSync(join(fallback, 'dsh-app'))).toBe(appTarget)
  572. expect(lstatSync(join(fallback, 'bundle-a')).isSymbolicLink()).toBe(true)
  573. })
  574. it('serializes concurrent healers and retains the identical link', async () => {
  575. const anchor = stageInstallation({})
  576. const home = tmp()
  577. await Promise.all([
  578. healProfilesModuleFallback({ installAnchor: anchor, home }),
  579. healProfilesModuleFallback({ installAnchor: anchor, home }),
  580. ])
  581. const fallback = join(home, 'profiles', 'node_modules')
  582. expect(lstatSync(join(fallback, 'dsh-app')).isSymbolicLink()).toBe(true)
  583. })
  584. it('does not acquire the writer lock for a complete generation', async () => {
  585. const anchor = stageInstallation({})
  586. const home = tmp()
  587. const modules = join(home, 'profiles', 'node_modules')
  588. await healProfilesModuleFallback({ installAnchor: anchor, home })
  589. let releaseLock: (() => void) | undefined
  590. let reportLock: (() => void) | undefined
  591. const lockHeld = new Promise<void>((resolve) => { reportLock = resolve })
  592. const release = new Promise<void>((resolve) => { releaseLock = resolve })
  593. const holder = withFileLock(modules, async () => {
  594. reportLock?.()
  595. await release
  596. })
  597. await lockHeld
  598. const healer = healProfilesModuleFallback({ installAnchor: anchor, home })
  599. const outcome = await Promise.race([
  600. healer.then(() => 'complete' as const),
  601. new Promise<'blocked'>(resolve => setTimeout(() => { resolve('blocked') }, 100)),
  602. ])
  603. releaseLock?.()
  604. await Promise.all([holder, healer])
  605. expect(outcome).toBe('complete')
  606. })
  607. it('waits for the module-fallback writer lock before publishing entries', async () => {
  608. const anchor = stageInstallation({})
  609. const home = tmp()
  610. const modules = join(home, 'profiles', 'node_modules')
  611. mkdirSync(modules, { recursive: true })
  612. let releaseLock: (() => void) | undefined
  613. let reportLock: (() => void) | undefined
  614. const lockHeld = new Promise<void>((resolve) => { reportLock = resolve })
  615. const release = new Promise<void>((resolve) => { releaseLock = resolve })
  616. const holder = withFileLock(modules, async () => {
  617. reportLock?.()
  618. await release
  619. })
  620. await lockHeld
  621. const healer = healProfilesModuleFallback({ installAnchor: anchor, home })
  622. await new Promise(resolve => setTimeout(resolve, 20))
  623. expect(existsSync(join(modules, 'dsh-app'))).toBe(false)
  624. releaseLock?.()
  625. await Promise.all([holder, healer])
  626. expect(lstatSync(join(modules, 'dsh-app')).isSymbolicLink()).toBe(true)
  627. })
  628. it('writes real ESM proxies for a packaged executable', async () => {
  629. const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } })
  630. const bundleDir = join(anchor, '..', 'node_modules', 'bundle-a')
  631. const bundleManifest = JSON.parse(readFileSync(join(bundleDir, 'package.json'), 'utf8')) as Record<string, unknown>
  632. bundleManifest.exports = {
  633. '.': './index.js',
  634. './feature': './feature.js',
  635. './legacy/': './legacy/',
  636. './types': { types: './feature.d.ts' },
  637. }
  638. writeFileSync(join(bundleDir, 'package.json'), JSON.stringify(bundleManifest))
  639. writeFileSync(join(bundleDir, 'feature.js'), 'export const feature = "proxied"\n')
  640. const home = tmp()
  641. Object.defineProperty(process, 'pkg', { configurable: true, value: {} })
  642. try {
  643. await healProfilesModuleFallback({ installAnchor: anchor, home })
  644. const fallback = join(home, 'profiles', 'node_modules')
  645. const proxy = join(fallback, 'bundle-a')
  646. expect(lstatSync(proxy).isDirectory()).toBe(true)
  647. const proxyManifest = JSON.parse(readFileSync(join(proxy, 'package.json'), 'utf8')) as {
  648. version: unknown
  649. exports: unknown
  650. dsh: { moduleFallback: { targets: Record<string, unknown> } }
  651. }
  652. expect(proxyManifest).toMatchObject({
  653. version: '0.0.0',
  654. exports: { '.': './entry-0.js', './feature': './entry-1.js' },
  655. })
  656. expect(proxyManifest.dsh.moduleFallback.targets['.']).toEqual(expect.stringContaining('/bundle-a/index.js'))
  657. await expect(import(join(proxy, 'entry-0.js'))).resolves.toMatchObject({ packageName: 'bundle-a' })
  658. await expect(import(join(proxy, 'entry-1.js'))).resolves.toMatchObject({ feature: 'proxied' })
  659. await healProfilesModuleFallback({ installAnchor: anchor, home })
  660. } finally {
  661. delete (process as NodeJS.Process & { pkg?: unknown }).pkg
  662. }
  663. })
  664. it('resolves import-only exports from each package installation', async () => {
  665. const anchor = stageInstallation({
  666. 'bundle-a': { patch: '[]\n', deps: { 'nested-esm': '0.0.0' } },
  667. })
  668. const bundleDir = join(anchor, '..', 'node_modules', 'bundle-a')
  669. const bundleManifest = JSON.parse(readFileSync(join(bundleDir, 'package.json'), 'utf8')) as Record<string, unknown>
  670. bundleManifest.exports = { '.': { import: './index.js' } }
  671. writeFileSync(join(bundleDir, 'package.json'), JSON.stringify(bundleManifest))
  672. const nestedDir = join(bundleDir, 'node_modules', 'nested-esm')
  673. mkdirSync(nestedDir, { recursive: true })
  674. writeFileSync(join(nestedDir, 'package.json'), JSON.stringify({
  675. name: 'nested-esm',
  676. version: '0.0.0',
  677. type: 'module',
  678. exports: { import: './index.js' },
  679. }))
  680. writeFileSync(join(nestedDir, 'index.js'), 'export const nested = "proxied"\n')
  681. const home = tmp()
  682. Object.defineProperty(process, 'pkg', { configurable: true, value: {} })
  683. try {
  684. await healProfilesModuleFallback({ installAnchor: anchor, home })
  685. const fallback = join(home, 'profiles', 'node_modules')
  686. await expect(import(join(fallback, 'bundle-a', 'entry-0.js'))).resolves.toMatchObject({ packageName: 'bundle-a' })
  687. await expect(import(join(fallback, 'nested-esm', 'entry-0.js'))).resolves.toMatchObject({ nested: 'proxied' })
  688. } finally {
  689. delete (process as NodeJS.Process & { pkg?: unknown }).pkg
  690. }
  691. })
  692. it('resolves explicit condition targets without filesystem package lookup', async () => {
  693. const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } })
  694. const bundleDir = join(anchor, '..', 'node_modules', 'bundle-a')
  695. const manifest = JSON.parse(readFileSync(join(bundleDir, 'package.json'), 'utf8')) as Record<string, unknown>
  696. manifest.exports = {
  697. '.': { import: './index.js', require: './index.cjs' },
  698. './mini': { types: './mini/index.d.ts', import: './mini/index.js', require: './mini/index.cjs' },
  699. './web': { types: './dist/web/web.d.ts', import: './dist/web/index.mjs', default: './dist/web/index.mjs' },
  700. }
  701. writeFileSync(join(bundleDir, 'package.json'), JSON.stringify(manifest))
  702. mkdirSync(join(bundleDir, 'mini'))
  703. writeFileSync(join(bundleDir, 'mini', 'index.js'), 'export const mini = true\n')
  704. mkdirSync(join(bundleDir, 'dist', 'web'), { recursive: true })
  705. writeFileSync(join(bundleDir, 'dist', 'web', 'index.mjs'), 'export const web = true\n')
  706. Object.defineProperty(process, 'pkg', { configurable: true, value: {} })
  707. try {
  708. const home = tmp()
  709. await healProfilesModuleFallback({ installAnchor: anchor, home })
  710. const proxy = join(home, 'profiles', 'node_modules', 'bundle-a')
  711. await expect(import(join(proxy, 'entry-1.js'))).resolves.toMatchObject({ mini: true })
  712. await expect(import(join(proxy, 'entry-2.js'))).resolves.toMatchObject({ web: true })
  713. } finally {
  714. delete (process as NodeJS.Process & { pkg?: unknown }).pkg
  715. }
  716. })
  717. it('preserves the installation path while resolving packaged exports', async () => {
  718. const anchor = stageInstallation({})
  719. const appDir = join(anchor, '..')
  720. const physical = tmp()
  721. writeFileSync(join(physical, 'package.json'), JSON.stringify({
  722. name: 'linked-esm',
  723. version: '0.0.0',
  724. type: 'module',
  725. exports: { import: './index.js' },
  726. }))
  727. writeFileSync(join(physical, 'index.js'), 'export const linked = true\n')
  728. symlinkSync(physical, join(appDir, 'node_modules', 'linked-esm'), 'junction')
  729. const appManifest = JSON.parse(readFileSync(anchor, 'utf8')) as { dependencies: Record<string, string> }
  730. appManifest.dependencies['linked-esm'] = '0.0.0'
  731. writeFileSync(anchor, JSON.stringify(appManifest))
  732. Object.defineProperty(process, 'pkg', { configurable: true, value: {} })
  733. try {
  734. const home = tmp()
  735. await healProfilesModuleFallback({ installAnchor: anchor, home })
  736. const proxyManifest = JSON.parse(readFileSync(
  737. join(home, 'profiles', 'node_modules', 'linked-esm', 'package.json'),
  738. 'utf8',
  739. )) as { dsh: { moduleFallback: { targets: Record<string, string> } } }
  740. expect(proxyManifest.dsh.moduleFallback.targets['.']).toContain('/app/node_modules/linked-esm/index.js')
  741. } finally {
  742. delete (process as NodeJS.Process & { pkg?: unknown }).pkg
  743. }
  744. })
  745. it('uses the legacy index fallback when a package has no exports or main', async () => {
  746. const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } })
  747. const bundleDir = join(anchor, '..', 'node_modules', 'bundle-a')
  748. const manifest = JSON.parse(readFileSync(join(bundleDir, 'package.json'), 'utf8')) as Record<string, unknown>
  749. delete manifest.main
  750. writeFileSync(join(bundleDir, 'package.json'), JSON.stringify(manifest))
  751. Object.defineProperty(process, 'pkg', { configurable: true, value: {} })
  752. try {
  753. const home = tmp()
  754. await healProfilesModuleFallback({ installAnchor: anchor, home })
  755. await expect(import(join(home, 'profiles', 'node_modules', 'bundle-a', 'entry-0.js')))
  756. .resolves.toMatchObject({ packageName: 'bundle-a' })
  757. } finally {
  758. delete (process as NodeJS.Process & { pkg?: unknown }).pkg
  759. }
  760. })
  761. it('uses Node legacy resolution for an extensionless main entry', async () => {
  762. const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } })
  763. const bundleDir = join(anchor, '..', 'node_modules', 'bundle-a')
  764. const manifest = JSON.parse(readFileSync(join(bundleDir, 'package.json'), 'utf8')) as Record<string, unknown>
  765. manifest.main = './index'
  766. writeFileSync(join(bundleDir, 'package.json'), JSON.stringify(manifest))
  767. Object.defineProperty(process, 'pkg', { configurable: true, value: {} })
  768. try {
  769. const home = tmp()
  770. await healProfilesModuleFallback({ installAnchor: anchor, home })
  771. await expect(import(join(home, 'profiles', 'node_modules', 'bundle-a', 'entry-0.js')))
  772. .resolves.toMatchObject({ packageName: 'bundle-a' })
  773. } finally {
  774. delete (process as NodeJS.Process & { pkg?: unknown }).pkg
  775. }
  776. })
  777. it('skips executable-only and declaration-only packages without import entries', async () => {
  778. for (const marker of ['bin', 'types', 'typings']) {
  779. const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } })
  780. const manifest = JSON.parse(readFileSync(anchor, 'utf8')) as Record<string, unknown>
  781. delete manifest.main
  782. manifest[marker] = marker === 'bin' ? { dsh: './lib/bin.js' } : './index.d.ts'
  783. if (marker === 'types') manifest.main = ''
  784. writeFileSync(anchor, JSON.stringify(manifest))
  785. rmSync(join(anchor, '..', 'index.js'))
  786. Object.defineProperty(process, 'pkg', { configurable: true, value: {} })
  787. try {
  788. const home = tmp()
  789. await healProfilesModuleFallback({ installAnchor: anchor, home })
  790. const fallback = join(home, 'profiles', 'node_modules')
  791. expect(existsSync(join(fallback, 'dsh-app'))).toBe(false)
  792. expect(existsSync(join(fallback, 'bundle-a', 'entry-0.js'))).toBe(true)
  793. } finally {
  794. delete (process as NodeJS.Process & { pkg?: unknown }).pkg
  795. }
  796. }
  797. })
  798. it('fails loud on a missing legacy main entry', async () => {
  799. const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } })
  800. const bundleDir = join(anchor, '..', 'node_modules', 'bundle-a')
  801. const manifest = JSON.parse(readFileSync(join(bundleDir, 'package.json'), 'utf8')) as Record<string, unknown>
  802. delete manifest.main
  803. writeFileSync(join(bundleDir, 'package.json'), JSON.stringify(manifest))
  804. rmSync(join(bundleDir, 'index.js'))
  805. Object.defineProperty(process, 'pkg', { configurable: true, value: {} })
  806. try {
  807. await expect(healProfilesModuleFallback({ installAnchor: anchor, home: tmp() })).rejects.toThrow('main entry is missing')
  808. } finally {
  809. delete (process as NodeJS.Process & { pkg?: unknown }).pkg
  810. }
  811. })
  812. it('omits unavailable ESM exports and rejects malformed export targets', async () => {
  813. for (const mode of ['missing', 'directory', 'absent-map', 'invalid', 'escape', 'null', 'null-subpath']) {
  814. const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } })
  815. const bundleDir = join(anchor, '..', 'node_modules', 'bundle-a')
  816. const manifest = JSON.parse(readFileSync(join(bundleDir, 'package.json'), 'utf8')) as Record<string, unknown>
  817. const target = mode === 'missing' ? './missing.js'
  818. : mode === 'directory' ? './mini'
  819. : mode === 'escape' ? './../outside.js'
  820. : '../outside.js'
  821. manifest.exports = mode === 'absent-map' ? null
  822. : mode === 'null-subpath' ? { './bad': null }
  823. : { '.': mode === 'null' ? null : { import: target } }
  824. writeFileSync(join(bundleDir, 'package.json'), JSON.stringify(manifest))
  825. if (mode === 'directory') mkdirSync(join(bundleDir, 'mini'))
  826. Object.defineProperty(process, 'pkg', { configurable: true, value: {} })
  827. try {
  828. const home = tmp()
  829. if (mode === 'missing' || mode === 'directory' || mode === 'absent-map') {
  830. await healProfilesModuleFallback({ installAnchor: anchor, home })
  831. expect(existsSync(join(home, 'profiles', 'node_modules', 'bundle-a'))).toBe(false)
  832. } else {
  833. await expect(healProfilesModuleFallback({ installAnchor: anchor, home })).rejects.toThrow(
  834. mode === 'null' || mode === 'null-subpath'
  835. ? 'cannot resolve ESM export bundle-a'
  836. : 'resolves outside its package',
  837. )
  838. }
  839. } finally {
  840. delete (process as NodeJS.Process & { pkg?: unknown }).pkg
  841. }
  842. }
  843. })
  844. it('requires a package version before writing a packaged proxy', async () => {
  845. const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } })
  846. const bundleDir = join(anchor, '..', 'node_modules', 'bundle-a')
  847. const manifest = JSON.parse(readFileSync(join(bundleDir, 'package.json'), 'utf8')) as Record<string, unknown>
  848. manifest.version = ''
  849. writeFileSync(join(bundleDir, 'package.json'), JSON.stringify(manifest))
  850. Object.defineProperty(process, 'pkg', { configurable: true, value: {} })
  851. try {
  852. await expect(healProfilesModuleFallback({ installAnchor: anchor, home: tmp() })).rejects.toThrow(
  853. 'installed package bundle-a must declare a non-empty version',
  854. )
  855. } finally {
  856. delete (process as NodeJS.Process & { pkg?: unknown }).pkg
  857. }
  858. })
  859. it('replaces plain-node links and stale managed proxies in packaged mode', async () => {
  860. const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } })
  861. const home = tmp()
  862. await healProfilesModuleFallback({ installAnchor: anchor, home })
  863. const proxy = join(home, 'profiles', 'node_modules', 'bundle-a')
  864. expect(lstatSync(proxy).isSymbolicLink()).toBe(true)
  865. Object.defineProperty(process, 'pkg', { configurable: true, value: {} })
  866. try {
  867. await healProfilesModuleFallback({ installAnchor: anchor, home })
  868. expect(lstatSync(proxy).isDirectory()).toBe(true)
  869. const stale = JSON.parse(readFileSync(join(proxy, 'package.json'), 'utf8')) as {
  870. version: string
  871. }
  872. stale.version = 'stale'
  873. writeFileSync(join(proxy, 'package.json'), JSON.stringify(stale))
  874. await healProfilesModuleFallback({ installAnchor: anchor, home })
  875. expect(JSON.parse(readFileSync(join(proxy, 'package.json'), 'utf8'))).toMatchObject({
  876. version: '0.0.0',
  877. })
  878. } finally {
  879. delete (process as NodeJS.Process & { pkg?: unknown }).pkg
  880. }
  881. })
  882. it('replaces a managed packaged proxy with a plain-node symlink', async () => {
  883. const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } })
  884. const home = tmp()
  885. const fallback = join(home, 'profiles', 'node_modules', 'bundle-a')
  886. Object.defineProperty(process, 'pkg', { configurable: true, value: {} })
  887. try {
  888. await healProfilesModuleFallback({ installAnchor: anchor, home })
  889. expect(lstatSync(fallback).isDirectory()).toBe(true)
  890. } finally {
  891. delete (process as NodeJS.Process & { pkg?: unknown }).pkg
  892. }
  893. await healProfilesModuleFallback({ installAnchor: anchor, home })
  894. expect(lstatSync(fallback).isSymbolicLink()).toBe(true)
  895. })
  896. it('rejects foreign packaged fallback directories with valid or invalid metadata', async () => {
  897. const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } })
  898. Object.defineProperty(process, 'pkg', { configurable: true, value: {} })
  899. try {
  900. for (const metadata of ['{}', '{']) {
  901. const home = tmp()
  902. const proxy = join(home, 'profiles', 'node_modules', 'bundle-a')
  903. mkdirSync(proxy, { recursive: true })
  904. writeFileSync(join(proxy, 'package.json'), metadata)
  905. await expect(healProfilesModuleFallback({ installAnchor: anchor, home })).rejects.toThrow(
  906. 'exists and is not a dsh-managed module proxy',
  907. )
  908. }
  909. } finally {
  910. delete (process as NodeJS.Process & { pkg?: unknown }).pkg
  911. }
  912. })
  913. })