tsconfig-paths-loader.spec.ts 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180
  1. import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
  2. import type { ResolveFnOutput, ResolveHookContext } from 'node:module'
  3. import { tmpdir } from 'node:os'
  4. import { dirname, join } from 'node:path'
  5. import { pathToFileURL } from 'node:url'
  6. import { afterEach, describe, expect, it, vi } from 'vitest'
  7. import { initialize, resolveHook, TsconfigPathsResolver } from '../src/tsconfig-paths-loader.ts'
  8. class ResolverFixture {
  9. readonly root = mkdtempSync(join(tmpdir(), 'dsh-tsconfig-paths-'))
  10. path(relativePath: string): string {
  11. return join(this.root, relativePath)
  12. }
  13. write(relativePath: string, content = 'export {}\n'): string {
  14. const path = this.path(relativePath)
  15. mkdirSync(dirname(path), { recursive: true })
  16. writeFileSync(path, content)
  17. return path
  18. }
  19. writeJson(relativePath: string, value: unknown): string {
  20. return this.write(relativePath, `${JSON.stringify(value)}\n`)
  21. }
  22. createResolver(paths: Record<string, string[]>): TsconfigPathsResolver {
  23. const tsconfigPath = this.writeJson('tsconfig.json', { compilerOptions: { paths } })
  24. return TsconfigPathsResolver.create(tsconfigPath)
  25. }
  26. parentURL(relativePath = 'consumer/src/nested/index.ts'): string {
  27. return pathToFileURL(this.path(relativePath)).href
  28. }
  29. dispose(): void {
  30. rmSync(this.root, { recursive: true, force: true })
  31. }
  32. }
  33. const fixtures: ResolverFixture[] = []
  34. function fixture(): ResolverFixture {
  35. const value = new ResolverFixture()
  36. fixtures.push(value)
  37. return value
  38. }
  39. afterEach(() => {
  40. for (const value of fixtures.splice(0)) value.dispose()
  41. })
  42. describe('TsconfigPathsResolver', () => {
  43. it('orders exact, longer-prefix, and longer-suffix path rules', async () => {
  44. const files = fixture()
  45. files.writeJson('consumer/package.json', {
  46. dependencies: {
  47. '@scope/feature-name': '*',
  48. '@scope/feature-other': '*',
  49. '@scope/plain-suffix': '*',
  50. },
  51. })
  52. files.write('targets/exact.ts')
  53. files.write('targets/prefix/other.ts')
  54. files.write('targets/generic/feature-other.ts')
  55. files.write('targets/suffix/plain.ts')
  56. files.write('targets/generic/plain-suffix.ts')
  57. const resolver = files.createResolver({
  58. '@scope/*': ['./targets/generic/*'],
  59. '@scope/*-suffix': ['./targets/suffix/*'],
  60. '@scope/feature-*': ['./targets/prefix/*'],
  61. '@scope/feature-name': ['./targets/exact.ts'],
  62. })
  63. await expect(resolver.resolve('@scope/feature-name', files.parentURL()))
  64. .resolves.toBe(pathToFileURL(files.path('targets/exact.ts')).href)
  65. await expect(resolver.resolve('@scope/feature-other', files.parentURL()))
  66. .resolves.toBe(pathToFileURL(files.path('targets/prefix/other.ts')).href)
  67. await expect(resolver.resolve('@scope/plain-suffix', files.parentURL()))
  68. .resolves.toBe(pathToFileURL(files.path('targets/suffix/plain.ts')).href)
  69. })
  70. it('resolves only self-references and runtime dependencies from the nearest ancestor manifest', async () => {
  71. const files = fixture()
  72. files.writeJson('consumer/package.json', {
  73. name: 'self-package',
  74. dependencies: { dependency: '*' },
  75. optionalDependencies: { optional: '*' },
  76. peerDependencies: { peer: '*' },
  77. })
  78. for (const name of ['self-package', 'dependency', 'optional', 'peer', 'undeclared']) {
  79. files.write(`targets/${name}.ts`)
  80. }
  81. const resolver = files.createResolver(Object.fromEntries(
  82. ['self-package', 'dependency', 'optional', 'peer', 'undeclared']
  83. .map(name => [name, [`./targets/${name}`]]),
  84. ))
  85. for (const name of ['self-package', 'dependency', 'optional', 'peer']) {
  86. await expect(resolver.resolve(name, files.parentURL()))
  87. .resolves.toBe(pathToFileURL(files.path(`targets/${name}.ts`)).href)
  88. }
  89. await expect(resolver.resolve('undeclared', files.parentURL())).resolves.toBeUndefined()
  90. })
  91. it('probes native TypeScript extensions and index files but excludes TSX and missing targets', async () => {
  92. const files = fixture()
  93. const names = ['plain-ts', 'module-mts', 'common-cts', 'directory', 'tsx-implicit', 'tsx-explicit', 'missing']
  94. files.writeJson('consumer/package.json', {
  95. dependencies: Object.fromEntries(names.map(name => [name, '*'])),
  96. })
  97. files.write('targets/plain.ts')
  98. files.write('targets/module.mts')
  99. files.write('targets/common.cts')
  100. files.write('targets/directory/index.ts')
  101. files.write('targets/component.tsx')
  102. const resolver = files.createResolver({
  103. 'plain-ts': ['./targets/plain'],
  104. 'module-mts': ['./targets/module'],
  105. 'common-cts': ['./targets/common'],
  106. 'directory': ['./targets/directory'],
  107. 'tsx-implicit': ['./targets/component'],
  108. 'tsx-explicit': ['./targets/component.tsx'],
  109. 'missing': ['./targets/missing'],
  110. })
  111. for (const [name, target] of [
  112. ['plain-ts', 'targets/plain.ts'],
  113. ['module-mts', 'targets/module.mts'],
  114. ['common-cts', 'targets/common.cts'],
  115. ['directory', 'targets/directory/index.ts'],
  116. ] as const) {
  117. await expect(resolver.resolve(name, files.parentURL()))
  118. .resolves.toBe(pathToFileURL(files.path(target)).href)
  119. }
  120. await expect(resolver.resolve('tsx-implicit', files.parentURL())).resolves.toBeUndefined()
  121. await expect(resolver.resolve('tsx-explicit', files.parentURL())).resolves.toBeUndefined()
  122. await expect(resolver.resolve('missing', files.parentURL())).resolves.toBeUndefined()
  123. })
  124. it('anchors inherited paths at the config that declared them', async () => {
  125. const files = fixture()
  126. files.writeJson('consumer/package.json', { dependencies: { custom: '*' } })
  127. files.write('targets/custom.ts')
  128. files.writeJson('base.json', { compilerOptions: { paths: { custom: ['./targets/custom'] } } })
  129. const customTsconfig = files.writeJson('configs/custom.json', { extends: '../base.json' })
  130. const resolver = TsconfigPathsResolver.create(customTsconfig)
  131. await expect(resolver.resolve('custom', files.parentURL()))
  132. .resolves.toBe(pathToFileURL(files.path('targets/custom.ts')).href)
  133. })
  134. it('short-circuits matched aliases and delegates unsupported schemes or unmatched requests', async () => {
  135. const files = fixture()
  136. files.writeJson('consumer/package.json', { dependencies: { matched: '*' } })
  137. const target = files.write('targets/matched.ts')
  138. const tsconfigPath = files.writeJson('tsconfig.json', {
  139. compilerOptions: { paths: { matched: ['./targets/matched'] } },
  140. })
  141. initialize({ tsconfigPath })
  142. const context: ResolveHookContext = {
  143. conditions: [],
  144. importAttributes: {},
  145. parentURL: files.parentURL(),
  146. }
  147. const nextResolve = vi.fn(async (
  148. specifier: string,
  149. _context: ResolveHookContext,
  150. ): Promise<ResolveFnOutput> => ({ url: `next:${specifier}` }))
  151. await expect(resolveHook('matched', context, nextResolve))
  152. .resolves.toEqual({ url: pathToFileURL(target).href, shortCircuit: true })
  153. expect(nextResolve).not.toHaveBeenCalled()
  154. for (const specifier of ['unmatched', 'node:fs', 'data:text/javascript,export default 1', 'https://example.test/mod.ts']) {
  155. await expect(resolveHook(specifier, context, nextResolve)).resolves.toEqual({ url: `next:${specifier}` })
  156. expect(nextResolve).toHaveBeenLastCalledWith(specifier, context)
  157. }
  158. })
  159. })