launch.spec.ts 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169
  1. /** Public dsh launch resolution for the TypeScript SDK. */
  2. import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
  3. import { tmpdir } from 'node:os'
  4. import { join, resolve } from 'node:path'
  5. import { pathToFileURL } from 'node:url'
  6. import { afterEach, describe, expect, it } from 'vitest'
  7. import {
  8. DEFAULT_INITIALIZE_TIMEOUT_MS,
  9. installedDshBin,
  10. resolveDshNodeLaunchFromManifests,
  11. resolveDshBinFromManifests,
  12. resolveDshLaunch,
  13. } from '../src/launch.ts'
  14. const cleanups: string[] = []
  15. afterEach(() => {
  16. for (const path of cleanups.splice(0)) rmSync(path, { recursive: true, force: true })
  17. })
  18. function manifestPair(dsh: object, client: object): { dshUrl: string; clientUrl: string; root: string } {
  19. const root = mkdtempSync(join(tmpdir(), 'dsh-sdk-manifests-'))
  20. cleanups.push(root)
  21. const dshPath = join(root, 'dsh-package.json')
  22. const clientPath = join(root, 'client-package.json')
  23. writeFileSync(dshPath, JSON.stringify(dsh))
  24. writeFileSync(clientPath, JSON.stringify(client))
  25. return {
  26. dshUrl: pathToFileURL(dshPath).href,
  27. clientUrl: pathToFileURL(clientPath).href,
  28. root,
  29. }
  30. }
  31. describe('SDK dsh launch resolution', () => {
  32. it('resolves the same-version installed dsh entry by default', () => {
  33. const bin = installedDshBin()
  34. expect(bin.endsWith(join('apps', 'cli', 'lib', 'bin.js'))).toBe(true)
  35. const launch = resolveDshLaunch()
  36. expect(launch.command).toBe(process.execPath)
  37. expect(launch.args).toEqual(existsSync(bin)
  38. ? [bin, '--profile', 'sdk']
  39. : [
  40. '--import', import.meta.resolve('tsx/esm'), resolve(bin, '..', '..', 'src/bin.ts'),
  41. '--profile', 'sdk',
  42. '--patch', resolve(bin, '..', '..', 'src/sdk-source.cordis.patch.yml'),
  43. ])
  44. expect(launch.initializeTimeoutMs).toBe(DEFAULT_INITIALIZE_TIMEOUT_MS)
  45. expect(launch.description).toBe('dsh profile "sdk"')
  46. })
  47. it('makes every filesystem input absolute before spawn and preserves patch order', () => {
  48. const caller = resolve('/tmp', 'sdk-launch-caller')
  49. const launch = resolveDshLaunch({
  50. dshBin: './bin/dsh',
  51. profile: 'custom-sdk',
  52. patches: ['./first.yml', '../second.yml'],
  53. dshHome: './home',
  54. processCwd: './worker',
  55. env: { PATH: '/bin', DSH_HOME: '/stale' },
  56. initializeTimeoutMs: 123,
  57. requestTimeoutMs: 456,
  58. shutdownTimeoutMs: 789,
  59. disposeEofGraceMs: 12,
  60. disposeGraceMs: 34,
  61. }, caller)
  62. expect(launch).toMatchObject({
  63. command: process.execPath,
  64. args: [
  65. join(caller, 'bin/dsh'),
  66. '--profile', 'custom-sdk',
  67. '--patch', join(caller, 'first.yml'),
  68. '--patch', resolve(caller, '../second.yml'),
  69. ],
  70. cwd: join(caller, 'worker'),
  71. description: 'dsh profile "custom-sdk"',
  72. initializeTimeoutMs: 123,
  73. requestTimeoutMs: 456,
  74. shutdownTimeoutMs: 789,
  75. disposeEofGraceMs: 12,
  76. disposeGraceMs: 34,
  77. })
  78. expect(launch.environment()).toEqual({ PATH: '/bin', DSH_HOME: join(caller, 'home') })
  79. })
  80. it('falls back to the same package source entry through an absolute tsx loader', () => {
  81. const pair = manifestPair({ version: '1.0.0', bin: 'lib/bin.js' }, { version: '1.0.0' })
  82. const sourceBin = join(pair.root, 'src/bin.ts')
  83. const sourcePatch = join(pair.root, 'src/sdk-source.cordis.patch.yml')
  84. const sourceTsconfig = join(pair.root, 'tsconfig.json')
  85. mkdirSync(join(pair.root, 'src'))
  86. writeFileSync(sourceBin, '')
  87. writeFileSync(sourcePatch, '[]\n')
  88. writeFileSync(sourceTsconfig, '{}\n')
  89. expect(resolveDshNodeLaunchFromManifests(pair.dshUrl, pair.clientUrl, 'file:///tsx-loader.mjs'))
  90. .toEqual({
  91. nodeArgs: ['--import', 'file:///tsx-loader.mjs', sourceBin],
  92. patches: [sourcePatch],
  93. environment: { TSX_TSCONFIG_PATH: sourceTsconfig },
  94. })
  95. expect(resolveDshNodeLaunchFromManifests(pair.dshUrl, pair.clientUrl))
  96. .toEqual({
  97. nodeArgs: ['--import', import.meta.resolve('tsx/esm'), sourceBin],
  98. patches: [sourcePatch],
  99. environment: { TSX_TSCONFIG_PATH: sourceTsconfig },
  100. })
  101. })
  102. it('uses the built entry when the manifest bin exists', () => {
  103. const pair = manifestPair({ version: '1.0.0', bin: 'lib/bin.js' }, { version: '1.0.0' })
  104. const bin = join(pair.root, 'lib/bin.js')
  105. mkdirSync(join(pair.root, 'lib'))
  106. writeFileSync(bin, '')
  107. expect(resolveDshNodeLaunchFromManifests(pair.dshUrl, pair.clientUrl)).toEqual({
  108. nodeArgs: [bin],
  109. patches: [],
  110. environment: {},
  111. })
  112. })
  113. it.each([0, 1, 2])('fails loud when a source launch is missing required file set %s', (presentCount) => {
  114. const pair = manifestPair({ version: '1.0.0', bin: 'lib/bin.js' }, { version: '1.0.0' })
  115. mkdirSync(join(pair.root, 'src'))
  116. const sourceFiles = ['src/bin.ts', 'src/sdk-source.cordis.patch.yml', 'tsconfig.json']
  117. for (const source of sourceFiles.slice(0, presentCount)) writeFileSync(join(pair.root, source), '')
  118. expect(() => resolveDshNodeLaunchFromManifests(pair.dshUrl, pair.clientUrl, 'file:///tsx-loader.mjs'))
  119. .toThrow('is missing its built executable')
  120. })
  121. it('reads explicit and inherited environments when the child starts', () => {
  122. const explicit: NodeJS.ProcessEnv = { MARKER: 'before' }
  123. const explicitLaunch = resolveDshLaunch({ dshBin: '/bin/dsh', env: explicit })
  124. explicit.MARKER = 'after'
  125. expect(explicitLaunch.environment().MARKER).toBe('after')
  126. const inheritedLaunch = resolveDshLaunch({ dshBin: '/bin/dsh' })
  127. process.env.DSH_SDK_LATE_ENV_TEST = 'late'
  128. try {
  129. expect(inheritedLaunch.environment().DSH_SDK_LATE_ENV_TEST).toBe('late')
  130. } finally {
  131. delete process.env.DSH_SDK_LATE_ENV_TEST
  132. }
  133. })
  134. it.each([2, '2.0.0'])(
  135. 'rejects a dsh version that differs from the client (%j)',
  136. (version) => {
  137. const pair = manifestPair({ version, bin: 'bin.js' }, { version: '1.0.0' })
  138. expect(() => resolveDshBinFromManifests(pair.dshUrl, pair.clientUrl))
  139. .toThrow(`requires the same dsh version, got ${String(version)}`)
  140. },
  141. )
  142. it('accepts the string npm bin form', () => {
  143. const pair = manifestPair({ version: '1.0.0', bin: './bin.js' }, { version: '1.0.0' })
  144. expect(resolveDshBinFromManifests(pair.dshUrl, pair.clientUrl)).toBe(join(pair.root, 'bin.js'))
  145. })
  146. it.each([null, {}, ''])(
  147. 'rejects a manifest without a usable dsh executable (%j)',
  148. (bin) => {
  149. const pair = manifestPair({ version: '1.0.0', bin }, { version: '1.0.0' })
  150. expect(() => resolveDshBinFromManifests(pair.dshUrl, pair.clientUrl))
  151. .toThrow('declares no dsh executable')
  152. },
  153. )
  154. })