profile.spec.ts 49 KB

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