diff --git a/packages/playwright/src/third_party/tsconfig-loader.ts b/packages/playwright/src/third_party/tsconfig-loader.ts index d925d588e9..668f3650f6 100644 --- a/packages/playwright/src/third_party/tsconfig-loader.ts +++ b/packages/playwright/src/third_party/tsconfig-loader.ts @@ -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 } | undefined; - serialized: string | undefined; - allowJs: boolean; +export interface LoadedTsConfig { + tsConfigPath: string; + baseUrl?: string; + paths?: { [key: string]: Array }; + 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(), +): 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) { diff --git a/packages/playwright/src/transform/transform.ts b/packages/playwright/src/transform/transform.ts index 29b8b78a2b..801bf0c0f9 100644 --- a/packages/playwright/src/transform/transform.ts +++ b/packages/playwright/src/transform/transform.ts @@ -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(); +const cachedTSConfigs = new Map(); 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; diff --git a/tests/playwright-test/resolver.spec.ts b/tests/playwright-test/resolver.spec.ts index f0e95e91ca..778efefe1f 100644 --- a/tests/playwright-test/resolver.spec.ts +++ b/tests/playwright-test/resolver.spec.ts @@ -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); +});