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 * Typing for the parts of tsconfig that we care about
*/ */
interface Tsconfig { interface TsConfig {
extends?: string; extends?: string;
compilerOptions?: { compilerOptions?: {
baseUrl?: string; baseUrl?: string;
@ -39,55 +39,29 @@ interface Tsconfig {
strict?: boolean; strict?: boolean;
allowJs?: boolean; allowJs?: boolean;
}; };
references?: any[]; references?: { path: string }[];
} }
export interface TsConfigLoaderResult { export interface LoadedTsConfig {
tsConfigPath: string | undefined; tsConfigPath: string;
baseUrl: string | undefined; baseUrl?: string;
paths: { [key: string]: Array<string> } | undefined; paths?: { [key: string]: Array<string> };
serialized: string | undefined; allowJs?: boolean;
allowJs: boolean;
} }
export interface TsConfigLoaderParams { export interface TsConfigLoaderParams {
cwd: string; cwd: string;
} }
export function tsConfigLoader({ export function tsConfigLoader({ cwd, }: TsConfigLoaderParams): LoadedTsConfig[] {
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
const configPath = resolveConfigPath(cwd); const configPath = resolveConfigPath(cwd);
if (!configPath) { if (!configPath)
return { return [];
tsConfigPath: undefined,
baseUrl: undefined,
paths: undefined,
serialized: undefined,
allowJs: false,
};
}
const config = loadTsconfig(configPath);
return { const references: LoadedTsConfig[] = [];
tsConfigPath: configPath, const config = loadTsConfig(configPath, references);
baseUrl: return [config, ...references];
(config && config.compilerOptions && config.compilerOptions.baseUrl),
paths: config && config.compilerOptions && config.compilerOptions.paths,
serialized: undefined,
allowJs: !!config?.compilerOptions?.allowJs,
};
} }
function resolveConfigPath(cwd: string): string | undefined { function resolveConfigPath(cwd: string): string | undefined {
@ -122,79 +96,64 @@ export function walkForTsConfig(
return walkForTsConfig(parentDirectory, existsSync); 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, configFilePath: string,
): Tsconfig | undefined { references: LoadedTsConfig[],
if (!fs.existsSync(configFilePath)) { visited = new Map<string, LoadedTsConfig>(),
return undefined; ): 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 configString = fs.readFileSync(configFilePath, 'utf-8');
const cleanedJson = StripBom(configString); const cleanedJson = StripBom(configString);
const parsedConfig: Tsconfig = json5.parse(cleanedJson); const parsedConfig: TsConfig = json5.parse(cleanedJson);
let config: Tsconfig = {};
const extendsArray = Array.isArray(parsedConfig.extends) ? parsedConfig.extends : (parsedConfig.extends ? [parsedConfig.extends] : []); const extendsArray = Array.isArray(parsedConfig.extends) ? parsedConfig.extends : (parsedConfig.extends ? [parsedConfig.extends] : []);
for (let extendedConfig of extendsArray) { for (const extendedConfig of extendsArray) {
if ( const extendedConfigPath = resolveConfigFile(configFilePath, extendedConfig);
typeof extendedConfig === "string" && const base = loadTsConfig(extendedConfigPath, references, visited);
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) || {};
// baseUrl should be interpreted as relative to the base tsconfig, // 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 // 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); const extendsDir = path.dirname(extendedConfig);
base.compilerOptions.baseUrl = path.join( base.baseUrl = path.join(extendsDir, base.baseUrl);
extendsDir,
base.compilerOptions.baseUrl
);
} }
result = { ...result, ...base };
config = mergeConfigs(config, base);
} }
config = mergeConfigs(config, parsedConfig); const loadedConfig = Object.fromEntries(Object.entries({
// The only top-level property that is excluded from inheritance is "references". baseUrl: parsedConfig.compilerOptions?.baseUrl,
// https://www.typescriptlang.org/tsconfig#extends paths: parsedConfig.compilerOptions?.paths,
config.references = parsedConfig.references; allowJs: parsedConfig?.compilerOptions?.allowJs,
}).filter(([, value]) => value !== undefined));
if (path.basename(configFilePath) === 'jsconfig.json' && config.compilerOptions?.allowJs === undefined) { result = { ...result, ...loadedConfig };
config.compilerOptions = config.compilerOptions || {};
config.compilerOptions.allowJs = true;
}
return config; for (const ref of parsedConfig.references || [])
} references.push(loadTsConfig(resolveConfigFile(configFilePath, ref.path), references, visited));
function mergeConfigs(base: Tsconfig, override: Tsconfig): Tsconfig { if (path.basename(configFilePath) === 'jsconfig.json' && result.allowJs === undefined)
return { result.allowJs = true;
...base, return result;
...override,
compilerOptions: {
...base.compilerOptions,
...override.compilerOptions,
},
};
} }
function StripBom(string: string) { function StripBom(string: string) {

View file

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