feat(tsconfig): respect tsconfig references (#29330)
Fixes https://github.com/microsoft/playwright/issues/29256
This commit is contained in:
parent
b9565ea26e
commit
dd0ef72cd8
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue