feat(tsconfig): respect tsconfig references (#29330)

Fixes https://github.com/microsoft/playwright/issues/29256
This commit is contained in:
Pavel Feldman 2024-02-02 16:18:44 -08:00 committed by GitHub
parent b9565ea26e
commit dd0ef72cd8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 103 additions and 110 deletions

View File

@ -31,7 +31,7 @@ import { json5 } from '../utilsBundle';
/**
* Typing for the parts of tsconfig that we care about
*/
interface Tsconfig {
interface TsConfig {
extends?: string;
compilerOptions?: {
baseUrl?: string;
@ -39,55 +39,29 @@ interface Tsconfig {
strict?: boolean;
allowJs?: boolean;
};
references?: any[];
references?: { path: string }[];
}
export interface TsConfigLoaderResult {
tsConfigPath: string | undefined;
baseUrl: string | undefined;
paths: { [key: string]: Array<string> } | undefined;
serialized: string | undefined;
allowJs: boolean;
export interface LoadedTsConfig {
tsConfigPath: string;
baseUrl?: string;
paths?: { [key: string]: Array<string> };
allowJs?: boolean;
}
export interface TsConfigLoaderParams {
cwd: string;
}
export function tsConfigLoader({
cwd,
}: TsConfigLoaderParams): TsConfigLoaderResult {
const loadResult = loadSyncDefault(cwd);
loadResult.serialized = JSON.stringify(loadResult);
return loadResult;
}
function loadSyncDefault(
cwd: string,
): TsConfigLoaderResult {
// Tsconfig.loadSync uses path.resolve. This is why we can use an absolute path as filename
export function tsConfigLoader({ cwd, }: TsConfigLoaderParams): LoadedTsConfig[] {
const configPath = resolveConfigPath(cwd);
if (!configPath) {
return {
tsConfigPath: undefined,
baseUrl: undefined,
paths: undefined,
serialized: undefined,
allowJs: false,
};
}
const config = loadTsconfig(configPath);
if (!configPath)
return [];
return {
tsConfigPath: configPath,
baseUrl:
(config && config.compilerOptions && config.compilerOptions.baseUrl),
paths: config && config.compilerOptions && config.compilerOptions.paths,
serialized: undefined,
allowJs: !!config?.compilerOptions?.allowJs,
};
const references: LoadedTsConfig[] = [];
const config = loadTsConfig(configPath, references);
return [config, ...references];
}
function resolveConfigPath(cwd: string): string | undefined {
@ -122,79 +96,64 @@ export function walkForTsConfig(
return walkForTsConfig(parentDirectory, existsSync);
}
function loadTsconfig(
function resolveConfigFile(baseConfigFile: string, referencedConfigFile: string) {
if (!referencedConfigFile.endsWith('.json'))
referencedConfigFile += '.json';
const currentDir = path.dirname(baseConfigFile);
let resolvedConfigFile = path.resolve(currentDir, referencedConfigFile);
if (referencedConfigFile.indexOf('/') !== -1 && referencedConfigFile.indexOf('.') !== -1 && !fs.existsSync(referencedConfigFile))
resolvedConfigFile = path.join(currentDir, 'node_modules', referencedConfigFile);
return resolvedConfigFile;
}
function loadTsConfig(
configFilePath: string,
): Tsconfig | undefined {
if (!fs.existsSync(configFilePath)) {
return undefined;
}
references: LoadedTsConfig[],
visited = new Map<string, LoadedTsConfig>(),
): LoadedTsConfig {
if (visited.has(configFilePath))
return visited.get(configFilePath)!;
let result: LoadedTsConfig = {
tsConfigPath: configFilePath,
};
visited.set(configFilePath, result);
if (!fs.existsSync(configFilePath))
return result;
const configString = fs.readFileSync(configFilePath, 'utf-8');
const cleanedJson = StripBom(configString);
const parsedConfig: Tsconfig = json5.parse(cleanedJson);
let config: Tsconfig = {};
const parsedConfig: TsConfig = json5.parse(cleanedJson);
const extendsArray = Array.isArray(parsedConfig.extends) ? parsedConfig.extends : (parsedConfig.extends ? [parsedConfig.extends] : []);
for (let extendedConfig of extendsArray) {
if (
typeof extendedConfig === "string" &&
extendedConfig.indexOf(".json") === -1
) {
extendedConfig += ".json";
}
const currentDir = path.dirname(configFilePath);
let extendedConfigPath = path.join(currentDir, extendedConfig);
if (
extendedConfig.indexOf("/") !== -1 &&
extendedConfig.indexOf(".") !== -1 &&
!fs.existsSync(extendedConfigPath)
) {
extendedConfigPath = path.join(
currentDir,
"node_modules",
extendedConfig
);
}
const base =
loadTsconfig(extendedConfigPath) || {};
for (const extendedConfig of extendsArray) {
const extendedConfigPath = resolveConfigFile(configFilePath, extendedConfig);
const base = loadTsConfig(extendedConfigPath, references, visited);
// baseUrl should be interpreted as relative to the base tsconfig,
// but we need to update it so it is relative to the original tsconfig being loaded
if (base.compilerOptions && base.compilerOptions.baseUrl) {
if (base.baseUrl && base.baseUrl) {
const extendsDir = path.dirname(extendedConfig);
base.compilerOptions.baseUrl = path.join(
extendsDir,
base.compilerOptions.baseUrl
);
base.baseUrl = path.join(extendsDir, base.baseUrl);
}
config = mergeConfigs(config, base);
result = { ...result, ...base };
}
config = mergeConfigs(config, parsedConfig);
// The only top-level property that is excluded from inheritance is "references".
// https://www.typescriptlang.org/tsconfig#extends
config.references = parsedConfig.references;
const loadedConfig = Object.fromEntries(Object.entries({
baseUrl: parsedConfig.compilerOptions?.baseUrl,
paths: parsedConfig.compilerOptions?.paths,
allowJs: parsedConfig?.compilerOptions?.allowJs,
}).filter(([, value]) => value !== undefined));
if (path.basename(configFilePath) === 'jsconfig.json' && config.compilerOptions?.allowJs === undefined) {
config.compilerOptions = config.compilerOptions || {};
config.compilerOptions.allowJs = true;
}
result = { ...result, ...loadedConfig };
return config;
}
for (const ref of parsedConfig.references || [])
references.push(loadTsConfig(resolveConfigFile(configFilePath, ref.path), references, visited));
function mergeConfigs(base: Tsconfig, override: Tsconfig): Tsconfig {
return {
...base,
...override,
compilerOptions: {
...base.compilerOptions,
...override.compilerOptions,
},
};
if (path.basename(configFilePath) === 'jsconfig.json' && result.allowJs === undefined)
result.allowJs = true;
return result;
}
function StripBom(string: string) {

View File

@ -19,7 +19,7 @@ import path from 'path';
import url from 'url';
import { sourceMapSupport, pirates } from '../utilsBundle';
import type { Location } from '../../types/testReporter';
import type { TsConfigLoaderResult } from '../third_party/tsconfig-loader';
import type { LoadedTsConfig } from '../third_party/tsconfig-loader';
import { tsConfigLoader } from '../third_party/tsconfig-loader';
import Module from 'module';
import type { BabelPlugin, BabelTransformFunction } from './babelBundle';
@ -34,7 +34,7 @@ type ParsedTsConfigData = {
paths: { key: string, values: string[] }[];
allowJs: boolean;
};
const cachedTSConfigs = new Map<string, ParsedTsConfigData | undefined>();
const cachedTSConfigs = new Map<string, ParsedTsConfigData[]>();
export type TransformConfig = {
babelPlugins: [string, any?][];
@ -57,9 +57,7 @@ export function transformConfig(): TransformConfig {
return _transformConfig;
}
function validateTsConfig(tsconfig: TsConfigLoaderResult): ParsedTsConfigData | undefined {
if (!tsconfig.tsConfigPath)
return;
function validateTsConfig(tsconfig: LoadedTsConfig): ParsedTsConfigData {
// Make 'baseUrl' absolute, because it is relative to the tsconfig.json, not to cwd.
// When no explicit baseUrl is set, resolve paths relative to the tsconfig file.
// See https://www.typescriptlang.org/tsconfig#paths
@ -67,21 +65,19 @@ function validateTsConfig(tsconfig: TsConfigLoaderResult): ParsedTsConfigData |
// Only add the catch-all mapping when baseUrl is specified
const pathsFallback = tsconfig.baseUrl ? [{ key: '*', values: ['*'] }] : [];
return {
allowJs: tsconfig.allowJs,
allowJs: !!tsconfig.allowJs,
absoluteBaseUrl,
paths: Object.entries(tsconfig.paths || {}).map(([key, values]) => ({ key, values })).concat(pathsFallback)
};
}
function loadAndValidateTsconfigForFile(file: string): ParsedTsConfigData | undefined {
function loadAndValidateTsconfigsForFile(file: string): ParsedTsConfigData[] {
const cwd = path.dirname(file);
if (!cachedTSConfigs.has(cwd)) {
const loaded = tsConfigLoader({
cwd
});
cachedTSConfigs.set(cwd, validateTsConfig(loaded));
const loaded = tsConfigLoader({ cwd });
cachedTSConfigs.set(cwd, loaded.map(validateTsConfig));
}
return cachedTSConfigs.get(cwd);
return cachedTSConfigs.get(cwd)!;
}
const pathSeparator = process.platform === 'win32' ? ';' : ':';
@ -97,8 +93,10 @@ export function resolveHook(filename: string, specifier: string): string | undef
return resolveImportSpecifierExtension(path.resolve(path.dirname(filename), specifier));
const isTypeScript = filename.endsWith('.ts') || filename.endsWith('.tsx');
const tsconfig = loadAndValidateTsconfigForFile(filename);
if (tsconfig && (isTypeScript || tsconfig.allowJs)) {
const tsconfigs = loadAndValidateTsconfigsForFile(filename);
for (const tsconfig of tsconfigs) {
if (!isTypeScript && !tsconfig.allowJs)
continue;
let longestPrefixLength = -1;
let pathMatchedByLongestPrefix: string | undefined;

View File

@ -570,3 +570,39 @@ test('should import packages with non-index main script through path resolver',
expect(result.output).not.toContain(`find module`);
expect(result.output).toContain(`foo=42`);
});
test('should respect tsconfig project references', async ({ runInlineTest }) => {
test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/29256' });
const result = await runInlineTest({
'playwright.config.ts': `export default { projects: [{name: 'foo'}], };`,
'tsconfig.json': `{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.test.json" }
]
}`,
'tsconfig.test.json': `{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"util/*": ["./foo/bar/util/*"],
},
},
}`,
'foo/bar/util/b.ts': `
export const foo: string = 'foo';
`,
'a.test.ts': `
import { foo } from 'util/b';
import { test, expect } from '@playwright/test';
test('test', ({}, testInfo) => {
expect(foo).toBe('foo');
});
`,
});
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
});