Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 55 additions & 1 deletion packages/nx/src/plugins/js/utils/register.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { MockInstance } from 'vitest';
import type { Mock, MockInstance } from 'vitest';
import type { CompilerOptions } from 'typescript';
import { JsxEmit, ModuleKind, ScriptTarget } from 'typescript';
import { join } from 'path';
import { TempFs } from '../../../internal-testing-utils/temp-fs';
import {
getTranspiler,
getTsNodeCompilerOptions,
Expand All @@ -11,6 +13,7 @@ import {
isTsEsmSyntaxError,
NODENEXT_ESM_RESOLVER_SOURCE,
nodeNextEsmResolveHook,
registerTsConfigPaths,
resolveTsNodeEsmCompilerOptions,
} from './register';

Expand All @@ -21,6 +24,7 @@ import { createRequire, Module } from 'node:module';
import {
mockCjsModule,
resetCjsMocks,
unmockCjsModule,
} from '../../../internal-testing-utils/cjs-mock';
{
const req = createRequire(import.meta.url);
Expand Down Expand Up @@ -688,3 +692,53 @@ new Function('s', 'return import(s)')(process.argv[3]).then(
expect(result).toEqual({ ok: true, url: configUrl, kind: 1 });
}, 60_000);
});

describe('registerTsConfigPaths', () => {
let tempFs: TempFs;
let registerPaths: Mock;

beforeEach(() => {
tempFs = new TempFs('register-ts-config-paths', false);
// register.ts lazy-requires tsconfig-paths (CJS channel); replace it there.
// Stubbing `register` captures the baseUrl without installing a resolver hook.
registerPaths = vi.fn(() => () => {});
mockCjsModule(import.meta.url, 'tsconfig-paths', {
...require('tsconfig-paths'),
register: registerPaths,
});
});

afterEach(() => {
unmockCjsModule(import.meta.url, 'tsconfig-paths');
tempFs.cleanup();
});

it('should resolve the baseUrl through an extends chain containing JSONC', () => {
tempFs.createFileSync(
'tsconfig.base.json',
JSON.stringify({
compilerOptions: {
baseUrl: '.',
paths: { '@lib/*': ['libs/*/src/index.ts'] },
},
})
);
tempFs.createFileSync(
'project/tsconfig.json',
`{
"extends": "../tsconfig.base.json",
/* a block comment */
"compilerOptions": {
// a line comment
"strictPropertyInitialization": false,
},
}`
);

registerTsConfigPaths(join(tempFs.tempDir, 'project', 'tsconfig.json'));

expect(registerPaths).toHaveBeenCalledWith(
expect.objectContaining({ baseUrl: tempFs.tempDir })
);
});
});
5 changes: 3 additions & 2 deletions packages/nx/src/plugins/js/utils/register.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { dirname, isAbsolute, join, resolve, sep } from 'path';
import { existsSync, readFileSync } from 'fs';
import { existsSync } from 'fs';
import type { TsConfigOptions } from 'ts-node';
import type { CompilerOptions } from 'typescript';
import { readJsonFile } from '../../../utils/fileutils';
import { logger, NX_PREFIX, stripIndent } from '../../../utils/logger';
import { workspaceRoot } from '../../../utils/workspace-root';
import { getRootTsConfigPath, readTsConfigWithoutFiles } from './typescript';
Expand Down Expand Up @@ -1427,7 +1428,7 @@ function resolvePathsBaseUrl(tsconfigPath: string): string {
const absolute = resolve(queue.shift()!);
const dir = dirname(absolute);
try {
const raw = JSON.parse(readFileSync(absolute, 'utf-8'));
const raw = readJsonFile(absolute);
chain.push({ dir, raw });
const exts: string[] = raw.extends
? Array.isArray(raw.extends)
Expand Down
196 changes: 196 additions & 0 deletions packages/vite/plugins/nx-tsconfig-paths.plugin.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import { TempFs } from '@nx/devkit/internal-testing-utils';
import { join } from 'node:path';
import * as ts from 'typescript';

// `var` rather than `let`: transitive imports read `workspaceRoot` while the
// module graph is still loading, before a `let` would leave its temporal dead
// zone.
var workspaceRootMock: string | undefined;
jest.mock('@nx/devkit', () => {
const actual = jest.requireActual('@nx/devkit');
return {
...actual,
get workspaceRoot() {
return workspaceRootMock ?? actual.workspaceRoot;
},
};
});

var failRootTsConfigLoad = false;
jest.mock('tsconfig-paths', () => {
const actual = jest.requireActual('tsconfig-paths');
return {
...actual,
loadConfig: (path?: string) =>
failRootTsConfigLoad && path?.endsWith('tsconfig.base.json')
? { resultType: 'failed', message: "Couldn't find tsconfig.json" }
: actual.loadConfig(path),
};
});

import { nxViteTsPaths } from './nx-tsconfig-paths.plugin';

describe('nxViteTsPaths', () => {
let tempFs: TempFs;
let originalTsConfigPath: string | undefined;

beforeEach(() => {
tempFs = new TempFs('nx-vite-ts-paths');
workspaceRootMock = tempFs.tempDir;
originalTsConfigPath = process.env.NX_TSCONFIG_PATH;
failRootTsConfigLoad = false;
});

afterEach(() => {
if (originalTsConfigPath === undefined) {
delete process.env.NX_TSCONFIG_PATH;
} else {
process.env.NX_TSCONFIG_PATH = originalTsConfigPath;
}
tempFs.cleanup();
});

const resolveWith = async (importPath: string) => {
const plugin = nxViteTsPaths();
await (plugin as any).configResolved({
root: join(tempFs.tempDir, 'app'),
command: 'build',
plugins: [],
});
return (plugin as any).resolveId(importPath);
};

const withProjectTsConfigOutsideWorkspace = async () => {
await tempFs.createFiles({
'external/tsconfig.json': JSON.stringify({
compilerOptions: { baseUrl: '.', paths: { '@ext/*': ['libs/*'] } },
}),
'app/src/main.ts': '',
});
process.env.NX_TSCONFIG_PATH = join(
tempFs.tempDir,
'external/tsconfig.json'
);
};

it('should defer to other resolvers when the workspace has no root-level tsconfig', async () => {
await withProjectTsConfigOutsideWorkspace();

await expect(resolveWith('@nope/missing')).resolves.toBeNull();
});

it('should defer to other resolvers when the root-level tsconfig cannot be loaded', async () => {
await withProjectTsConfigOutsideWorkspace();
await tempFs.createFiles({ 'tsconfig.base.json': JSON.stringify({}) });
failRootTsConfigLoad = true;

await expect(resolveWith('@nope/missing')).resolves.toBeNull();
});

it('should resolve a workspace alias through the root-level tsconfig', async () => {
await tempFs.createFiles({
'tsconfig.base.json': JSON.stringify({
compilerOptions: {
baseUrl: '.',
paths: { '@repo/util': ['libs/util/index.ts'] },
},
}),
'libs/util/index.ts': '',
'app/src/main.ts': '',
});

await expect(resolveWith('@repo/util')).resolves.toEqual(
join(tempFs.tempDir, 'libs/util/index.ts')
);
});

it('should substitute the wildcard of a mapped path pointing at a directory', async () => {
await tempFs.createFiles({
'tsconfig.base.json': JSON.stringify({
compilerOptions: {
baseUrl: '.',
paths: { '@lib/*': ['packages/*/src'] },
},
}),
'packages/one/src/index.ts': '',
'app/src/main.ts': '',
});

await expect(resolveWith('@lib/one')).resolves.toEqual(
join(tempFs.tempDir, 'packages/one/src/index.ts')
);
});

it('should resolve paths inherited through extends against the tsconfig that declares them', async () => {
// No `tsconfig.base.json`: the root-level lookup falls back to the
// project tsconfig, so both resolution passes share its directory and
// nothing masks a base taken from the leaf.
await tempFs.createFiles({
'tsconfig.json': JSON.stringify({
compilerOptions: { paths: { '@repo/util/*': ['libs/util/*'] } },
}),
'app/tsconfig.json': JSON.stringify({ extends: '../tsconfig.json' }),
'libs/util/foo.ts': '',
'app/src/main.ts': '',
});

await expect(resolveWith('@repo/util/foo')).resolves.toEqual(
join(tempFs.tempDir, 'libs/util/foo.ts')
);
});

describe('when more than one alias resolves', () => {
const exact = { '@repo/exact': ['packages/exact'] };
const wildcard = { '@repo/*': ['generic/*'] };

const resolveWithTypeScript = (
paths: Record<string, string[]>,
moduleResolution: ts.ModuleResolutionKind,
module: ts.ModuleKind
) =>
ts.resolveModuleName(
'@repo/exact',
join(tempFs.tempDir, 'app/src/main.ts'),
{ baseUrl: tempFs.tempDir, paths, module, moduleResolution },
ts.sys
).resolvedModule?.resolvedFileName;

it.each([
['the exact alias is declared first', { ...exact, ...wildcard }],
['the wildcard alias is declared first', { ...wildcard, ...exact }],
])('should pick the alias TypeScript picks when %s', async (_, paths) => {
await tempFs.createFiles({
'tsconfig.base.json': JSON.stringify({
compilerOptions: { baseUrl: '.', paths },
}),
'packages/exact/index.ts': '',
'generic/exact.ts': '',
'app/src/main.ts': '',
});
const expected = join(tempFs.tempDir, 'packages/exact/index.ts');

expect(
resolveWithTypeScript(
paths,
ts.ModuleResolutionKind.Bundler,
ts.ModuleKind.ESNext
)
).toEqual(expected);
expect(
resolveWithTypeScript(
paths,
ts.ModuleResolutionKind.NodeNext,
ts.ModuleKind.NodeNext
)
).toEqual(expected);
expect(
resolveWithTypeScript(
paths,
ts.ModuleResolutionKind.Node10,
ts.ModuleKind.CommonJS
)
).toEqual(expected);
await expect(resolveWith('@repo/exact')).resolves.toEqual(expected);
});
});
});
Loading
Loading