diff --git a/package.json b/package.json index 41bed63c..94eb99d0 100644 --- a/package.json +++ b/package.json @@ -85,5 +85,8 @@ } } ] + }, + "dependencies": { + "resolve-pkg-maps": "^1.0.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 99104a1a..aba99605 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,11 +11,15 @@ specifiers: jsonc-parser: ^3.2.0 manten: ^0.6.0 pkgroll: ^1.8.0 + resolve-pkg-maps: ^1.0.0 slash: ^5.0.0 tsx: ^3.12.1 type-fest: ^3.4.0 typescript: ^4.9.4 +dependencies: + resolve-pkg-maps: 1.0.0 + devDependencies: '@pvtnbr/eslint-config': 0.33.0_lzzuuodtsqwxnvqeq4g4likcqa '@types/node': 18.11.17 @@ -2709,6 +2713,10 @@ packages: engines: {node: '>=4'} dev: true + /resolve-pkg-maps/1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + dev: false + /resolve/1.22.1: resolution: {integrity: sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==} hasBin: true diff --git a/src/index.ts b/src/index.ts index 010863c5..5c25a840 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,3 +2,4 @@ export * from './types.js'; export * from './get-tsconfig.js'; export * from './parse-tsconfig/index.js'; export * from './paths-matcher/index.js'; +export * from './resolver/index.js'; diff --git a/src/parse-tsconfig/index.ts b/src/parse-tsconfig/index.ts index d4dd9286..e0a695c7 100644 --- a/src/parse-tsconfig/index.ts +++ b/src/parse-tsconfig/index.ts @@ -16,7 +16,7 @@ export function parseTsconfig( throw new Error(`Cannot resolve tsconfig at path: ${tsconfigPath}`); } const directoryPath = path.dirname(realTsconfigPath); - let config: TsConfigJson = readJsonc(realTsconfigPath) || {}; + let config = readJsonc(realTsconfigPath) || {}; if (typeof config !== 'object') { throw new SyntaxError(`Failed to parse tsconfig at: ${tsconfigPath}`); diff --git a/src/parse-tsconfig/resolve-extends.ts b/src/parse-tsconfig/resolve-extends.ts index 8baad032..30d163aa 100644 --- a/src/parse-tsconfig/resolve-extends.ts +++ b/src/parse-tsconfig/resolve-extends.ts @@ -3,6 +3,7 @@ import fs from 'fs'; import Module from 'module'; import { findUp } from '../utils/find-up.js'; import { readJsonc } from '../utils/read-jsonc.js'; +import { parsePackageName } from '../utils/parse-package-name.js'; const { existsSync } = fs; @@ -60,8 +61,7 @@ export function resolveExtends( const pnpApi = getPnpApi(); if (pnpApi) { const { resolveRequest: resolveWithPnp } = pnpApi; - const [first, second] = filePath.split('/'); - const packageName = first.startsWith('@') ? `${first}/${second}` : first; + const { packageName } = parsePackageName(filePath); try { if (packageName === filePath) { diff --git a/src/paths-matcher/index.ts b/src/paths-matcher/index.ts index a0961750..41a34b5c 100644 --- a/src/paths-matcher/index.ts +++ b/src/paths-matcher/index.ts @@ -35,13 +35,15 @@ function parsePaths( }); } +export type PathsMatcher = (specifier: string) => string[]; + /** * Reference: * https://github.com/microsoft/TypeScript/blob/3ccbe804f850f40d228d3c875be952d94d39aa1d/src/compiler/moduleNameResolver.ts#L2465 */ export function createPathsMatcher( tsconfig: TsConfigResult, -) { +): PathsMatcher | null { if (!tsconfig.config.compilerOptions) { return null; } diff --git a/src/resolver/index.ts b/src/resolver/index.ts new file mode 100644 index 00000000..734facc3 --- /dev/null +++ b/src/resolver/index.ts @@ -0,0 +1,49 @@ +import fs from 'fs'; +import path from 'path'; +import { createPathsMatcher } from '../paths-matcher/index'; +import type { TsConfigResult } from '../types'; +import { resolveBareSpecifier } from './resolve-bare-specifier'; +import type { FsAPI } from './types'; +import { resolvePathExtension } from './resolve-path-extension'; + +export function createResolver( + tsconfig?: TsConfigResult, + api: FsAPI = fs, +) { + const preserveSymlinks = tsconfig?.config.compilerOptions?.preserveSymlinks; + const pathsResolver = tsconfig && createPathsMatcher(tsconfig); + + // TODO: include Node.js's --preserve-symlinks + const resolveSymlink = (path: string | undefined) => ( + (!path || preserveSymlinks) + ? path + : api.realpathSync(path) + ); + + return function resolver( + request: string, + context: string, + conditions?: string[], + ): string | undefined { + // Resolve relative specifier + if (request.startsWith('.')) { + request = path.join(context, request); + } + + // Absolute specifier + if (request.startsWith('/')) { + return resolveSymlink(resolvePathExtension(request, api)); + } + + // Resolve bare specifier + return resolveSymlink( + resolveBareSpecifier( + request, + context, + conditions, + pathsResolver, + api, + ), + ); + }; +} diff --git a/src/resolver/resolve-bare-specifier.ts b/src/resolver/resolve-bare-specifier.ts new file mode 100644 index 00000000..6d60d9a0 --- /dev/null +++ b/src/resolver/resolve-bare-specifier.ts @@ -0,0 +1,104 @@ +import path from 'path'; +import type { PackageJson } from 'type-fest'; +import { type PathsMatcher } from '../paths-matcher/index'; +import { findUp } from '../utils/find-up'; +import { parsePackageName } from '../utils/parse-package-name'; +import { readJsonc } from '../utils/read-jsonc'; +import type { FsAPI } from './types'; +import { resolvePathExtension } from './resolve-path-extension'; +import { resolveExports } from 'resolve-pkg-exports'; + +// Export maps +// https://github.com/microsoft/TypeScript/blob/71e852922888337ef51a0e48416034a94a6c34d9/src/compiler/moduleSpecifiers.ts#L663 + +// Import maps +//https://github.com/microsoft/TypeScript/blob/71e852922888337ef51a0e48416034a94a6c34d9/src/compiler/moduleNameResolver.ts#L2101 + +export function resolveBareSpecifier( + request: string, + context: string, + conditions: string[] | undefined, + pathsResolver: PathsMatcher | null | undefined, + api: FsAPI, +) { + // Try tsconfig.paths + if (pathsResolver) { + const possiblePaths = pathsResolver(request); + + for (const possiblePath of possiblePaths) { + /** + * If a path resolves to a package, + * would it resolve the export maps? + * + * Also, what if we resolve a package path + * by absolute path? Would it resolve the export map? + * Or does it need to be resolved by bare specifier? + */ + const resolved = resolvePathExtension(possiblePath, api); + if (resolved) { + return resolved; + } + } + } + + /* + 1. Find all node_module parent directories + 2. parse the package to find package directory (eg. dep/a -> dep) + 3. check exports map and check path against it + */ + const nodeModuleDirectories = findUp(context, 'node_modules', true, api); + const { packageName, packageSubpath } = parsePackageName(request); + + for (const nodeModuleDirectory of nodeModuleDirectories) { + const dependencyPath = path.join(nodeModuleDirectory, packageName); + const packageJsonPath = path.join(dependencyPath, 'package.json'); + + if (api.existsSync(packageJsonPath)) { + const packageJson = readJsonc(packageJsonPath, api); + + if (packageJson) { + const { exports } = packageJson; + if (exports) { + const resolvedSubpaths = resolveExports( + exports, + packageSubpath, + conditions || [], + ); + + for (const possibleSubpath of resolvedSubpaths) { + const resolved = resolvePathExtension( + path.join(dependencyPath, possibleSubpath), + api, + ); + + if (resolved) { + return resolved; + } + } + + // If not in export maps, dont allow lookups + continue; + } + + if (!packageSubpath && packageJson.main) { + const resolved = resolvePathExtension( + path.join(dependencyPath, packageJson.main), + api, + ); + if (resolved) { + return resolved; + } + } + } + } + + // Also resolves subpaths if packgae.json#main or #exports doesnt exist + const resolved = resolvePathExtension( + path.join(nodeModuleDirectory, request), + api, + ); + if (resolved) { + return resolved; + } + } +} diff --git a/src/resolver/resolve-path-extension.ts b/src/resolver/resolve-path-extension.ts new file mode 100644 index 00000000..ae1a826b --- /dev/null +++ b/src/resolver/resolve-path-extension.ts @@ -0,0 +1,125 @@ +import path from 'path'; +import type { PackageJson } from 'type-fest'; +import { readJsonc } from '../utils/read-jsonc'; +import type { FsAPI } from './types'; + +const stripExtensionPattern = /\.([mc]js|jsx?)$/; + +const safeStat = ( + api: FsAPI, + request: string, +) => (api.existsSync(request) && api.statSync(request)); + +function tryExtensions( + request: string, + api: FsAPI, + extensions: string[], +) { + for (const extension of extensions) { + const checkPath = request + extension; + if (api.existsSync(checkPath)) { + return checkPath; + } + } +} + +function getPackageEntry( + request: string, + api: FsAPI, +) { + const packageJsonPath = `${request}/package.json`; + if (api.existsSync(packageJsonPath)) { + const packageJson = readJsonc(packageJsonPath, api); + return packageJson?.main; + } +} + +function resolve( + request: string, + api: FsAPI, + extensions: string[], + nextGenExtensions: Record, +): string | undefined { + let resolved = tryExtensions(request, api, extensions); + if (resolved) { + return resolved; + } + + // If it has .js, strip it off and try again from start + const hasExtension = request.match(stripExtensionPattern); + if (hasExtension) { + resolved = tryExtensions( + request.slice(0, hasExtension.index), + api, + ( + nextGenExtensions[hasExtension[1] as string] + || extensions + ), + ); + + if (resolved) { + return resolved; + } + } + + const stat = safeStat(api, request); + if (stat && stat.isDirectory()) { + // Check if package.json#main exists + const hasMain = getPackageEntry(request, api); + if (hasMain) { + const mainPath = path.join(request, hasMain); + const mainStat = safeStat(api, mainPath); + if (mainStat && mainStat.isFile()) { + return mainPath; + } + + resolved = resolve(mainPath, api, extensions, nextGenExtensions); + + if (resolved) { + return resolved; + } + } + + // Fallback to index if main path doesnt exist + resolved = tryExtensions(path.join(request, 'index'), api, extensions); + + if (resolved) { + return resolved; + } + } +} + +export function resolvePathExtension( + request: string, + api: FsAPI, +) { + // Try resolving in TypeScript mode + let resolved = resolve( + request, + api, + ['.ts', '.tsx'], + { + mjs: ['.mts'], + cjs: ['.cts'], + }, + ); + + if (resolved) { + return resolved; + } + + // Try resolving in JavaScript mode + resolved = resolve( + request, + api, + ['.js', '.jsx'], + { + mjs: ['.mjs'], + cjs: ['.cjs'], + }, + ); + + if (resolved) { + return resolved; + } +} diff --git a/src/resolver/types.ts b/src/resolver/types.ts new file mode 100644 index 00000000..de62da0b --- /dev/null +++ b/src/resolver/types.ts @@ -0,0 +1,3 @@ +import fs from 'fs'; + +export type FsAPI = Pick; diff --git a/src/utils/find-up.ts b/src/utils/find-up.ts index bee46d69..ecb31f75 100644 --- a/src/utils/find-up.ts +++ b/src/utils/find-up.ts @@ -2,21 +2,52 @@ import path from 'path'; import fs from 'fs'; import slash from 'slash'; -export function findUp( +type FsAPI = Pick; + +function findUp( + searchPath: string, + fileName: string, + findAll?: false, + api?: FsAPI, +): string | undefined; + +function findUp( + searchPath: string, + fileName: string, + findAll: boolean, + api?: FsAPI, +): string[]; + +function findUp( searchPath: string, fileName: string, + findAll?: boolean, + api: FsAPI = fs, ) { + const foundPaths: string[] = []; + while (true) { const configPath = path.join(searchPath, fileName); - if (fs.existsSync(configPath)) { - return slash(configPath); + + if (api.existsSync(configPath)) { + const foundPath = slash(configPath); + + if (findAll) { + foundPaths.push(foundPath); + } else { + return foundPath; + } } const parentPath = path.dirname(searchPath); if (parentPath === searchPath) { - return; + break; } searchPath = parentPath; } + + return findAll ? foundPaths : undefined; } + +export { findUp }; diff --git a/src/utils/parse-package-name.ts b/src/utils/parse-package-name.ts new file mode 100644 index 00000000..2b28227c --- /dev/null +++ b/src/utils/parse-package-name.ts @@ -0,0 +1,16 @@ +const SLASH = '/'; + +export function parsePackageName( + request: string, +) { + const segments = request.split(SLASH); + let packageName = segments.shift()!; + if (packageName[0] === '@' && segments[0]) { + packageName += SLASH + segments.shift(); + } + + return { + packageName, + packageSubpath: segments.join(SLASH), + }; +} diff --git a/src/utils/read-jsonc.ts b/src/utils/read-jsonc.ts index 09659b5f..2873ca1c 100644 --- a/src/utils/read-jsonc.ts +++ b/src/utils/read-jsonc.ts @@ -1,6 +1,12 @@ import fs from 'fs'; import { parse } from 'jsonc-parser'; -export const readJsonc = ( +export const readJsonc = ( jsonPath: string, -) => parse(fs.readFileSync(jsonPath, 'utf8')); + api: Pick = fs, +): T | undefined => { + const parsed = parse(api.readFileSync(jsonPath, 'utf8')); + if (parsed && typeof parsed === 'object') { + return parsed; + } +}; diff --git a/tests/index.ts b/tests/index.ts index e77b312a..e713d031 100644 --- a/tests/index.ts +++ b/tests/index.ts @@ -1,7 +1,8 @@ import { describe } from 'manten'; describe('get-tsconfig', ({ runTestSuite }) => { - runTestSuite(import('./specs/get-tsconfig.js')); - runTestSuite(import('./specs/parse-tsconfig/index.js')); - runTestSuite(import('./specs/create-paths-matcher.js')); + // runTestSuite(import('./specs/get-tsconfig.js')); + // runTestSuite(import('./specs/parse-tsconfig/index.js')); + // runTestSuite(import('./specs/create-paths-matcher.js')); + runTestSuite(import('./specs/resolver/index.js')); }); diff --git a/tests/specs/create-paths-matcher.ts b/tests/specs/create-paths-matcher.ts index d8ae8d62..1da9c734 100644 --- a/tests/specs/create-paths-matcher.ts +++ b/tests/specs/create-paths-matcher.ts @@ -117,9 +117,9 @@ export default testSuite(({ describe }) => { const matcher = createPathsMatcher(tsconfig!)!; expect(matcher).not.toBeNull(); - const resolvedAttempts = await getTscResolution('exactMatch', fixture.path); + const resolved = await getTscResolution('exactMatch', fixture.path); expect(matcher('exactMatch')).toStrictEqual([ - resolvedAttempts[0].filePath.slice(0, -3), + resolved.attempts[0].filePath.slice(0, -3), ]); await fixture.rm(); @@ -152,9 +152,9 @@ export default testSuite(({ describe }) => { const matcher = createPathsMatcher(tsconfig!)!; expect(matcher).not.toBeNull(); - const resolvedAttempts = await getTscResolution('$lib', fixture.path); + const resolved = await getTscResolution('$lib', fixture.path); expect(matcher('$lib')).toStrictEqual([ - resolvedAttempts[0].filePath.slice(0, -3), + resolved.attempts[0].filePath.slice(0, -3), ]); await fixture.rm(); @@ -177,9 +177,9 @@ export default testSuite(({ describe }) => { const matcher = createPathsMatcher(tsconfig!)!; expect(matcher).not.toBeNull(); - const resolvedAttempts = await getTscResolution('exactMatch', fixture.path); + const resolved = await getTscResolution('exactMatch', fixture.path); expect(matcher('exactMatch')).toStrictEqual([ - resolvedAttempts[0].filePath.slice(0, -3), + resolved.attempts[0].filePath.slice(0, -3), ]); await fixture.rm(); @@ -203,9 +203,9 @@ export default testSuite(({ describe }) => { const matcher = createPathsMatcher(tsconfig!)!; expect(matcher).not.toBeNull(); - const resolvedAttempts = await getTscResolution('exactMatch', fixture.path); + const resolved = await getTscResolution('exactMatch', fixture.path); expect(matcher('exactMatch')).toStrictEqual([ - resolvedAttempts[0].filePath.slice(0, -3), + resolved.attempts[0].filePath.slice(0, -3), ]); await fixture.rm(); @@ -229,9 +229,9 @@ export default testSuite(({ describe }) => { const matcher = createPathsMatcher(tsconfig!)!; expect(tsconfig).not.toBeNull(); - const resolvedAttempts = await getTscResolution('exactMatch', fixture.path); + const resolved = await getTscResolution('exactMatch', fixture.path); expect(matcher('exactMatch')).toStrictEqual([ - resolvedAttempts[0].filePath.slice(0, -3), + resolved.attempts[0].filePath.slice(0, -3), ]); await fixture.rm(); @@ -255,9 +255,9 @@ export default testSuite(({ describe }) => { const matcher = createPathsMatcher(tsconfig!)!; expect(tsconfig).not.toBeNull(); - const resolvedAttempts = await getTscResolution('prefix-specifier', fixture.path); + const resolved = await getTscResolution('prefix-specifier', fixture.path); expect(matcher('prefix-specifier')).toStrictEqual([ - resolvedAttempts[0].filePath.slice(0, -3), + resolved.attempts[0].filePath.slice(0, -3), ]); await fixture.rm(); @@ -281,9 +281,9 @@ export default testSuite(({ describe }) => { const matcher = createPathsMatcher(tsconfig!)!; expect(tsconfig).not.toBeNull(); - const resolvedAttempts = await getTscResolution('specifier-suffix', fixture.path); + const resolved = await getTscResolution('specifier-suffix', fixture.path); expect(matcher('specifier-suffix')).toStrictEqual([ - resolvedAttempts[0].filePath.slice(0, -3), + resolved.attempts[0].filePath.slice(0, -3), ]); await fixture.rm(); @@ -372,9 +372,9 @@ export default testSuite(({ describe }) => { const matcher = createPathsMatcher(tsconfig!)!; expect(tsconfig).not.toBeNull(); - const resolvedAttempts = await getTscResolution('/absolute', fixture.path); + const resolved = await getTscResolution('/absolute', fixture.path); expect(matcher('/absolute')).toStrictEqual([ - resolvedAttempts[0].filePath.slice(0, -3), + resolved.attempts[0].filePath.slice(0, -3), ]); await fixture.rm(); @@ -397,9 +397,9 @@ export default testSuite(({ describe }) => { const matcher = createPathsMatcher(tsconfig!)!; expect(tsconfig).not.toBeNull(); - const resolvedAttempts = await getTscResolution('.src', fixture.path); + const resolved = await getTscResolution('.src', fixture.path); expect(matcher('.src')).toStrictEqual([ - resolvedAttempts[0].filePath.slice(0, -3), + resolved.attempts[0].filePath.slice(0, -3), ]); await fixture.rm(); diff --git a/tests/specs/resolver/error-cases.ts b/tests/specs/resolver/error-cases.ts new file mode 100644 index 00000000..fc87977c --- /dev/null +++ b/tests/specs/resolver/error-cases.ts @@ -0,0 +1,39 @@ +import { testSuite, expect } from 'manten'; +import { createFixture } from 'fs-fixture'; +import { getTscResolution } from '../../utils'; +import { createResolver } from '#get-tsconfig'; + +export default testSuite(({ describe }) => { + describe('error', ({ test }) => { + test('non existent path', async () => { + const fixture = await createFixture({ + 'tsconfig.json': '', + }); + + const request = './non-existent-path'; + const resolved = createResolver()(request, fixture.path); + const tsResolved = await getTscResolution(request, fixture.path); + + expect(resolved).toBeUndefined(); + expect(tsResolved.resolved).toBeUndefined(); + + await fixture.rm(); + }); + + test('explicit .ts', async () => { + const fixture = await createFixture({ + 'tsconfig.json': '', + 'some-file.ts': '', + }); + + const request = './some-file.ts'; + const resolved = createResolver()(request, fixture.path); + const tsResolved = await getTscResolution(request, fixture.path); + + expect(resolved).toBeUndefined(); + expect(tsResolved.resolved).toBeUndefined(); + + await fixture.rm(); + }); + }); +}); diff --git a/tests/specs/resolver/index.ts b/tests/specs/resolver/index.ts new file mode 100644 index 00000000..e0949812 --- /dev/null +++ b/tests/specs/resolver/index.ts @@ -0,0 +1,14 @@ +import { testSuite } from 'manten'; + +export default testSuite(({ describe }) => { + describe('resolver', async ({ runTestSuite }) => { + // runTestSuite(import('./error-cases.js')); + // runTestSuite(import('./resolve-extensionless.js')); + // runTestSuite(import('./resolve-js-jsx.js')); + // runTestSuite(import('./resolve-cjs-mjs.js')); + // runTestSuite(import('./resolve-directories.js')); + // runTestSuite(import('./resolve-directories-package-json.js')); + runTestSuite(import('./resolve-dependency.js')); + // resolves tsconfig.paths + }); +}); diff --git a/tests/specs/resolver/resolve-cjs-mjs.ts b/tests/specs/resolver/resolve-cjs-mjs.ts new file mode 100644 index 00000000..a01bf486 --- /dev/null +++ b/tests/specs/resolver/resolve-cjs-mjs.ts @@ -0,0 +1,146 @@ +import { testSuite, expect } from 'manten'; +import { createFixture } from 'fs-fixture'; +import { getTscResolution } from '../../utils'; +import { createResolver } from '#get-tsconfig'; + +export default testSuite(({ describe }) => { + describe('.mjs & .cjs', ({ describe }) => { + describe('append TS extensions', ({ test }) => { + test('resolve .mjs -> .mjs.ts', async () => { + const fixture = await createFixture({ + 'tsconfig.json': '', + 'some-file.mjs.ts': '', + }); + + const request = './some-file.mjs'; + const resolved = createResolver()(request, fixture.path); + const tsResolved = await getTscResolution(request, fixture.path); + + expect(resolved?.endsWith('/some-file.mjs.ts')).toBeTruthy(); + expect(resolved).toBe(tsResolved.resolved); + + await fixture.rm(); + }); + + test('resolve .cjs -> .cjs.tsx', async () => { + const fixture = await createFixture({ + 'tsconfig.json': '', + 'some-file.cjs.tsx': '', + }); + + const request = './some-file.cjs'; + const resolved = createResolver()(request, fixture.path); + const tsResolved = await getTscResolution(request, fixture.path); + + expect(resolved?.endsWith('/some-file.cjs.tsx')).toBeTruthy(); + expect(resolved).toBe(tsResolved.resolved); + + await fixture.rm(); + }); + }); + + describe('resolves TS counter part', ({ test }) => { + test('.mjs -> .mts', async () => { + const fixture = await createFixture({ + 'tsconfig.json': '', + 'some-file.mts': '', + }); + + const request = './some-file.mjs'; + const resolved = createResolver()(request, fixture.path); + const tsResolved = await getTscResolution(request, fixture.path); + + expect(resolved?.endsWith('/some-file.mts')).toBeTruthy(); + expect(resolved).toBe(tsResolved.resolved); + + await fixture.rm(); + }); + + test('.cjs -> .cts', async () => { + const fixture = await createFixture({ + 'tsconfig.json': '', + 'some-file.cts': '', + }); + + const request = './some-file.cjs'; + const resolved = createResolver()(request, fixture.path); + const tsResolved = await getTscResolution(request, fixture.path); + + expect(resolved?.endsWith('/some-file.cts')).toBeTruthy(); + expect(resolved).toBe(tsResolved.resolved); + + await fixture.rm(); + }); + }); + + describe('resolves as JS', ({ test }) => { + test('.mjs', async () => { + const fixture = await createFixture({ + 'tsconfig.json': '', + 'some-file.mjs': '', + }); + + const request = './some-file.mjs'; + const resolved = createResolver()(request, fixture.path); + const tsResolved = await getTscResolution(request, fixture.path); + + expect(resolved?.endsWith('/some-file.mjs')).toBeTruthy(); + expect(resolved).toBe(tsResolved.resolved); + + await fixture.rm(); + }); + + test('.cjs', async () => { + const fixture = await createFixture({ + 'tsconfig.json': '', + 'some-file.cjs': '', + }); + + const request = './some-file.cjs'; + const resolved = createResolver()(request, fixture.path); + const tsResolved = await getTscResolution(request, fixture.path); + + expect(resolved?.endsWith('/some-file.cjs')).toBeTruthy(); + expect(resolved).toBe(tsResolved.resolved); + + await fixture.rm(); + }); + }); + + describe('combination', ({ test }) => { + test('.mts over .mjs', async () => { + const fixture = await createFixture({ + 'tsconfig.json': '', + 'some-file.mjs': '', + 'some-file.mts': '', + }); + + const request = './some-file.mjs'; + const resolved = createResolver()(request, fixture.path); + const tsResolved = await getTscResolution(request, fixture.path); + + expect(resolved?.endsWith('/some-file.mts')).toBeTruthy(); + expect(resolved).toBe(tsResolved.resolved); + + await fixture.rm(); + }); + + test('.mjs.jsx over .mjs', async () => { + const fixture = await createFixture({ + 'tsconfig.json': '', + 'some-file.mjs.jsx': '', + 'some-file.mjs': '', + }); + + const request = './some-file.mjs'; + const resolved = createResolver()(request, fixture.path); + const tsResolved = await getTscResolution(request, fixture.path); + + expect(resolved?.endsWith('/some-file.mjs.jsx')).toBeTruthy(); + expect(resolved).toBe(tsResolved.resolved); + + await fixture.rm(); + }); + }); + }); +}); diff --git a/tests/specs/resolver/resolve-dependency.ts b/tests/specs/resolver/resolve-dependency.ts new file mode 100644 index 00000000..a5a1a91e --- /dev/null +++ b/tests/specs/resolver/resolve-dependency.ts @@ -0,0 +1,529 @@ +import path from 'path'; +import { testSuite, expect } from 'manten'; +import { createFixture } from 'fs-fixture'; +import { getTscResolution } from '../../utils'; +import { createResolver } from '#get-tsconfig'; + +export default testSuite(({ describe }) => { + describe('dependencies', ({ test, describe }) => { + describe('finds package', ({ test }) => { + test('index.js', async () => { + const fixture = await createFixture({ + 'tsconfig.json': '', + 'node_modules/some-dependency': { + 'package.json': JSON.stringify({ + name: 'some-dependency', + }), + 'index.js': '', + }, + }); + + const request = 'some-dependency'; + const resolved = createResolver()(request, fixture.path); + const tsResolved = await getTscResolution(request, fixture.path); + + expect(resolved?.endsWith('/index.js')).toBeTruthy(); + expect(resolved).toBe(tsResolved.resolved); + + await fixture.rm(); + }); + + test('@org', async () => { + const fixture = await createFixture({ + 'tsconfig.json': '', + 'node_modules/@org.js': '', + }); + + const request = '@org'; + const resolved = createResolver()(request, fixture.path); + const tsResolved = await getTscResolution(request, fixture.path); + + expect(resolved?.endsWith('/@org.js')).toBeTruthy(); + expect(resolved).toBe(tsResolved.resolved); + + await fixture.rm(); + }); + + test('@org/package', async () => { + const fixture = await createFixture({ + 'tsconfig.json': '', + 'node_modules/@org/package.js': '', + }); + + const request = '@org/package'; + const resolved = createResolver()(request, fixture.path); + const tsResolved = await getTscResolution(request, fixture.path); + + // console.log({ resolved, tsResolved }); + + expect(resolved?.endsWith('/@org/package.js')).toBeTruthy(); + expect(resolved).toBe(tsResolved.resolved); + + await fixture.rm(); + }); + + test('no package.json', async () => { + const fixture = await createFixture({ + 'tsconfig.json': '', + 'node_modules/some-dependency': { + 'index.js': '', + }, + }); + + const request = 'some-dependency'; + const resolved = createResolver()(request, fixture.path); + const tsResolved = await getTscResolution(request, fixture.path); + + expect(resolved?.endsWith('/index.js')).toBeTruthy(); + expect(resolved).toBe(tsResolved.resolved); + + await fixture.rm(); + }); + + test('no directory', async () => { + const fixture = await createFixture({ + 'tsconfig.json': '', + 'node_modules/some-dependency.js': '', + }); + + const request = 'some-dependency'; + const resolved = createResolver()(request, fixture.path); + const tsResolved = await getTscResolution(request, fixture.path); + + expect(resolved?.endsWith('/some-dependency.js')).toBeTruthy(); + expect(resolved).toBe(tsResolved.resolved); + + await fixture.rm(); + }); + + test('nested', async () => { + const fixture = await createFixture({ + 'a/b/c/d': { + 'tsconfig.json': '', + }, + 'node_modules/some-dependency': { + 'index.tsx': '', + }, + }); + + const request = 'some-dependency'; + const projectPath = path.join(fixture.path, 'a/b/c/d'); + const resolved = createResolver()(request, projectPath); + const tsResolved = await getTscResolution(request, projectPath); + + expect(resolved?.endsWith('/some-dependency/index.tsx')).toBeTruthy(); + expect(resolved).toBe(tsResolved.resolved); + + await fixture.rm(); + }); + }); + + describe('main', ({ test }) => { + test('.ts -> .ts', async () => { + const fixture = await createFixture({ + 'tsconfig.json': '', + 'node_modules/dep': { + 'package.json': JSON.stringify({ + main: 'file.ts', + }), + 'file.ts': '', + }, + }); + + const request = 'dep'; + const resolved = createResolver()(request, fixture.path); + const tsResolved = await getTscResolution(request, fixture.path); + + expect(resolved?.endsWith('/file.ts')).toBeTruthy(); + expect(resolved).toBe(tsResolved.resolved); + + await fixture.rm(); + }); + + test('.js -> .js', async () => { + const fixture = await createFixture({ + 'tsconfig.json': '', + 'node_modules/dep': { + 'package.json': JSON.stringify({ + main: 'file.js', + }), + 'file.js': '', + }, + }); + + const request = 'dep'; + const resolved = createResolver()(request, fixture.path); + const tsResolved = await getTscResolution(request, fixture.path); + + expect(resolved?.endsWith('/file.js')).toBeTruthy(); + expect(resolved).toBe(tsResolved.resolved); + + await fixture.rm(); + }); + + test('.js -> ts', async () => { + const fixture = await createFixture({ + 'tsconfig.json': '', + 'node_modules/dep': { + 'package.json': JSON.stringify({ + main: 'file.js', + }), + 'file.ts': '', + }, + }); + + const request = 'dep'; + const resolved = createResolver()(request, fixture.path); + const tsResolved = await getTscResolution(request, fixture.path); + + expect(resolved?.endsWith('/file.ts')).toBeTruthy(); + expect(resolved).toBe(tsResolved.resolved); + + await fixture.rm(); + }); + + test('.js -> tsx', async () => { + const fixture = await createFixture({ + 'tsconfig.json': '', + 'node_modules/dep': { + 'package.json': JSON.stringify({ + main: 'file.js', + }), + 'file.tsx': '', + }, + }); + + const request = 'dep'; + const resolved = createResolver()(request, fixture.path); + const tsResolved = await getTscResolution(request, fixture.path); + + expect(resolved?.endsWith('/file.tsx')).toBeTruthy(); + expect(resolved).toBe(tsResolved.resolved); + + await fixture.rm(); + }); + + test('nonexistent main fallback to index', async () => { + const fixture = await createFixture({ + 'tsconfig.json': '', + 'node_modules/dep': { + 'package.json': JSON.stringify({ + main: 'non-existent', + }), + 'index.js': '', + }, + }); + + const request = 'dep'; + const resolved = createResolver()(request, fixture.path); + const tsResolved = await getTscResolution(request, fixture.path); + + expect(resolved?.endsWith('/index.js')).toBeTruthy(); + expect(resolved).toBe(tsResolved.resolved); + + await fixture.rm(); + }); + + test('relative path', async () => { + const fixture = await createFixture({ + 'tsconfig.json': '', + 'index.ts': '', + 'node_modules/dep': { + 'package.json': JSON.stringify({ + main: '../..', + }), + }, + }); + + const request = 'dep'; + const resolved = createResolver()(request, fixture.path); + const tsResolved = await getTscResolution(request, fixture.path); + + expect(resolved?.endsWith('/index.ts')).toBeTruthy(); + expect(resolved).toBe(tsResolved.resolved); + + await fixture.rm(); + }); + + test('@scoped: .js -> ts', async () => { + const fixture = await createFixture({ + 'tsconfig.json': '', + 'node_modules/@org/dep': { + 'package.json': JSON.stringify({ + main: 'file.js', + }), + 'file.ts': '', + }, + }); + + const request = '@org/dep'; + const resolved = createResolver()(request, fixture.path); + const tsResolved = await getTscResolution(request, fixture.path); + + expect(resolved?.endsWith('/file.ts')).toBeTruthy(); + expect(resolved).toBe(tsResolved.resolved); + + await fixture.rm(); + }); + }); + + describe('subpaths', ({ describe, test }) => { + test('no package.json', async () => { + const fixture = await createFixture({ + 'tsconfig.json': '', + 'node_modules/dep': { + 'file.ts': '', + }, + }); + + const request = 'dep/file'; + const resolved = createResolver()(request, fixture.path); + const tsResolved = await getTscResolution(request, fixture.path); + + expect(resolved?.endsWith('/file.ts')).toBeTruthy(); + expect(resolved).toBe(tsResolved.resolved); + + await fixture.rm(); + }); + + test('package.json', async () => { + const fixture = await createFixture({ + 'tsconfig.json': '', + 'node_modules/dep': { + 'package.json': JSON.stringify({}), + 'file.js': '', + }, + }); + + const request = 'dep/file.js'; + const resolved = createResolver()(request, fixture.path); + const tsResolved = await getTscResolution(request, fixture.path); + + expect(resolved?.endsWith('/file.js')).toBeTruthy(); + expect(resolved).toBe(tsResolved.resolved); + + await fixture.rm(); + }); + + test('.js -> .ts', async () => { + const fixture = await createFixture({ + 'tsconfig.json': '', + 'node_modules/dep': { + 'package.json': JSON.stringify({}), + 'file.ts': '', + }, + }); + + const request = 'dep/file.js'; + const resolved = createResolver()(request, fixture.path); + const tsResolved = await getTscResolution(request, fixture.path); + + expect(resolved?.endsWith('/file.ts')).toBeTruthy(); + expect(resolved).toBe(tsResolved.resolved); + + await fixture.rm(); + }); + + test('.mjs -> .mts', async () => { + const fixture = await createFixture({ + 'tsconfig.json': '', + 'node_modules/dep': { + 'package.json': JSON.stringify({}), + 'file.mts': '', + }, + }); + + const request = 'dep/file.mjs'; + const resolved = createResolver()(request, fixture.path); + const tsResolved = await getTscResolution(request, fixture.path); + + expect(resolved?.endsWith('/file.mts')).toBeTruthy(); + expect(resolved).toBe(tsResolved.resolved); + + await fixture.rm(); + }); + + describe('export map', ({ test }) => { + test('main', async () => { + const fixture = await createFixture({ + 'tsconfig.json': JSON.stringify({ + compilerOptions: { + moduleResolution: 'Node16', + }, + }), + 'node_modules/dep': { + 'package.json': JSON.stringify({ + exports: './file.js', + }), + 'file.js': '', + }, + }); + + const request = 'dep'; + const resolved = createResolver()(request, fixture.path); + const tsResolved = await getTscResolution(request, fixture.path); + + expect(resolved?.endsWith('/file.js')).toBeTruthy(); + expect(resolved).toBe(tsResolved.resolved); + + await fixture.rm(); + }); + + test('main - should not work without ./', async () => { + const fixture = await createFixture({ + 'tsconfig.json': JSON.stringify({ + compilerOptions: { + moduleResolution: 'Node16', + }, + }), + 'node_modules/dep': { + 'package.json': JSON.stringify({ + // Should be prefixed by ./ + exports: 'file.js', + }), + 'file.js': '', + }, + }); + + expect(() => createResolver()('dep', fixture.path)).toThrow( + 'Invalid "exports" target "file.js" defined in the package config', + ); + + await fixture.rm(); + }); + + test('export map', async () => { + const fixture = await createFixture({ + 'tsconfig.json': JSON.stringify({ + compilerOptions: { + moduleResolution: 'Node16', + }, + }), + 'node_modules/dep': { + 'package.json': JSON.stringify({ + exports: { + '.': './entry.js', + './a': './file-a.js', + './b': './file-b.js', + }, + }), + 'entry.js': '', + 'file-a.js': '', + 'file-b.js': '', + }, + }); + + const map = { + dep: '/entry.js', + 'dep/a': '/file-a.js', + 'dep/b': '/file-b.js', + }; + + for (const request in map) { + const resolved = createResolver()(request, fixture.path); + const tsResolved = await getTscResolution(request, fixture.path); + expect(resolved?.endsWith(map[request as keyof typeof map])).toBeTruthy(); + expect(resolved).toBe(tsResolved.resolved); + } + + await fixture.rm(); + }); + + test('export map with *', async () => { + const fixture = await createFixture({ + 'tsconfig.json': JSON.stringify({ + compilerOptions: { + moduleResolution: 'Node16', + }, + }), + 'node_modules/dep': { + 'package.json': JSON.stringify({ + exports: { + './file/*': './lib/*.js', + }, + }), + lib: { + 'a.js': '', + 'b.js': '', + }, + }, + }); + + const map = { + 'dep/file/a': '/lib/a.js', + 'dep/file/b': '/lib/b.js', + }; + + for (const request in map) { + const resolved = createResolver()(request, fixture.path); + const tsResolved = await getTscResolution(request, fixture.path); + expect(resolved?.endsWith(map[request as keyof typeof map])).toBeTruthy(); + expect(resolved).toBe(tsResolved.resolved); + } + + await fixture.rm(); + }); + + test('export map block', async () => { + const fixture = await createFixture({ + 'tsconfig.json': JSON.stringify({ + compilerOptions: { + moduleResolution: 'Node16', + }, + }), + 'node_modules/dep': { + 'package.json': JSON.stringify({ + exports: {}, + }), + 'a.js': '', + 'b.js': '', + }, + }); + + const files = ['dep/a', 'dep/b']; + + for (const file of files) { + const resolved = createResolver()(file, fixture.path); + const tsResolved = await getTscResolution(file, fixture.path); + expect(resolved).toBeUndefined(); + expect(resolved).toBe(tsResolved.resolved); + } + + await fixture.rm(); + }); + }); + + test('self import', async () => { + const fixture = await createFixture({ + 'tsconfig.json': JSON.stringify({ + compilerOptions: { + moduleResolution: 'Node16', + }, + }), + 'node_modules/dep': { + 'package.json': JSON.stringify({ + name: 'dep', + exports: './entry.js', + }), + 'entry.js': '', + 'node_modules/dep': { + 'package.json': JSON.stringify({ + name: 'dep', + exports: { + '.': './entry.js', + './file': './file.js', + }, + }), + 'entry.js': '', + }, + }, + }); + + const resolved = createResolver()('dep', path.join(fixture.path, 'node_modules/dep/')); + expect(resolved?.endsWith('/node_modules/dep/node_modules/dep/entry.js')).toBeTruthy(); + + await fixture.rm(); + }); + }); + }); +}); diff --git a/tests/specs/resolver/resolve-directories-package-json.ts b/tests/specs/resolver/resolve-directories-package-json.ts new file mode 100644 index 00000000..36c3cf6c --- /dev/null +++ b/tests/specs/resolver/resolve-directories-package-json.ts @@ -0,0 +1,180 @@ +import { testSuite, expect } from 'manten'; +import { createFixture } from 'fs-fixture'; +import { getTscResolution } from '../../utils'; +import { createResolver } from '#get-tsconfig'; + +export default testSuite(({ describe }) => { + describe('directories with package.json', ({ test, describe }) => { + describe('main', () => { + test('.ts -> .ts', async () => { + const fixture = await createFixture({ + 'tsconfig.json': '', + directory: { + 'package.json': JSON.stringify({ + main: 'file.ts', + }), + 'file.ts': '', + }, + }); + + const request = './directory'; + const resolved = createResolver()(request, fixture.path); + const tsResolved = await getTscResolution(request, fixture.path); + + expect(resolved?.endsWith('/file.ts')).toBeTruthy(); + expect(resolved).toBe(tsResolved.resolved); + + await fixture.rm(); + }); + + test('.js -> .js', async () => { + const fixture = await createFixture({ + 'tsconfig.json': '', + directory: { + 'package.json': JSON.stringify({ + main: 'main.js', + }), + 'main.js': '', + }, + }); + + const request = './directory'; + const resolved = createResolver()(request, fixture.path); + const tsResolved = await getTscResolution(request, fixture.path); + + expect(resolved?.endsWith('/main.js')).toBeTruthy(); + expect(resolved).toBe(tsResolved.resolved); + + await fixture.rm(); + }); + + test('.js -> ts', async () => { + const fixture = await createFixture({ + 'tsconfig.json': '', + directory: { + 'package.json': JSON.stringify({ + main: 'main.js', + }), + 'main.ts': '', + }, + }); + + const request = './directory'; + const resolved = createResolver()(request, fixture.path); + const tsResolved = await getTscResolution(request, fixture.path); + + expect(resolved?.endsWith('/main.ts')).toBeTruthy(); + expect(resolved).toBe(tsResolved.resolved); + + await fixture.rm(); + }); + + test('.js -> tsx', async () => { + const fixture = await createFixture({ + 'tsconfig.json': '', + directory: { + 'package.json': JSON.stringify({ + main: 'main.js', + }), + 'main.tsx': '', + }, + }); + + const request = './directory'; + const resolved = createResolver()(request, fixture.path); + const tsResolved = await getTscResolution(request, fixture.path); + + expect(resolved?.endsWith('/main.tsx')).toBeTruthy(); + expect(resolved).toBe(tsResolved.resolved); + + await fixture.rm(); + }); + + test('nonexistent main fallback to index', async () => { + const fixture = await createFixture({ + 'tsconfig.json': '', + directory: { + 'package.json': JSON.stringify({ + main: 'non-existent', + }), + 'index.tsx': '', + }, + }); + + const request = './directory'; + const resolved = createResolver()(request, fixture.path); + const tsResolved = await getTscResolution(request, fixture.path); + + expect(resolved?.endsWith('/index.tsx')).toBeTruthy(); + expect(resolved).toBe(tsResolved.resolved); + + await fixture.rm(); + }); + + test('relative path', async () => { + const fixture = await createFixture({ + 'tsconfig.json': '', + 'index.ts': '', + directory: { + 'package.json': JSON.stringify({ + main: '..', + }), + }, + }); + + const request = './directory'; + const resolved = createResolver()(request, fixture.path); + const tsResolved = await getTscResolution(request, fixture.path); + + expect(resolved?.endsWith('/index.ts')).toBeTruthy(); + expect(resolved).toBe(tsResolved.resolved); + + await fixture.rm(); + }); + + test('package.json to be parsed as JSONC', async () => { + const fixture = await createFixture({ + 'tsconfig.json': '', + directory: { + 'file.ts': '', + 'package.json': `{ + // comment here + "main": "file.js", // dangling comma + }`, + }, + }); + + const request = './directory'; + const resolved = createResolver()(request, fixture.path); + const tsResolved = await getTscResolution(request, fixture.path); + + expect(resolved?.endsWith('/file.ts')).toBeTruthy(); + expect(resolved).toBe(tsResolved.resolved); + + await fixture.rm(); + }); + }); + + test('export maps to not work', async () => { + const fixture = await createFixture({ + 'tsconfig.json': '', + directory: { + 'file.ts': '', + 'package.json': JSON.stringify({ + name: 'package', + exports: './file.ts', + }), + }, + }); + + const request = './directory'; + const resolved = createResolver()(request, fixture.path); + const tsResolved = await getTscResolution(request, fixture.path); + + expect(resolved).toBe(undefined); + expect(resolved).toBe(tsResolved.resolved); + + await fixture.rm(); + }); + }); +}); diff --git a/tests/specs/resolver/resolve-directories.ts b/tests/specs/resolver/resolve-directories.ts new file mode 100644 index 00000000..676ca3f6 --- /dev/null +++ b/tests/specs/resolver/resolve-directories.ts @@ -0,0 +1,72 @@ +import { testSuite, expect } from 'manten'; +import { createFixture } from 'fs-fixture'; +import { getTscResolution } from '../../utils'; +import { createResolver } from '#get-tsconfig'; + +export default testSuite(({ describe }) => { + describe('resolves directories', ({ test }) => { + test('directory -> directory/index.ts', async () => { + const fixture = await createFixture({ + 'tsconfig.json': '', + 'directory/index.ts': '', + }); + + const request = './directory'; + const resolved = createResolver()(request, fixture.path); + const tsResolved = await getTscResolution(request, fixture.path); + + expect(resolved?.endsWith('/index.ts')).toBeTruthy(); + expect(resolved).toBe(tsResolved.resolved); + + await fixture.rm(); + }); + + test('directory -> directory/index.tsx', async () => { + const fixture = await createFixture({ + 'tsconfig.json': '', + 'directory/index.tsx': '', + }); + + const request = './directory'; + const resolved = createResolver()(request, fixture.path); + const tsResolved = await getTscResolution(request, fixture.path); + + expect(resolved?.endsWith('/index.tsx')).toBeTruthy(); + expect(resolved).toBe(tsResolved.resolved); + + await fixture.rm(); + }); + + test('directory -> directory/index.js', async () => { + const fixture = await createFixture({ + 'tsconfig.json': '', + 'directory/index.js': '', + }); + + const request = './directory'; + const resolved = createResolver()(request, fixture.path); + const tsResolved = await getTscResolution(request, fixture.path); + + expect(resolved?.endsWith('/index.js')).toBeTruthy(); + expect(resolved).toBe(tsResolved.resolved); + + await fixture.rm(); + }); + + test('directory -> directory/index.jsx', async () => { + const fixture = await createFixture({ + 'tsconfig.json': '', + 'directory/index.jsx': '', + }); + + const request = './directory'; + const resolved = createResolver()(request, fixture.path); + const tsResolved = await getTscResolution(request, fixture.path); + + expect(resolved?.endsWith('/index.jsx')).toBeTruthy(); + expect(resolved).toBe(tsResolved.resolved); + + await fixture.rm(); + }); + }); +}); diff --git a/tests/specs/resolver/resolve-extensionless.ts b/tests/specs/resolver/resolve-extensionless.ts new file mode 100644 index 00000000..f1de1fb2 --- /dev/null +++ b/tests/specs/resolver/resolve-extensionless.ts @@ -0,0 +1,55 @@ +import { testSuite, expect } from 'manten'; +import { createFixture } from 'fs-fixture'; +import { getTscResolution } from '../../utils'; +import { createResolver } from '#get-tsconfig'; + +export default testSuite(({ describe }) => { + describe('resolves extensionless', ({ test }) => { + test('resolve .ts', async () => { + const fixture = await createFixture({ + 'tsconfig.json': '', + 'some-file.ts': '', + }); + + const request = './some-file'; + const resolved = createResolver()(request, fixture.path); + const tsResolved = await getTscResolution(request, fixture.path); + + expect(resolved).toBe(tsResolved.resolved); + + await fixture.rm(); + }); + + test('resolve .tsx', async () => { + const fixture = await createFixture({ + 'tsconfig.json': '', + 'some-file.tsx': '', + }); + + const request = './some-file'; + const resolved = createResolver()(request, fixture.path); + const tsResolved = await getTscResolution(request, fixture.path); + + expect(resolved).toBe(tsResolved.resolved); + + await fixture.rm(); + }); + + test('combination', async () => { + const fixture = await createFixture({ + 'tsconfig.json': '', + 'some-file': '', + 'some-file.ts': '', + 'some-file.tsx': '', + }); + + const request = './some-file'; + const resolved = createResolver()(request, fixture.path); + const tsResolved = await getTscResolution(request, fixture.path); + + expect(resolved).toBe(tsResolved.resolved); + + await fixture.rm(); + }); + }); +}); diff --git a/tests/specs/resolver/resolve-js-jsx.ts b/tests/specs/resolver/resolve-js-jsx.ts new file mode 100644 index 00000000..f42e6c47 --- /dev/null +++ b/tests/specs/resolver/resolve-js-jsx.ts @@ -0,0 +1,195 @@ +import { testSuite, expect } from 'manten'; +import { createFixture } from 'fs-fixture'; +import { getTscResolution } from '../../utils'; +import { createResolver } from '#get-tsconfig'; + +export default testSuite(({ describe }) => { + describe('.js & .jsx', ({ describe }) => { + describe('append TS extensions', ({ test }) => { + test('resolve .js -> .js.ts', async () => { + const fixture = await createFixture({ + 'tsconfig.json': '', + 'some-file.js.ts': '', + }); + + const request = './some-file.js'; + const resolved = createResolver()(request, fixture.path); + const tsResolved = await getTscResolution(request, fixture.path); + + expect(resolved?.endsWith('/some-file.js.ts')).toBeTruthy(); + expect(resolved).toBe(tsResolved.resolved); + + await fixture.rm(); + }); + + test('resolve .js -> .js.tsx', async () => { + const fixture = await createFixture({ + 'tsconfig.json': '', + 'some-file.js.tsx': '', + }); + + const request = './some-file.js'; + const resolved = createResolver()(request, fixture.path); + const tsResolved = await getTscResolution(request, fixture.path); + + expect(resolved?.endsWith('/some-file.js.tsx')).toBeTruthy(); + expect(resolved).toBe(tsResolved.resolved); + + await fixture.rm(); + }); + }); + + describe('resolves TS counter part', ({ test }) => { + test('.js -> .ts', async () => { + const fixture = await createFixture({ + 'tsconfig.json': '', + 'some-file.ts': '', + }); + + const request = './some-file.js'; + const resolved = createResolver()(request, fixture.path); + const tsResolved = await getTscResolution(request, fixture.path); + + expect(resolved?.endsWith('/some-file.ts')).toBeTruthy(); + expect(resolved).toBe(tsResolved.resolved); + + await fixture.rm(); + }); + + test('.jsx -> .ts', async () => { + const fixture = await createFixture({ + 'tsconfig.json': '', + 'some-file.ts': '', + }); + + const request = './some-file.jsx'; + const resolved = createResolver()(request, fixture.path); + const tsResolved = await getTscResolution(request, fixture.path); + + expect(resolved?.endsWith('/some-file.ts')).toBeTruthy(); + expect(resolved).toBe(tsResolved.resolved); + + await fixture.rm(); + }); + + test('.js -> .tsx', async () => { + const fixture = await createFixture({ + 'tsconfig.json': '', + 'some-file.tsx': '', + }); + + const request = './some-file.js'; + const resolved = createResolver()(request, fixture.path); + const tsResolved = await getTscResolution(request, fixture.path); + + expect(resolved?.endsWith('/some-file.tsx')).toBeTruthy(); + expect(resolved).toBe(tsResolved.resolved); + + await fixture.rm(); + }); + + test('.jsx -> .tsx', async () => { + const fixture = await createFixture({ + 'tsconfig.json': '', + 'some-file.tsx': '', + }); + + const request = './some-file.jsx'; + const resolved = createResolver()(request, fixture.path); + const tsResolved = await getTscResolution(request, fixture.path); + + expect(resolved?.endsWith('/some-file.tsx')).toBeTruthy(); + expect(resolved).toBe(tsResolved.resolved); + + await fixture.rm(); + }); + }); + + describe('resolves as JS', ({ test }) => { + test('.js', async () => { + const fixture = await createFixture({ + 'tsconfig.json': '', + 'some-file.js': '', + }); + + const request = './some-file.js'; + const resolved = createResolver()(request, fixture.path); + const tsResolved = await getTscResolution(request, fixture.path); + + expect(resolved?.endsWith('/some-file.js')).toBeTruthy(); + expect(resolved).toBe(tsResolved.resolved); + + await fixture.rm(); + }); + + test('.jsx', async () => { + const fixture = await createFixture({ + 'tsconfig.json': '', + 'some-file.jsx': '', + }); + + const request = './some-file.jsx'; + const resolved = createResolver()(request, fixture.path); + const tsResolved = await getTscResolution(request, fixture.path); + + expect(resolved?.endsWith('/some-file.jsx')).toBeTruthy(); + expect(resolved).toBe(tsResolved.resolved); + + await fixture.rm(); + }); + }); + + describe('combination', ({ test }) => { + test('.js.ts over .ts', async () => { + const fixture = await createFixture({ + 'tsconfig.json': '', + 'some-file.js.ts': '', + 'some-file.ts': '', + }); + + const request = './some-file.js'; + const resolved = createResolver()(request, fixture.path); + const tsResolved = await getTscResolution(request, fixture.path); + + expect(resolved?.endsWith('/some-file.js.ts')).toBeTruthy(); + expect(resolved).toBe(tsResolved.resolved); + + await fixture.rm(); + }); + + test('.ts over .js', async () => { + const fixture = await createFixture({ + 'tsconfig.json': '', + 'some-file.js': '', + 'some-file.ts': '', + }); + + const request = './some-file.js'; + const resolved = createResolver()(request, fixture.path); + const tsResolved = await getTscResolution(request, fixture.path); + + expect(resolved?.endsWith('/some-file.ts')).toBeTruthy(); + expect(resolved).toBe(tsResolved.resolved); + + await fixture.rm(); + }); + + test('.ts over .tsx', async () => { + const fixture = await createFixture({ + 'tsconfig.json': '', + 'some-file.ts': '', + 'some-file.tsx': '', + }); + + const request = './some-file.js'; + const resolved = createResolver()(request, fixture.path); + const tsResolved = await getTscResolution(request, fixture.path); + + expect(resolved?.endsWith('/some-file.ts')).toBeTruthy(); + expect(resolved).toBe(tsResolved.resolved); + + await fixture.rm(); + }); + }); + }); +}); diff --git a/tests/tsc-experiment.ts b/tests/tsc-experiment.ts new file mode 100644 index 00000000..64c84fcd --- /dev/null +++ b/tests/tsc-experiment.ts @@ -0,0 +1,57 @@ +import path from 'path'; +import fs from 'fs/promises'; +import { createFixture } from 'fs-fixture'; +import { execa } from 'execa'; + +const tscPath = path.resolve('node_modules/.bin/tsc'); + +const randomId = () => Math.random().toString(36).slice(2); + +async function tscResolve( + request: string, + fixturePath: string, +) { + const filePath = path.join(fixturePath, `${randomId()}.ts`); + await fs.writeFile( + filePath, + `import '${request}'`, + ); + const { stdout } = await execa( + tscPath, + [ + '--traceResolution', + '--noEmit', + ], + { cwd: fixturePath }, + ); + + await fs.rm(filePath, { + force: true, + }); + + return stdout; +} + +(async () => { + const fixture = await createFixture({ + 'tsconfig.json': JSON.stringify({ + compilerOptions: { + moduleResolution: 'Node16', + }, + }), + node_modules: { + 'dep': { + 'package.json': JSON.stringify({ + exports: ['./missing.ts', './asdf.ts'], + }), + 'asdf.ts': '', + }, + }, + }); + + const stdout = await tscResolve('dep', fixture.path); + + console.log(stdout); + + await fixture.rm(); +})(); diff --git a/tests/utils.ts b/tests/utils.ts index b1f953c7..d65363f3 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -27,7 +27,9 @@ export async function getTscTsconfig( return JSON.parse(tscProcess.stdout); } -const resolveAttemptPattern = /^(File|Directory) '(.+)'/gm; +const resolveAttemptPattern = /^(File|Directory) '(.+)' (exist|does not exist).*?(use it as a name resolution result\.)?$/gm; + +const resolvedPattern = /successfully resolved to '(.+)'/; const divider = '='.repeat(8); @@ -35,15 +37,36 @@ async function parseTscResolve( stdout: string, request: string, ) { - const resolveLog = stdout.slice( + const logStartIndex = stdout.indexOf( + '\n', stdout.indexOf(`${divider} Resolving module '${request}'`), - stdout.indexOf(`${divider} Module name '${request}'`), + ) + 1; + + const logEndIndex = stdout.indexOf(`${divider} Module name `, logStartIndex); + const resolvedToStartIndex = logEndIndex + divider.length; + const resolvedToEndIndex = stdout.indexOf(divider, resolvedToStartIndex); + + const resolveLog = stdout.slice( + logStartIndex, + logEndIndex, ); + const resolvedToMessage = stdout.slice(resolvedToStartIndex, resolvedToEndIndex); + const resolvedResult = resolvedToMessage.match(resolvedPattern); + const resolved = resolvedResult?.[1]; const resolveAttempts = resolveLog.matchAll(resolveAttemptPattern); - - return Array.from(resolveAttempts).map(( - [, type, filePath], - ) => ({ type, filePath })); + const attempts = Array.from(resolveAttempts).map(( + [, type, filePath, exists, resolved], + ) => ({ + type, + filePath, + exists: exists === 'exist', + resolved: Boolean(resolved), + })); + + return { + resolved, + attempts, + }; } export async function getTscResolution(