diff --git a/packages/playwright/src/third_party/tsconfig-loader.ts b/packages/playwright/src/third_party/tsconfig-loader.ts index d14b7602b0..d85ff32100 100644 --- a/packages/playwright/src/third_party/tsconfig-loader.ts +++ b/packages/playwright/src/third_party/tsconfig-loader.ts @@ -44,8 +44,11 @@ interface TsConfig { export interface LoadedTsConfig { tsConfigPath: string; - baseUrl?: string; - paths?: { [key: string]: Array }; + paths?: { + mapping: { [key: string]: Array }; + pathsBasePath: string; // absolute path + }; + absoluteBaseUrl?: string; allowJs?: boolean; } @@ -132,25 +135,27 @@ function loadTsConfig( 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.baseUrl && base.baseUrl) { - const extendsDir = path.dirname(extendedConfig); - base.baseUrl = path.join(extendsDir, base.baseUrl); - } // Retain result instance, so that caching works. Object.assign(result, base, { tsConfigPath: configFilePath }); } - const loadedConfig = Object.fromEntries(Object.entries({ - baseUrl: parsedConfig.compilerOptions?.baseUrl, - paths: parsedConfig.compilerOptions?.paths, - allowJs: parsedConfig?.compilerOptions?.allowJs, - }).filter(([, value]) => value !== undefined)); - - // Retain result instance, so that caching works. - Object.assign(result, loadedConfig); + if (parsedConfig.compilerOptions?.allowJs !== undefined) + result.allowJs = parsedConfig.compilerOptions.allowJs; + if (parsedConfig.compilerOptions?.paths !== undefined) { + // We must store pathsBasePath from the config that defines "paths" and later resolve + // based on this absolute path, when no "baseUrl" is specified. See tsc for reference: + // https://github.com/microsoft/TypeScript/blob/353ccb7688351ae33ccf6e0acb913aa30621eaf4/src/compiler/commandLineParser.ts#L3129 + // https://github.com/microsoft/TypeScript/blob/353ccb7688351ae33ccf6e0acb913aa30621eaf4/src/compiler/moduleSpecifiers.ts#L510 + result.paths = { + mapping: parsedConfig.compilerOptions.paths, + pathsBasePath: path.dirname(configFilePath), + }; + } + if (parsedConfig.compilerOptions?.baseUrl !== undefined) { + // Follow tsc and resolve all relative file paths in the config right away. + // This way it is safe to inherit paths between the configs. + result.absoluteBaseUrl = path.resolve(path.dirname(configFilePath), parsedConfig.compilerOptions.baseUrl); + } for (const ref of parsedConfig.references || []) references.push(loadTsConfig(resolveConfigFile(configFilePath, ref.path), references, visited)); diff --git a/packages/playwright/src/transform/transform.ts b/packages/playwright/src/transform/transform.ts index 82b5acdda1..a2e376d043 100644 --- a/packages/playwright/src/transform/transform.ts +++ b/packages/playwright/src/transform/transform.ts @@ -30,7 +30,7 @@ import { getFromCompilationCache, currentFileDepsCollector, belongsToNodeModules const version = require('../../package.json').version; type ParsedTsConfigData = { - absoluteBaseUrl: string; + pathsBase?: string; paths: { key: string, values: string[] }[]; allowJs: boolean; }; @@ -58,16 +58,15 @@ export function transformConfig(): TransformConfig { } 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 - const absoluteBaseUrl = path.resolve(path.dirname(tsconfig.tsConfigPath), tsconfig.baseUrl ?? '.'); + const pathsBase = tsconfig.absoluteBaseUrl ?? tsconfig.paths?.pathsBasePath; // Only add the catch-all mapping when baseUrl is specified - const pathsFallback = tsconfig.baseUrl ? [{ key: '*', values: ['*'] }] : []; + const pathsFallback = tsconfig.absoluteBaseUrl ? [{ key: '*', values: ['*'] }] : []; return { allowJs: !!tsconfig.allowJs, - absoluteBaseUrl, - paths: Object.entries(tsconfig.paths || {}).map(([key, values]) => ({ key, values })).concat(pathsFallback) + pathsBase, + paths: Object.entries(tsconfig.paths?.mapping || {}).map(([key, values]) => ({ key, values })).concat(pathsFallback) }; } @@ -132,7 +131,7 @@ export function resolveHook(filename: string, specifier: string): string | undef let candidate = value; if (value.includes('*')) candidate = candidate.replace('*', matchedPartOfSpecifier); - candidate = path.resolve(tsconfig.absoluteBaseUrl, candidate); + candidate = path.resolve(tsconfig.pathsBase!, candidate); const existing = resolveImportSpecifierExtension(candidate); if (existing) { longestPrefixLength = keyPrefix.length; diff --git a/tests/playwright-test/resolver.spec.ts b/tests/playwright-test/resolver.spec.ts index 778efefe1f..4092263648 100644 --- a/tests/playwright-test/resolver.spec.ts +++ b/tests/playwright-test/resolver.spec.ts @@ -505,6 +505,9 @@ test('should support extends in tsconfig.json', async ({ runInlineTest }) => { }`, 'tsconfig.base1.json': `{ "extends": "./tsconfig.base.json", + "compilerOptions": { + "allowJs": true, + }, }`, 'tsconfig.base2.json': `{ "compilerOptions": { @@ -518,7 +521,9 @@ test('should support extends in tsconfig.json', async ({ runInlineTest }) => { }, }, }`, - 'a.test.ts': ` + 'a.test.js': ` + // This js file is affected by tsconfig because allowJs is inherited. + // Next line resolve to the final baseUrl ("dir") + relative path mapping ("./foo/bar/util/*"). const { foo } = require('util/file'); import { test, expect } from '@playwright/test'; test('test', ({}, testInfo) => { @@ -534,6 +539,36 @@ test('should support extends in tsconfig.json', async ({ runInlineTest }) => { expect(result.exitCode).toBe(0); }); +test('should resolve paths relative to the originating config when extending and no baseUrl', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'tsconfig.json': `{ + "extends": ["./dir/tsconfig.base.json"], + }`, + 'dir/tsconfig.base.json': `{ + "compilerOptions": { + "paths": { + "~/*": ["../mapped/*"], + }, + }, + }`, + 'a.test.ts': ` + // This resolves relative to the base tsconfig that defined path mapping, + // because there is no baseUrl in the final tsconfig. + const { foo } = require('~/file'); + import { test, expect } from '@playwright/test'; + test('test', ({}, testInfo) => { + expect(foo).toBe('foo'); + }); + `, + 'mapped/file.ts': ` + module.exports = { foo: 'foo' }; + `, + }); + + expect(result.passed).toBe(1); + expect(result.exitCode).toBe(0); +}); + test('should import packages with non-index main script through path resolver', async ({ runInlineTest }) => { const result = await runInlineTest({ 'app/pkg/main.ts': `