From ece352f79195ef47c38e24938989f4ca29ea334a Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Mon, 13 Feb 2023 12:01:54 -0500 Subject: [PATCH] TS 5.0 Supporting Multiple Configuration Files in extends (#1958) * extract tsconfig tests to new spec file; add failing test for "extends" from multiple configs * Add support for `"extends": []` extending from multiple configs * Fix tests * fix test * fix test on windows * fmt * fix --- src/configuration.ts | 90 +++++---- .../tsnode-opts-from-tsconfig.spec.ts | 181 ++++++++++++++++++ src/test/helpers/version-checks.ts | 2 + src/test/index.spec.ts | 114 ----------- .../a/require-hook-from-a.js | 0 .../tsconfig-extends-multiple/a/tsconfig.json | 10 + .../tsconfig-extends-multiple/b/tsconfig.json | 12 ++ .../tsconfig-extends-multiple/c/tsconfig.json | 10 + .../tsconfig-extends-multiple/d/tsconfig.json | 8 + tests/tsconfig-extends-multiple/empty.ts | 0 .../node_modules/transpiler-from-c/index.js | 0 tests/tsconfig-extends-multiple/tsconfig.json | 6 + 12 files changed, 283 insertions(+), 150 deletions(-) create mode 100644 src/test/configuration/tsnode-opts-from-tsconfig.spec.ts create mode 100644 tests/tsconfig-extends-multiple/a/require-hook-from-a.js create mode 100644 tests/tsconfig-extends-multiple/a/tsconfig.json create mode 100644 tests/tsconfig-extends-multiple/b/tsconfig.json create mode 100644 tests/tsconfig-extends-multiple/c/tsconfig.json create mode 100644 tests/tsconfig-extends-multiple/d/tsconfig.json create mode 100644 tests/tsconfig-extends-multiple/empty.ts create mode 100644 tests/tsconfig-extends-multiple/node_modules/transpiler-from-c/index.js create mode 100644 tests/tsconfig-extends-multiple/tsconfig.json diff --git a/src/configuration.ts b/src/configuration.ts index 4ab0a7ccf..c59536986 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -154,7 +154,7 @@ export function readConfig( }> = []; let config: any = { compilerOptions: {} }; let basePath = cwd; - let configFilePath: string | undefined = undefined; + let rootConfigPath: string | undefined = undefined; const projectSearchDir = resolve(cwd, rawApiOptions.projectSearchDir ?? cwd); const { @@ -170,24 +170,31 @@ export function readConfig( if (project) { const resolved = resolve(cwd, project); const nested = join(resolved, 'tsconfig.json'); - configFilePath = fileExists(nested) ? nested : resolved; + rootConfigPath = fileExists(nested) ? nested : resolved; } else { - configFilePath = ts.findConfigFile(projectSearchDir, fileExists); + rootConfigPath = ts.findConfigFile(projectSearchDir, fileExists); } - if (configFilePath) { - let pathToNextConfigInChain = configFilePath; + if (rootConfigPath) { + // If root extends [a, c] and a extends b, c extends d, then this array will look like: + // [root, c, d, a, b] + let configPaths = [rootConfigPath]; const tsInternals = createTsInternals(ts); const errors: Array<_ts.Diagnostic> = []; // Follow chain of "extends" - while (true) { - const result = ts.readConfigFile(pathToNextConfigInChain, readFile); + for ( + let configPathIndex = 0; + configPathIndex < configPaths.length; + configPathIndex++ + ) { + const configPath = configPaths[configPathIndex]; + const result = ts.readConfigFile(configPath, readFile); // Return diagnostics. if (result.error) { return { - configFilePath, + configFilePath: rootConfigPath, config: { errors: [result.error], fileNames: [], options: {} }, tsNodeOptionsFromTsconfig: {}, optionBasePaths: {}, @@ -195,37 +202,48 @@ export function readConfig( } const c = result.config; - const bp = dirname(pathToNextConfigInChain); + const bp = dirname(configPath); configChain.push({ config: c, basePath: bp, - configPath: pathToNextConfigInChain, + configPath: configPath, }); - if (c.extends == null) break; - const resolvedExtendedConfigPath = tsInternals.getExtendsConfigPath( - c.extends, - { - fileExists, - readDirectory: ts.sys.readDirectory, - readFile, - useCaseSensitiveFileNames: ts.sys.useCaseSensitiveFileNames, - trace: tsTrace, - }, - bp, - errors, - (ts as unknown as TSInternal).createCompilerDiagnostic - ); - if (errors.length) { - return { - configFilePath, - config: { errors, fileNames: [], options: {} }, - tsNodeOptionsFromTsconfig: {}, - optionBasePaths: {}, - }; + if (c.extends == null) continue; + const extendsArray = Array.isArray(c.extends) ? c.extends : [c.extends]; + for (const e of extendsArray) { + const resolvedExtendedConfigPath = tsInternals.getExtendsConfigPath( + e, + { + fileExists, + readDirectory: ts.sys.readDirectory, + readFile, + useCaseSensitiveFileNames: ts.sys.useCaseSensitiveFileNames, + trace: tsTrace, + }, + bp, + errors, + (ts as unknown as TSInternal).createCompilerDiagnostic + ); + if (errors.length) { + return { + configFilePath: rootConfigPath, + config: { errors, fileNames: [], options: {} }, + tsNodeOptionsFromTsconfig: {}, + optionBasePaths: {}, + }; + } + if (resolvedExtendedConfigPath != null) { + // Tricky! If "extends" array is [a, c] then this will splice them into this order: + // [root, c, a] + // This is what we want. + configPaths.splice( + configPathIndex + 1, + 0, + resolvedExtendedConfigPath + ); + } } - if (resolvedExtendedConfigPath == null) break; - pathToNextConfigInChain = resolvedExtendedConfigPath; } ({ config, basePath } = configChain[0]); @@ -277,7 +295,7 @@ export function readConfig( rawApiOptions.files ?? tsNodeOptionsFromTsconfig.files ?? DEFAULTS.files; // Only if a config file is *not* loaded, load an implicit configuration from @tsconfig/bases - const skipDefaultCompilerOptions = configFilePath != null; + const skipDefaultCompilerOptions = rootConfigPath != null; const defaultCompilerOptionsForNodeVersion = skipDefaultCompilerOptions ? undefined : { @@ -316,12 +334,12 @@ export function readConfig( }, basePath, undefined, - configFilePath + rootConfigPath ) ); return { - configFilePath, + configFilePath: rootConfigPath, config: fixedConfig, tsNodeOptionsFromTsconfig, optionBasePaths, diff --git a/src/test/configuration/tsnode-opts-from-tsconfig.spec.ts b/src/test/configuration/tsnode-opts-from-tsconfig.spec.ts new file mode 100644 index 000000000..641930062 --- /dev/null +++ b/src/test/configuration/tsnode-opts-from-tsconfig.spec.ts @@ -0,0 +1,181 @@ +import { BIN_PATH } from '../helpers/paths'; +import { createExec } from '../exec-helpers'; +import { TEST_DIR } from '../helpers/paths'; +import { context, expect } from '../testlib'; +import { join, resolve } from 'path'; +import { tsSupportsExtendsArray } from '../helpers/version-checks'; +import { ctxTsNode } from '../helpers'; + +const test = context(ctxTsNode); + +const exec = createExec({ + cwd: TEST_DIR, +}); + +test.suite('should read ts-node options from tsconfig.json', (test) => { + const BIN_EXEC = `"${BIN_PATH}" --project tsconfig-options/tsconfig.json`; + + test('should override compiler options from env', async () => { + const r = await exec(`${BIN_EXEC} tsconfig-options/log-options1.js`, { + env: { + ...process.env, + TS_NODE_COMPILER_OPTIONS: '{"typeRoots": ["env-typeroots"]}', + }, + }); + expect(r.err).toBe(null); + const { config } = JSON.parse(r.stdout); + expect(config.options.typeRoots).toEqual([ + join(TEST_DIR, './tsconfig-options/env-typeroots').replace(/\\/g, '/'), + ]); + }); + + test('should use options from `tsconfig.json`', async () => { + const r = await exec(`${BIN_EXEC} tsconfig-options/log-options1.js`); + expect(r.err).toBe(null); + const { options, config } = JSON.parse(r.stdout); + expect(config.options.typeRoots).toEqual([ + join(TEST_DIR, './tsconfig-options/tsconfig-typeroots').replace( + /\\/g, + '/' + ), + ]); + expect(config.options.types).toEqual(['tsconfig-tsnode-types']); + expect(options.pretty).toBe(undefined); + expect(options.skipIgnore).toBe(false); + expect(options.transpileOnly).toBe(true); + expect(options.require).toEqual([ + join(TEST_DIR, './tsconfig-options/required1.js'), + ]); + }); + + test('should ignore empty strings in the array options', async () => { + const r = await exec(`${BIN_EXEC} tsconfig-options/log-options1.js`, { + env: { + ...process.env, + TS_NODE_IGNORE: '', + }, + }); + expect(r.err).toBe(null); + const { options } = JSON.parse(r.stdout); + expect(options.ignore).toEqual([]); + }); + + test('should have flags override / merge with `tsconfig.json`', async () => { + const r = await exec( + `${BIN_EXEC} --skip-ignore --compiler-options "{\\"types\\":[\\"flags-types\\"]}" --require ./tsconfig-options/required2.js tsconfig-options/log-options2.js` + ); + expect(r.err).toBe(null); + const { options, config } = JSON.parse(r.stdout); + expect(config.options.typeRoots).toEqual([ + join(TEST_DIR, './tsconfig-options/tsconfig-typeroots').replace( + /\\/g, + '/' + ), + ]); + expect(config.options.types).toEqual(['flags-types']); + expect(options.pretty).toBe(undefined); + expect(options.skipIgnore).toBe(true); + expect(options.transpileOnly).toBe(true); + expect(options.require).toEqual([ + join(TEST_DIR, './tsconfig-options/required1.js'), + './tsconfig-options/required2.js', + ]); + }); + + test('should have `tsconfig.json` override environment', async () => { + const r = await exec(`${BIN_EXEC} tsconfig-options/log-options1.js`, { + env: { + ...process.env, + TS_NODE_PRETTY: 'true', + TS_NODE_SKIP_IGNORE: 'true', + }, + }); + expect(r.err).toBe(null); + const { options, config } = JSON.parse(r.stdout); + expect(config.options.typeRoots).toEqual([ + join(TEST_DIR, './tsconfig-options/tsconfig-typeroots').replace( + /\\/g, + '/' + ), + ]); + expect(config.options.types).toEqual(['tsconfig-tsnode-types']); + expect(options.pretty).toBe(true); + expect(options.skipIgnore).toBe(false); + expect(options.transpileOnly).toBe(true); + expect(options.require).toEqual([ + join(TEST_DIR, './tsconfig-options/required1.js'), + ]); + }); + + test('should pull ts-node options from extended `tsconfig.json`', async () => { + const r = await exec( + `${BIN_PATH} --show-config --project ./tsconfig-extends/tsconfig.json` + ); + expect(r.err).toBe(null); + const config = JSON.parse(r.stdout); + expect(config['ts-node'].require).toEqual([ + resolve(TEST_DIR, 'tsconfig-extends/other/require-hook.js'), + ]); + expect(config['ts-node'].scopeDir).toBe( + resolve(TEST_DIR, 'tsconfig-extends/other/scopedir') + ); + expect(config['ts-node'].preferTsExts).toBe(true); + }); + + test.suite( + 'should pull ts-node options from extended `tsconfig.json`', + (test) => { + test.if(tsSupportsExtendsArray); + test('test', async () => { + const r = await exec( + `${BIN_PATH} --show-config --project ./tsconfig-extends-multiple/tsconfig.json` + ); + expect(r.err).toBe(null); + const config = JSON.parse(r.stdout); + + // root tsconfig extends [a, c] + // a extends b + // c extends d + + // https://devblogs.microsoft.com/typescript/announcing-typescript-5-0-beta/#supporting-multiple-configuration-files-in-extends + // If any fields "conflict", the latter entry wins. + + // This value comes from c + expect(config.compilerOptions.target).toBe('es2017'); + + // From root + expect(config['ts-node'].preferTsExts).toBe(true); + + // From a + expect(config['ts-node'].require).toEqual([ + resolve( + TEST_DIR, + 'tsconfig-extends-multiple/a/require-hook-from-a.js' + ), + ]); + + // From a, overrides declaration in b + expect(config['ts-node'].scopeDir).toBe( + resolve(TEST_DIR, 'tsconfig-extends-multiple/a/scopedir-from-a') + ); + + // From b + const key = + process.platform === 'win32' + ? 'b\\module-types-from-b' + : 'b/module-types-from-b'; + expect(config['ts-node'].moduleTypes).toStrictEqual({ + [key]: 'cjs', + }); + + // From c, overrides declaration in b + expect(config['ts-node'].transpiler).toBe('transpiler-from-c'); + + // From d, inherited by c, overrides value from b + expect(config['ts-node'].ignore).toStrictEqual([ + 'ignore-pattern-from-d', + ]); + }); + } + ); +}); diff --git a/src/test/helpers/version-checks.ts b/src/test/helpers/version-checks.ts index a783158ad..268a58426 100644 --- a/src/test/helpers/version-checks.ts +++ b/src/test/helpers/version-checks.ts @@ -38,6 +38,8 @@ export const tsSupportsAllowImportingTsExtensions = semver.gte( ts.version, '4.999.999' ); +// TS 5.0 adds ability for tsconfig to `"extends": []` an array of configs +export const tsSupportsExtendsArray = semver.gte(ts.version, '4.999.999'); // Relevant when @tsconfig/bases refers to es2021 and we run tests against // old TS versions. export const tsSupportsEs2021 = semver.gte(ts.version, '4.3.0'); diff --git a/src/test/index.spec.ts b/src/test/index.spec.ts index 5ba5f7f31..ef219534f 100644 --- a/src/test/index.spec.ts +++ b/src/test/index.spec.ts @@ -594,120 +594,6 @@ test.suite('ts-node', (test) => { expect(r.stderr).toBe(''); }); - test.suite('should read ts-node options from tsconfig.json', (test) => { - const BIN_EXEC = `"${BIN_PATH}" --project tsconfig-options/tsconfig.json`; - - test('should override compiler options from env', async () => { - const r = await exec(`${BIN_EXEC} tsconfig-options/log-options1.js`, { - env: { - ...process.env, - TS_NODE_COMPILER_OPTIONS: '{"typeRoots": ["env-typeroots"]}', - }, - }); - expect(r.err).toBe(null); - const { config } = JSON.parse(r.stdout); - expect(config.options.typeRoots).toEqual([ - join(TEST_DIR, './tsconfig-options/env-typeroots').replace( - /\\/g, - '/' - ), - ]); - }); - - test('should use options from `tsconfig.json`', async () => { - const r = await exec(`${BIN_EXEC} tsconfig-options/log-options1.js`); - expect(r.err).toBe(null); - const { options, config } = JSON.parse(r.stdout); - expect(config.options.typeRoots).toEqual([ - join(TEST_DIR, './tsconfig-options/tsconfig-typeroots').replace( - /\\/g, - '/' - ), - ]); - expect(config.options.types).toEqual(['tsconfig-tsnode-types']); - expect(options.pretty).toBe(undefined); - expect(options.skipIgnore).toBe(false); - expect(options.transpileOnly).toBe(true); - expect(options.require).toEqual([ - join(TEST_DIR, './tsconfig-options/required1.js'), - ]); - }); - - test('should ignore empty strings in the array options', async () => { - const r = await exec(`${BIN_EXEC} tsconfig-options/log-options1.js`, { - env: { - ...process.env, - TS_NODE_IGNORE: '', - }, - }); - expect(r.err).toBe(null); - const { options } = JSON.parse(r.stdout); - expect(options.ignore).toEqual([]); - }); - - test('should have flags override / merge with `tsconfig.json`', async () => { - const r = await exec( - `${BIN_EXEC} --skip-ignore --compiler-options "{\\"types\\":[\\"flags-types\\"]}" --require ./tsconfig-options/required2.js tsconfig-options/log-options2.js` - ); - expect(r.err).toBe(null); - const { options, config } = JSON.parse(r.stdout); - expect(config.options.typeRoots).toEqual([ - join(TEST_DIR, './tsconfig-options/tsconfig-typeroots').replace( - /\\/g, - '/' - ), - ]); - expect(config.options.types).toEqual(['flags-types']); - expect(options.pretty).toBe(undefined); - expect(options.skipIgnore).toBe(true); - expect(options.transpileOnly).toBe(true); - expect(options.require).toEqual([ - join(TEST_DIR, './tsconfig-options/required1.js'), - './tsconfig-options/required2.js', - ]); - }); - - test('should have `tsconfig.json` override environment', async () => { - const r = await exec(`${BIN_EXEC} tsconfig-options/log-options1.js`, { - env: { - ...process.env, - TS_NODE_PRETTY: 'true', - TS_NODE_SKIP_IGNORE: 'true', - }, - }); - expect(r.err).toBe(null); - const { options, config } = JSON.parse(r.stdout); - expect(config.options.typeRoots).toEqual([ - join(TEST_DIR, './tsconfig-options/tsconfig-typeroots').replace( - /\\/g, - '/' - ), - ]); - expect(config.options.types).toEqual(['tsconfig-tsnode-types']); - expect(options.pretty).toBe(true); - expect(options.skipIgnore).toBe(false); - expect(options.transpileOnly).toBe(true); - expect(options.require).toEqual([ - join(TEST_DIR, './tsconfig-options/required1.js'), - ]); - }); - - test('should pull ts-node options from extended `tsconfig.json`', async () => { - const r = await exec( - `${BIN_PATH} --show-config --project ./tsconfig-extends/tsconfig.json` - ); - expect(r.err).toBe(null); - const config = JSON.parse(r.stdout); - expect(config['ts-node'].require).toEqual([ - resolve(TEST_DIR, 'tsconfig-extends/other/require-hook.js'), - ]); - expect(config['ts-node'].scopeDir).toBe( - resolve(TEST_DIR, 'tsconfig-extends/other/scopedir') - ); - expect(config['ts-node'].preferTsExts).toBe(true); - }); - }); - test.suite( 'should use implicit @tsconfig/bases config when one is not loaded from disk', ({ contextEach }) => { diff --git a/tests/tsconfig-extends-multiple/a/require-hook-from-a.js b/tests/tsconfig-extends-multiple/a/require-hook-from-a.js new file mode 100644 index 000000000..e69de29bb diff --git a/tests/tsconfig-extends-multiple/a/tsconfig.json b/tests/tsconfig-extends-multiple/a/tsconfig.json new file mode 100644 index 000000000..652fef2c2 --- /dev/null +++ b/tests/tsconfig-extends-multiple/a/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../b/tsconfig.json", + "ts-node": { + "require": ["./require-hook-from-a"], + "scopeDir": "./scopedir-from-a" + }, + "compilerOptions": { + "target": "ES2015" + } +} diff --git a/tests/tsconfig-extends-multiple/b/tsconfig.json b/tests/tsconfig-extends-multiple/b/tsconfig.json new file mode 100644 index 000000000..3bfc4da0a --- /dev/null +++ b/tests/tsconfig-extends-multiple/b/tsconfig.json @@ -0,0 +1,12 @@ +{ + "ts-node": { + "ignore": ["ignore-pattern-from-b"], + "transpiler": "transpiler-from-b", + "transpileOnly": true, + "scopeDir": "./scopedir-from-b", + "moduleTypes": { "module-types-from-b": "cjs" } + }, + "compilerOptions": { + "target": "ES2016" + } +} diff --git a/tests/tsconfig-extends-multiple/c/tsconfig.json b/tests/tsconfig-extends-multiple/c/tsconfig.json new file mode 100644 index 000000000..aa12da3b8 --- /dev/null +++ b/tests/tsconfig-extends-multiple/c/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../d/tsconfig.json", + "ts-node": { + "transpiler": "transpiler-from-c", + "transpileOnly": true + }, + "compilerOptions": { + "target": "ES2017" + } +} diff --git a/tests/tsconfig-extends-multiple/d/tsconfig.json b/tests/tsconfig-extends-multiple/d/tsconfig.json new file mode 100644 index 000000000..ce7f19b56 --- /dev/null +++ b/tests/tsconfig-extends-multiple/d/tsconfig.json @@ -0,0 +1,8 @@ +{ + "ts-node": { + "ignore": ["ignore-pattern-from-d"] + }, + "compilerOptions": { + "target": "ES2018" + } +} diff --git a/tests/tsconfig-extends-multiple/empty.ts b/tests/tsconfig-extends-multiple/empty.ts new file mode 100644 index 000000000..e69de29bb diff --git a/tests/tsconfig-extends-multiple/node_modules/transpiler-from-c/index.js b/tests/tsconfig-extends-multiple/node_modules/transpiler-from-c/index.js new file mode 100644 index 000000000..e69de29bb diff --git a/tests/tsconfig-extends-multiple/tsconfig.json b/tests/tsconfig-extends-multiple/tsconfig.json new file mode 100644 index 000000000..47b0751b0 --- /dev/null +++ b/tests/tsconfig-extends-multiple/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": ["./a/tsconfig.json", "./c/tsconfig.json"], + "ts-node": { + "preferTsExts": true + } +}