feat(test runner): --tsconfig cli option (#31932)

Introduce `--tsconfig` to specify a single config to be used for all
imported files, instead of looking up tsconfig for each file separately.

Fixes #12829.
This commit is contained in:
Dmitry Gozman 2024-08-06 06:55:15 -07:00 committed by GitHub
parent bff97b4810
commit a54ed48b42
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 137 additions and 40 deletions

View File

@ -103,5 +103,6 @@ Complete set of Playwright Test options is available in the [configuration file]
| `--shard <shard>` | [Shard](./test-parallel.md#shard-tests-between-multiple-machines) tests and execute only selected shard, specified in the form `current/all`, 1-based, for example `3/5`.| | `--shard <shard>` | [Shard](./test-parallel.md#shard-tests-between-multiple-machines) tests and execute only selected shard, specified in the form `current/all`, 1-based, for example `3/5`.|
| `--timeout <number>` | Maximum timeout in milliseconds for each test, defaults to 30 seconds. Learn more about [various timeouts](./test-timeouts.md).| | `--timeout <number>` | Maximum timeout in milliseconds for each test, defaults to 30 seconds. Learn more about [various timeouts](./test-timeouts.md).|
| `--trace <mode>` | Force tracing mode, can be `on`, `off`, `on-first-retry`, `on-all-retries`, `retain-on-failure` | | `--trace <mode>` | Force tracing mode, can be `on`, `off`, `on-first-retry`, `on-all-retries`, `retain-on-failure` |
| `--tsconfig <path>` | Path to a single tsconfig applicable to all imported files. See [tsconfig resolution](./test-typescript.md#tsconfig-resolution) for more details. |
| `--update-snapshots` or `-u` | Whether to update [snapshots](./test-snapshots.md) with actual results instead of comparing them. Use this when snapshot expectations have changed.| | `--update-snapshots` or `-u` | Whether to update [snapshots](./test-snapshots.md) with actual results instead of comparing them. Use this when snapshot expectations have changed.|
| `--workers <number>` or `-j <number>`| The maximum number of concurrent worker processes that run in [parallel](./test-parallel.md). | | `--workers <number>` or `-j <number>`| The maximum number of concurrent worker processes that run in [parallel](./test-parallel.md). |

View File

@ -5,9 +5,9 @@ title: "TypeScript"
## Introduction ## Introduction
Playwright supports TypeScript out of the box. You just write tests in TypeScript, and Playwright will read them, transform to JavaScript and run. Note that Playwright does not check the types and will run tests even if there are non-critical TypeScript compilation errors. Playwright supports TypeScript out of the box. You just write tests in TypeScript, and Playwright will read them, transform to JavaScript and run.
We recommend you run TypeScript compiler alongside Playwright. For example on GitHub actions: Note that Playwright does not check the types and will run tests even if there are non-critical TypeScript compilation errors. We recommend you run TypeScript compiler alongside Playwright. For example on GitHub actions:
```yaml ```yaml
jobs: jobs:
@ -28,7 +28,7 @@ npx tsc -p tsconfig.json --noEmit -w
## tsconfig.json ## tsconfig.json
Playwright will pick up `tsconfig.json` for each source file it loads. Note that Playwright **only supports** the following tsconfig options: `paths` and `baseUrl`. Playwright will pick up `tsconfig.json` for each source file it loads. Note that Playwright **only supports** the following tsconfig options: `allowJs`, `baseUrl`, `paths` and `references`.
We recommend setting up a separate `tsconfig.json` in the tests directory so that you can change some preferences specifically for the tests. Here is an example directory structure. We recommend setting up a separate `tsconfig.json` in the tests directory so that you can change some preferences specifically for the tests. Here is an example directory structure.
@ -49,12 +49,12 @@ playwright.config.ts
Playwright supports [path mapping](https://www.typescriptlang.org/docs/handbook/module-resolution.html#path-mapping) declared in the `tsconfig.json`. Make sure that `baseUrl` is also set. Playwright supports [path mapping](https://www.typescriptlang.org/docs/handbook/module-resolution.html#path-mapping) declared in the `tsconfig.json`. Make sure that `baseUrl` is also set.
Here is an example `tsconfig.json` that works with Playwright Test: Here is an example `tsconfig.json` that works with Playwright:
```json ```json title="tsconfig.json"
{ {
"compilerOptions": { "compilerOptions": {
"baseUrl": ".", // This must be specified if "paths" is. "baseUrl": ".",
"paths": { "paths": {
"@myhelper/*": ["packages/myhelper/*"] // This mapping is relative to "baseUrl". "@myhelper/*": ["packages/myhelper/*"] // This mapping is relative to "baseUrl".
} }
@ -74,6 +74,22 @@ test('example', async ({ page }) => {
}); });
``` ```
### tsconfig resolution
By default, Playwright will look up a closest tsconfig for each imported file by going up the directory structure and looking for `tsconfig.json` or `jsconfig.json`. This way, you can create a `tests/tsconfig.json` file that will be used only for your tests and Playwright will pick it up automatically.
```sh
# Playwright will choose tsconfig automatically
npx playwrigh test
```
Alternatively, you can specify a single tsconfig file to use in the command line, and Playwright will use it for all imported files, not only test files.
```sh
# Pass a specific tsconfig
npx playwrigh test --tsconfig=tsconfig.test.json
```
## Manually compile tests with TypeScript ## Manually compile tests with TypeScript
Sometimes, Playwright Test will not be able to transform your TypeScript code correctly, for example when you are using experimental or very recent features of TypeScript, usually configured in `tsconfig.json`. Sometimes, Playwright Test will not be able to transform your TypeScript code correctly, for example when you are using experimental or very recent features of TypeScript, usually configured in `tsconfig.json`.

View File

@ -24,7 +24,6 @@ import { getPackageJsonPath, mergeObjects } from '../util';
import type { Matcher } from '../util'; import type { Matcher } from '../util';
import type { ConfigCLIOverrides } from './ipc'; import type { ConfigCLIOverrides } from './ipc';
import type { FullConfig, FullProject } from '../../types/testReporter'; import type { FullConfig, FullProject } from '../../types/testReporter';
import { setTransformConfig } from '../transform/transform';
export type ConfigLocation = { export type ConfigLocation = {
resolvedConfigFile?: string; resolvedConfigFile?: string;
@ -128,10 +127,6 @@ export class FullConfigInternal {
this.projects = projectConfigs.map(p => new FullProjectInternal(configDir, userConfig, this, p, this.configCLIOverrides, packageJsonDir)); this.projects = projectConfigs.map(p => new FullProjectInternal(configDir, userConfig, this, p, this.configCLIOverrides, packageJsonDir));
resolveProjectDependencies(this.projects); resolveProjectDependencies(this.projects);
this._assignUniqueProjectIds(this.projects); this._assignUniqueProjectIds(this.projects);
setTransformConfig({
babelPlugins: privateConfiguration?.babelPlugins || [],
external: userConfig.build?.external || [],
});
this.config.projects = this.projects.map(p => p.project); this.config.projects = this.projects.map(p => p.project);
} }

View File

@ -18,13 +18,13 @@ import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { gracefullyProcessExitDoNotHang, isRegExp } from 'playwright-core/lib/utils'; import { gracefullyProcessExitDoNotHang, isRegExp } from 'playwright-core/lib/utils';
import type { ConfigCLIOverrides, SerializedConfig } from './ipc'; import type { ConfigCLIOverrides, SerializedConfig } from './ipc';
import { requireOrImport } from '../transform/transform'; import { requireOrImport, setSingleTSConfig, setTransformConfig } from '../transform/transform';
import type { Config, Project } from '../../types/test'; import type { Config, Project } from '../../types/test';
import { errorWithFile, fileIsModule } from '../util'; import { errorWithFile, fileIsModule } from '../util';
import type { ConfigLocation } from './config'; import type { ConfigLocation } from './config';
import { FullConfigInternal } from './config'; import { FullConfigInternal } from './config';
import { addToCompilationCache } from '../transform/compilationCache'; import { addToCompilationCache } from '../transform/compilationCache';
import { initializeEsmLoader, registerESMLoader } from './esmLoaderHost'; import { configureESMLoader, configureESMLoaderTransformConfig, registerESMLoader } from './esmLoaderHost';
import { execArgvWithExperimentalLoaderOptions, execArgvWithoutExperimentalLoaderOptions } from '../transform/esmUtils'; import { execArgvWithExperimentalLoaderOptions, execArgvWithoutExperimentalLoaderOptions } from '../transform/esmUtils';
const kDefineConfigWasUsed = Symbol('defineConfigWasUsed'); const kDefineConfigWasUsed = Symbol('defineConfigWasUsed');
@ -87,10 +87,7 @@ export const defineConfig = (...configs: any[]) => {
export async function deserializeConfig(data: SerializedConfig): Promise<FullConfigInternal> { export async function deserializeConfig(data: SerializedConfig): Promise<FullConfigInternal> {
if (data.compilationCache) if (data.compilationCache)
addToCompilationCache(data.compilationCache); addToCompilationCache(data.compilationCache);
return await loadConfig(data.location, data.configCLIOverrides);
const config = await loadConfig(data.location, data.configCLIOverrides);
await initializeEsmLoader();
return config;
} }
async function loadUserConfig(location: ConfigLocation): Promise<Config> { async function loadUserConfig(location: ConfigLocation): Promise<Config> {
@ -101,6 +98,11 @@ async function loadUserConfig(location: ConfigLocation): Promise<Config> {
} }
export async function loadConfig(location: ConfigLocation, overrides?: ConfigCLIOverrides, ignoreProjectDependencies = false): Promise<FullConfigInternal> { export async function loadConfig(location: ConfigLocation, overrides?: ConfigCLIOverrides, ignoreProjectDependencies = false): Promise<FullConfigInternal> {
// 1. Setup tsconfig; configure ESM loader with tsconfig and compilation cache.
setSingleTSConfig(overrides?.tsconfig);
await configureESMLoader();
// 2. Load and validate playwright config.
const userConfig = await loadUserConfig(location); const userConfig = await loadUserConfig(location);
validateConfig(location.resolvedConfigFile || '<default config>', userConfig); validateConfig(location.resolvedConfigFile || '<default config>', userConfig);
const fullConfig = new FullConfigInternal(location, userConfig, overrides || {}); const fullConfig = new FullConfigInternal(location, userConfig, overrides || {});
@ -111,6 +113,15 @@ export async function loadConfig(location: ConfigLocation, overrides?: ConfigCLI
project.teardown = undefined; project.teardown = undefined;
} }
} }
// 3. Load transform options from the playwright config.
const babelPlugins = (userConfig as any)['@playwright/test']?.babelPlugins || [];
const external = userConfig.build?.external || [];
setTransformConfig({ babelPlugins, external });
// 4. Send transform options to ESM loader.
await configureESMLoaderTransformConfig();
return fullConfig; return fullConfig;
} }

View File

@ -16,7 +16,7 @@
import url from 'url'; import url from 'url';
import { addToCompilationCache, serializeCompilationCache } from '../transform/compilationCache'; import { addToCompilationCache, serializeCompilationCache } from '../transform/compilationCache';
import { transformConfig } from '../transform/transform'; import { singleTSConfig, transformConfig } from '../transform/transform';
import { PortTransport } from '../transform/portTransport'; import { PortTransport } from '../transform/portTransport';
let loaderChannel: PortTransport | undefined; let loaderChannel: PortTransport | undefined;
@ -67,9 +67,15 @@ export async function incorporateCompilationCache() {
addToCompilationCache(result.cache); addToCompilationCache(result.cache);
} }
export async function initializeEsmLoader() { export async function configureESMLoader() {
if (!loaderChannel)
return;
await loaderChannel.send('setSingleTSConfig', { tsconfig: singleTSConfig() });
await loaderChannel.send('addToCompilationCache', { cache: serializeCompilationCache() });
}
export async function configureESMLoaderTransformConfig() {
if (!loaderChannel) if (!loaderChannel)
return; return;
await loaderChannel.send('setTransformConfig', { config: transformConfig() }); await loaderChannel.send('setTransformConfig', { config: transformConfig() });
await loaderChannel.send('addToCompilationCache', { cache: serializeCompilationCache() });
} }

View File

@ -33,6 +33,7 @@ export type ConfigCLIOverrides = {
additionalReporters?: ReporterDescription[]; additionalReporters?: ReporterDescription[];
shard?: { current: number, total: number }; shard?: { current: number, total: number };
timeout?: number; timeout?: number;
tsconfig?: string;
ignoreSnapshots?: boolean; ignoreSnapshots?: boolean;
updateSnapshots?: 'all'|'none'|'missing'; updateSnapshots?: 'all'|'none'|'missing';
workers?: number | string; workers?: number | string;

View File

@ -286,6 +286,7 @@ function overridesFromOptions(options: { [key: string]: any }): ConfigCLIOverrid
reporter: resolveReporterOption(options.reporter), reporter: resolveReporterOption(options.reporter),
shard: shardPair ? { current: shardPair[0], total: shardPair[1] } : undefined, shard: shardPair ? { current: shardPair[0], total: shardPair[1] } : undefined,
timeout: options.timeout ? parseInt(options.timeout, 10) : undefined, timeout: options.timeout ? parseInt(options.timeout, 10) : undefined,
tsconfig: options.tsconfig ? path.resolve(process.cwd(), options.tsconfig) : undefined,
ignoreSnapshots: options.ignoreSnapshots ? !!options.ignoreSnapshots : undefined, ignoreSnapshots: options.ignoreSnapshots ? !!options.ignoreSnapshots : undefined,
updateSnapshots: options.updateSnapshots ? 'all' as const : undefined, updateSnapshots: options.updateSnapshots ? 'all' as const : undefined,
workers: options.workers, workers: options.workers,
@ -365,6 +366,7 @@ const testOptions: [string, string][] = [
['--shard <shard>', `Shard tests and execute only the selected shard, specify in the form "current/all", 1-based, for example "3/5"`], ['--shard <shard>', `Shard tests and execute only the selected shard, specify in the form "current/all", 1-based, for example "3/5"`],
['--timeout <timeout>', `Specify test timeout threshold in milliseconds, zero for unlimited (default: ${defaultTimeout})`], ['--timeout <timeout>', `Specify test timeout threshold in milliseconds, zero for unlimited (default: ${defaultTimeout})`],
['--trace <mode>', `Force tracing mode, can be ${kTraceModes.map(mode => `"${mode}"`).join(', ')}`], ['--trace <mode>', `Force tracing mode, can be ${kTraceModes.map(mode => `"${mode}"`).join(', ')}`],
['--tsconfig <path>', `Path to a single tsconfig applicable to all imported files (default: look up tsconfig for each imported file separately)`],
['--ui', `Run tests in interactive UI mode`], ['--ui', `Run tests in interactive UI mode`],
['--ui-host <host>', 'Host to serve UI on; specifying this option opens UI in a browser tab'], ['--ui-host <host>', 'Host to serve UI on; specifying this option opens UI in a browser tab'],
['--ui-port <port>', 'Port to serve UI on, 0 for any free port; specifying this option opens UI in a browser tab'], ['--ui-port <port>', 'Port to serve UI on, 0 for any free port; specifying this option opens UI in a browser tab'],

View File

@ -22,7 +22,7 @@ import { loadTestFile } from '../common/testLoader';
import type { FullConfigInternal } from '../common/config'; import type { FullConfigInternal } from '../common/config';
import { PoolBuilder } from '../common/poolBuilder'; import { PoolBuilder } from '../common/poolBuilder';
import { addToCompilationCache } from '../transform/compilationCache'; import { addToCompilationCache } from '../transform/compilationCache';
import { incorporateCompilationCache, initializeEsmLoader } from '../common/esmLoaderHost'; import { incorporateCompilationCache } from '../common/esmLoaderHost';
export class InProcessLoaderHost { export class InProcessLoaderHost {
private _config: FullConfigInternal; private _config: FullConfigInternal;
@ -34,7 +34,6 @@ export class InProcessLoaderHost {
} }
async start(errors: TestError[]) { async start(errors: TestError[]) {
await initializeEsmLoader();
return true; return true;
} }

View File

@ -52,12 +52,8 @@ export interface LoadedTsConfig {
allowJs?: boolean; allowJs?: boolean;
} }
export interface TsConfigLoaderParams { export function tsConfigLoader(tsconfigPathOrDirecotry: string): LoadedTsConfig[] {
cwd: string; const configPath = resolveConfigPath(tsconfigPathOrDirecotry);
}
export function tsConfigLoader({ cwd, }: TsConfigLoaderParams): LoadedTsConfig[] {
const configPath = resolveConfigPath(cwd);
if (!configPath) if (!configPath)
return []; return [];
@ -67,12 +63,12 @@ export function tsConfigLoader({ cwd, }: TsConfigLoaderParams): LoadedTsConfig[]
return [config, ...references]; return [config, ...references];
} }
function resolveConfigPath(cwd: string): string | undefined { function resolveConfigPath(tsconfigPathOrDirecotry: string): string | undefined {
if (fs.statSync(cwd).isFile()) { if (fs.statSync(tsconfigPathOrDirecotry).isFile()) {
return path.resolve(cwd); return path.resolve(tsconfigPathOrDirecotry);
} }
const configAbsolutePath = walkForTsConfig(cwd); const configAbsolutePath = walkForTsConfig(tsconfigPathOrDirecotry);
return configAbsolutePath ? path.resolve(configAbsolutePath) : undefined; return configAbsolutePath ? path.resolve(configAbsolutePath) : undefined;
} }

View File

@ -17,7 +17,7 @@
import fs from 'fs'; import fs from 'fs';
import url from 'url'; import url from 'url';
import { addToCompilationCache, currentFileDepsCollector, serializeCompilationCache, startCollectingFileDeps, stopCollectingFileDeps } from './compilationCache'; import { addToCompilationCache, currentFileDepsCollector, serializeCompilationCache, startCollectingFileDeps, stopCollectingFileDeps } from './compilationCache';
import { transformHook, resolveHook, setTransformConfig, shouldTransform } from './transform'; import { transformHook, resolveHook, setTransformConfig, shouldTransform, setSingleTSConfig } from './transform';
import { PortTransport } from './portTransport'; import { PortTransport } from './portTransport';
import { fileIsModule } from '../util'; import { fileIsModule } from '../util';
@ -89,6 +89,11 @@ function initialize(data: { port: MessagePort }) {
function createTransport(port: MessagePort) { function createTransport(port: MessagePort) {
return new PortTransport(port, async (method, params) => { return new PortTransport(port, async (method, params) => {
if (method === 'setSingleTSConfig') {
setSingleTSConfig(params.tsconfig);
return;
}
if (method === 'setTransformConfig') { if (method === 'setTransformConfig') {
setTransformConfig(params.config); setTransformConfig(params.config);
return; return;

View File

@ -57,6 +57,16 @@ export function transformConfig(): TransformConfig {
return _transformConfig; return _transformConfig;
} }
let _singleTSConfig: string | undefined;
export function setSingleTSConfig(value: string | undefined) {
_singleTSConfig = value;
}
export function singleTSConfig(): string | undefined {
return _singleTSConfig;
}
function validateTsConfig(tsconfig: LoadedTsConfig): ParsedTsConfigData { function validateTsConfig(tsconfig: LoadedTsConfig): ParsedTsConfigData {
// 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
@ -71,12 +81,12 @@ function validateTsConfig(tsconfig: LoadedTsConfig): ParsedTsConfigData {
} }
function loadAndValidateTsconfigsForFile(file: string): ParsedTsConfigData[] { function loadAndValidateTsconfigsForFile(file: string): ParsedTsConfigData[] {
const cwd = path.dirname(file); const tsconfigPathOrDirecotry = _singleTSConfig || path.dirname(file);
if (!cachedTSConfigs.has(cwd)) { if (!cachedTSConfigs.has(tsconfigPathOrDirecotry)) {
const loaded = tsConfigLoader({ cwd }); const loaded = tsConfigLoader(tsconfigPathOrDirecotry);
cachedTSConfigs.set(cwd, loaded.map(validateTsConfig)); cachedTSConfigs.set(tsconfigPathOrDirecotry, loaded.map(validateTsConfig));
} }
return cachedTSConfigs.get(cwd)!; return cachedTSConfigs.get(tsconfigPathOrDirecotry)!;
} }
const pathSeparator = process.platform === 'win32' ? ';' : ':'; const pathSeparator = process.platform === 'win32' ? ';' : ':';

View File

@ -128,8 +128,10 @@ test('should respect path resolver in experimental mode', async ({ runInlineTest
const result = await runInlineTest({ const result = await runInlineTest({
'package.json': JSON.stringify({ type: 'module' }), 'package.json': JSON.stringify({ type: 'module' }),
'playwright.config.ts': ` 'playwright.config.ts': `
// Make sure that config can use the path mapping.
import { foo } from 'util/b.js';
export default { export default {
projects: [{name: 'foo'}], projects: [{ name: foo }],
}; };
`, `,
'tsconfig.json': `{ 'tsconfig.json': `{
@ -147,7 +149,8 @@ test('should respect path resolver in experimental mode', async ({ runInlineTest
import { foo } from 'util/b.js'; import { foo } from 'util/b.js';
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
test('check project name', ({}, testInfo) => { test('check project name', ({}, testInfo) => {
expect(testInfo.project.name).toBe(foo); expect(testInfo.project.name).toBe('foo');
expect(foo).toBe('foo');
}); });
`, `,
'foo/bar/util/b.ts': ` 'foo/bar/util/b.ts': `

View File

@ -641,3 +641,55 @@ test('should respect tsconfig project references', async ({ runInlineTest }) =>
expect(result.exitCode).toBe(0); expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1); expect(result.passed).toBe(1);
}); });
test('should respect --tsconfig option', async ({ runInlineTest }) => {
const result = await runInlineTest({
'playwright.config.ts': `
import { foo } from '~/foo';
export default {
testDir: './tests' + foo,
};
`,
'tsconfig.json': `{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"~/*": ["./does-not-exist/*"],
},
},
}`,
'tsconfig.special.json': `{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"~/*": ["./mapped-from-root/*"],
},
},
}`,
'mapped-from-root/foo.ts': `
export const foo = 42;
`,
'tests42/tsconfig.json': `{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"~/*": ["../should-be-ignored/*"],
},
},
}`,
'tests42/a.test.ts': `
import { foo } from '~/foo';
import { test, expect } from '@playwright/test';
test('test', ({}) => {
expect(foo).toBe(42);
});
`,
'should-be-ignored/foo.ts': `
export const foo = 43;
`,
}, { tsconfig: 'tsconfig.special.json' });
expect(result.passed).toBe(1);
expect(result.exitCode).toBe(0);
expect(result.output).not.toContain(`Could not`);
});