| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180 |
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
- import type { ResolveFnOutput, ResolveHookContext } from 'node:module'
- import { tmpdir } from 'node:os'
- import { dirname, join } from 'node:path'
- import { pathToFileURL } from 'node:url'
- import { afterEach, describe, expect, it, vi } from 'vitest'
- import { initialize, resolveHook, TsconfigPathsResolver } from '../src/tsconfig-paths-loader.ts'
- class ResolverFixture {
- readonly root = mkdtempSync(join(tmpdir(), 'dsh-tsconfig-paths-'))
- path(relativePath: string): string {
- return join(this.root, relativePath)
- }
- write(relativePath: string, content = 'export {}\n'): string {
- const path = this.path(relativePath)
- mkdirSync(dirname(path), { recursive: true })
- writeFileSync(path, content)
- return path
- }
- writeJson(relativePath: string, value: unknown): string {
- return this.write(relativePath, `${JSON.stringify(value)}\n`)
- }
- createResolver(paths: Record<string, string[]>): TsconfigPathsResolver {
- const tsconfigPath = this.writeJson('tsconfig.json', { compilerOptions: { paths } })
- return TsconfigPathsResolver.create(tsconfigPath)
- }
- parentURL(relativePath = 'consumer/src/nested/index.ts'): string {
- return pathToFileURL(this.path(relativePath)).href
- }
- dispose(): void {
- rmSync(this.root, { recursive: true, force: true })
- }
- }
- const fixtures: ResolverFixture[] = []
- function fixture(): ResolverFixture {
- const value = new ResolverFixture()
- fixtures.push(value)
- return value
- }
- afterEach(() => {
- for (const value of fixtures.splice(0)) value.dispose()
- })
- describe('TsconfigPathsResolver', () => {
- it('orders exact, longer-prefix, and longer-suffix path rules', async () => {
- const files = fixture()
- files.writeJson('consumer/package.json', {
- dependencies: {
- '@scope/feature-name': '*',
- '@scope/feature-other': '*',
- '@scope/plain-suffix': '*',
- },
- })
- files.write('targets/exact.ts')
- files.write('targets/prefix/other.ts')
- files.write('targets/generic/feature-other.ts')
- files.write('targets/suffix/plain.ts')
- files.write('targets/generic/plain-suffix.ts')
- const resolver = files.createResolver({
- '@scope/*': ['./targets/generic/*'],
- '@scope/*-suffix': ['./targets/suffix/*'],
- '@scope/feature-*': ['./targets/prefix/*'],
- '@scope/feature-name': ['./targets/exact.ts'],
- })
- await expect(resolver.resolve('@scope/feature-name', files.parentURL()))
- .resolves.toBe(pathToFileURL(files.path('targets/exact.ts')).href)
- await expect(resolver.resolve('@scope/feature-other', files.parentURL()))
- .resolves.toBe(pathToFileURL(files.path('targets/prefix/other.ts')).href)
- await expect(resolver.resolve('@scope/plain-suffix', files.parentURL()))
- .resolves.toBe(pathToFileURL(files.path('targets/suffix/plain.ts')).href)
- })
- it('resolves only self-references and runtime dependencies from the nearest ancestor manifest', async () => {
- const files = fixture()
- files.writeJson('consumer/package.json', {
- name: 'self-package',
- dependencies: { dependency: '*' },
- optionalDependencies: { optional: '*' },
- peerDependencies: { peer: '*' },
- })
- for (const name of ['self-package', 'dependency', 'optional', 'peer', 'undeclared']) {
- files.write(`targets/${name}.ts`)
- }
- const resolver = files.createResolver(Object.fromEntries(
- ['self-package', 'dependency', 'optional', 'peer', 'undeclared']
- .map(name => [name, [`./targets/${name}`]]),
- ))
- for (const name of ['self-package', 'dependency', 'optional', 'peer']) {
- await expect(resolver.resolve(name, files.parentURL()))
- .resolves.toBe(pathToFileURL(files.path(`targets/${name}.ts`)).href)
- }
- await expect(resolver.resolve('undeclared', files.parentURL())).resolves.toBeUndefined()
- })
- it('probes native TypeScript extensions and index files but excludes TSX and missing targets', async () => {
- const files = fixture()
- const names = ['plain-ts', 'module-mts', 'common-cts', 'directory', 'tsx-implicit', 'tsx-explicit', 'missing']
- files.writeJson('consumer/package.json', {
- dependencies: Object.fromEntries(names.map(name => [name, '*'])),
- })
- files.write('targets/plain.ts')
- files.write('targets/module.mts')
- files.write('targets/common.cts')
- files.write('targets/directory/index.ts')
- files.write('targets/component.tsx')
- const resolver = files.createResolver({
- 'plain-ts': ['./targets/plain'],
- 'module-mts': ['./targets/module'],
- 'common-cts': ['./targets/common'],
- 'directory': ['./targets/directory'],
- 'tsx-implicit': ['./targets/component'],
- 'tsx-explicit': ['./targets/component.tsx'],
- 'missing': ['./targets/missing'],
- })
- for (const [name, target] of [
- ['plain-ts', 'targets/plain.ts'],
- ['module-mts', 'targets/module.mts'],
- ['common-cts', 'targets/common.cts'],
- ['directory', 'targets/directory/index.ts'],
- ] as const) {
- await expect(resolver.resolve(name, files.parentURL()))
- .resolves.toBe(pathToFileURL(files.path(target)).href)
- }
- await expect(resolver.resolve('tsx-implicit', files.parentURL())).resolves.toBeUndefined()
- await expect(resolver.resolve('tsx-explicit', files.parentURL())).resolves.toBeUndefined()
- await expect(resolver.resolve('missing', files.parentURL())).resolves.toBeUndefined()
- })
- it('anchors inherited paths at the config that declared them', async () => {
- const files = fixture()
- files.writeJson('consumer/package.json', { dependencies: { custom: '*' } })
- files.write('targets/custom.ts')
- files.writeJson('base.json', { compilerOptions: { paths: { custom: ['./targets/custom'] } } })
- const customTsconfig = files.writeJson('configs/custom.json', { extends: '../base.json' })
- const resolver = TsconfigPathsResolver.create(customTsconfig)
- await expect(resolver.resolve('custom', files.parentURL()))
- .resolves.toBe(pathToFileURL(files.path('targets/custom.ts')).href)
- })
- it('short-circuits matched aliases and delegates unsupported schemes or unmatched requests', async () => {
- const files = fixture()
- files.writeJson('consumer/package.json', { dependencies: { matched: '*' } })
- const target = files.write('targets/matched.ts')
- const tsconfigPath = files.writeJson('tsconfig.json', {
- compilerOptions: { paths: { matched: ['./targets/matched'] } },
- })
- initialize({ tsconfigPath })
- const context: ResolveHookContext = {
- conditions: [],
- importAttributes: {},
- parentURL: files.parentURL(),
- }
- const nextResolve = vi.fn(async (
- specifier: string,
- _context: ResolveHookContext,
- ): Promise<ResolveFnOutput> => ({ url: `next:${specifier}` }))
- await expect(resolveHook('matched', context, nextResolve))
- .resolves.toEqual({ url: pathToFileURL(target).href, shortCircuit: true })
- expect(nextResolve).not.toHaveBeenCalled()
- for (const specifier of ['unmatched', 'node:fs', 'data:text/javascript,export default 1', 'https://example.test/mod.ts']) {
- await expect(resolveHook(specifier, context, nextResolve)).resolves.toEqual({ url: `next:${specifier}` })
- expect(nextResolve).toHaveBeenLastCalledWith(specifier, context)
- }
- })
- })
|