Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(test runner): rework how tsconfig is applied #29882

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
81 changes: 62 additions & 19 deletions docs/src/test-typescript-js.md
Expand Up @@ -5,9 +5,9 @@ title: "TypeScript"

## 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
jobs:
Expand All @@ -28,31 +28,65 @@ npx tsc -p tsconfig.json --noEmit -w

## 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` and consult it for each source file it loads. Note that Playwright **only supports** the following tsconfig options: `allowJs`, `baseUrl`, `exclude`, `files`, `include`, `paths`, `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 to use the [`references` option](https://www.typescriptlang.org/tsconfig#references), so that you can configure TypeScript differently for source and test files.

Below is an example directory structure and `tsconfig` file templates.

```txt
src/
source.ts

tests/
tsconfig.json # test-specific tsconfig
example.spec.ts

tsconfig.json # generic tsconfig for all typescript sources

tsconfig.json
tsconfig.app.json
tsconfig.test.json
playwright.config.ts
```

```json title="tsconfig.json"
// This file just references two other configs.
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.test.json" }
]
}
```

```json title="tsconfig.app.json"
{
"include": ["./src"],
"compilerOptions": {
// Configure TypeScript for the app here.
}
}
```

```json title="tsconfig.test.json"
{
"include": ["./tests"],
"compilerOptions": {
// Configure TypeScript for tests here.
}
}
```

Note that `include` should be configured in each config to only apply to respective files.

### tsconfig path mapping

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.test.json"
{
"include": ["tests/**/*.ts"],
"compilerOptions": {
"baseUrl": ".", // This must be specified if "paths" is.
"paths": {
Expand All @@ -74,23 +108,32 @@ test('example', async ({ page }) => {
});
```

### tsconfig resolution in Playwright

Before loading `playwright.config.ts`, Playwright will search for `tsconfig.json` file next to it and in parent directories up to the package root containing `package.json`. This `tsconfig.json` will be used to load `playwright.config.ts`.

Then, if you specify [`property: TestConfig.testDir`], and it contains a `tsconfig.json` file, Playwright will use it instead of the root `tsconfig.json`. This is **not recommended** and is left for backwards compatibility only. See above for the [recommended `references` setup](#tsconfigjson).

Playwright consults `include`, `exclude` and `files` properties of the `tsconfig.json` before loading any typescript file, either through `require` or `import`, to determine whether to apply `tsconfig` to this particular file.

## 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`.

In this case, you can perform your own TypeScript compilation before sending the tests to Playwright.

First add a `tsconfig.json` file inside the tests directory:
First configure `tsconfig.test.json` to compile your tests:

```json
```json title="tsconfig.test.json"
{
"compilerOptions": {
"target": "ESNext",
"module": "commonjs",
"moduleResolution": "Node",
"sourceMap": true,
"outDir": "../tests-out",
}
"include": ["tests/**/*.ts"],
"compilerOptions": {
"target": "ESNext",
"module": "commonjs",
"moduleResolution": "Node",
"sourceMap": true,
"outDir": "./tests-out",
}
}
```

Expand All @@ -99,7 +142,7 @@ In `package.json`, add two scripts:
```json
{
"scripts": {
"pretest": "tsc --incremental -p tests/tsconfig.json",
"pretest": "tsc --incremental -p tsconfig.test.json",
"test": "playwright test -c tests-out"
}
}
Expand Down
5 changes: 0 additions & 5 deletions packages/playwright/src/common/config.ts
Expand Up @@ -24,7 +24,6 @@ import { getPackageJsonPath, mergeObjects } from '../util';
import type { Matcher } from '../util';
import type { ConfigCLIOverrides } from './ipc';
import type { FullConfig, FullProject } from '../../types/test';
import { setTransformConfig } from '../transform/transform';

export type ConfigLocation = {
resolvedConfigFile?: string;
Expand Down Expand Up @@ -133,10 +132,6 @@ export class FullConfigInternal {
this.projects = projectConfigs.map(p => new FullProjectInternal(configDir, userConfig, this, p, this.configCLIOverrides, throwawayArtifactsPath));
resolveProjectDependencies(this.projects);
this._assignUniqueProjectIds(this.projects);
setTransformConfig({
babelPlugins: privateConfiguration?.babelPlugins || [],
external: userConfig.build?.external || [],
});
this.config.projects = this.projects.map(p => p.project);
}

Expand Down
27 changes: 22 additions & 5 deletions packages/playwright/src/common/configLoader.ts
Expand Up @@ -18,13 +18,13 @@ import * as fs from 'fs';
import * as path from 'path';
import { gracefullyProcessExitDoNotHang, isRegExp } from 'playwright-core/lib/utils';
import type { ConfigCLIOverrides, SerializedConfig } from './ipc';
import { requireOrImport } from '../transform/transform';
import { requireOrImport, setupTransformConfig } from '../transform/transform';
import type { Config, Project } from '../../types/test';
import { errorWithFile, fileIsModule } from '../util';
import type { ConfigLocation } from './config';
import { FullConfigInternal } from './config';
import { addToCompilationCache } from '../transform/compilationCache';
import { initializeEsmLoader, registerESMLoader } from './esmLoaderHost';
import { configureESMLoader, registerESMLoader } from './esmLoaderHost';
import { execArgvWithExperimentalLoaderOptions, execArgvWithoutExperimentalLoaderOptions } from '../transform/esmUtils';

const kDefineConfigWasUsed = Symbol('defineConfigWasUsed');
Expand Down Expand Up @@ -88,9 +88,7 @@ export async function deserializeConfig(data: SerializedConfig): Promise<FullCon
if (data.compilationCache)
addToCompilationCache(data.compilationCache);

const config = await loadConfig(data.location, data.configCLIOverrides);
await initializeEsmLoader();
return config;
return await loadConfig(data.location, data.configCLIOverrides);
}

async function loadUserConfig(location: ConfigLocation): Promise<Config> {
Expand All @@ -101,8 +99,16 @@ async function loadUserConfig(location: ConfigLocation): Promise<Config> {
}

export async function loadConfig(location: ConfigLocation, overrides?: ConfigCLIOverrides, ignoreProjectDependencies = false): Promise<FullConfigInternal> {
// 1. Look for tsconfig starting with `configDir` and going up to the package.json level.
setupTransformConfig([], [], location.configDir, 'lookup-to-package-root');

// 2. Send tsconfig to ESM loader.
await configureESMLoader();

// 3. Load and validate playwright config.
const userConfig = await loadUserConfig(location);
validateConfig(location.resolvedConfigFile || '<default config>', userConfig);

const fullConfig = new FullConfigInternal(location, userConfig, overrides || {});
fullConfig.defineConfigWasUsed = !!(userConfig as any)[kDefineConfigWasUsed];
if (ignoreProjectDependencies) {
Expand All @@ -111,6 +117,17 @@ export async function loadConfig(location: ConfigLocation, overrides?: ConfigCLI
project.teardown = undefined;
}
}

// 4. Load transform options from the playwright config.
const babelPlugins = (userConfig as any)['@playwright/test']?.babelPlugins || [];
const external = userConfig.build?.external || [];

// 5. When {config.testDir}/tsconfig.json is present, switch to it for backwards compatibility.
setupTransformConfig(babelPlugins, external, fullConfig.config.rootDir, 'switch-if-present');

// 6. Sync new transform options to ESM loader.
await configureESMLoader();

return fullConfig;
}

Expand Down
2 changes: 1 addition & 1 deletion packages/playwright/src/common/esmLoaderHost.ts
Expand Up @@ -67,7 +67,7 @@ export async function incorporateCompilationCache() {
addToCompilationCache(result.cache);
}

export async function initializeEsmLoader() {
export async function configureESMLoader() {
if (!loaderChannel)
return;
await loaderChannel.send('setTransformConfig', { config: transformConfig() });
Expand Down
3 changes: 1 addition & 2 deletions packages/playwright/src/runner/loaderHost.ts
Expand Up @@ -22,7 +22,7 @@ import { loadTestFile } from '../common/testLoader';
import type { FullConfigInternal } from '../common/config';
import { PoolBuilder } from '../common/poolBuilder';
import { addToCompilationCache } from '../transform/compilationCache';
import { incorporateCompilationCache, initializeEsmLoader } from '../common/esmLoaderHost';
import { incorporateCompilationCache } from '../common/esmLoaderHost';

export class InProcessLoaderHost {
private _config: FullConfigInternal;
Expand All @@ -34,7 +34,6 @@ export class InProcessLoaderHost {
}

async start(errors: TestError[]) {
await initializeEsmLoader();
return true;
}

Expand Down
50 changes: 35 additions & 15 deletions packages/playwright/src/third_party/tsconfig-loader.ts
Expand Up @@ -38,8 +38,12 @@ interface TsConfig {
paths?: { [key: string]: Array<string> };
strict?: boolean;
allowJs?: boolean;
outDir?: string;
};
references?: { path: string }[];
files?: string[];
include?: string[];
exclude?: string[];
}

export interface LoadedTsConfig {
Expand All @@ -50,14 +54,19 @@ export interface LoadedTsConfig {
};
absoluteBaseUrl?: string;
allowJs?: boolean;
files?: string[]; // absolute paths
include?: string[]; // absolute path patterns
exclude?: string[]; // absolute path patterns
outDir?: string; // absolute path
}

export interface TsConfigLoaderParams {
cwd: string;
searchUpToPackageRoot?: boolean;
}

export function tsConfigLoader({ cwd, }: TsConfigLoaderParams): LoadedTsConfig[] {
const configPath = resolveConfigPath(cwd);
export function tsConfigLoader({ cwd, searchUpToPackageRoot }: TsConfigLoaderParams): LoadedTsConfig[] {
const configPath = resolveConfigPath(cwd, searchUpToPackageRoot);

if (!configPath)
return [];
Expand All @@ -67,27 +76,29 @@ export function tsConfigLoader({ cwd, }: TsConfigLoaderParams): LoadedTsConfig[]
return [config, ...references];
}

function resolveConfigPath(cwd: string): string | undefined {
if (fs.statSync(cwd).isFile()) {
return path.resolve(cwd);
}

const configAbsolutePath = walkForTsConfig(cwd);
function resolveConfigPath(cwd: string, searchUpToPackageRoot?: boolean): string | undefined {
const configAbsolutePath = walkForTsConfig(cwd, searchUpToPackageRoot);
return configAbsolutePath ? path.resolve(configAbsolutePath) : undefined;
}

export function walkForTsConfig(
function walkForTsConfig(
directory: string,
existsSync: (path: string) => boolean = fs.existsSync
searchUpToPackageRoot?: boolean,
): string | undefined {
const tsconfigPath = path.join(directory, "./tsconfig.json");
if (existsSync(tsconfigPath)) {
if (fs.existsSync(tsconfigPath)) {
return tsconfigPath;
}
const jsconfigPath = path.join(directory, "./jsconfig.json");
if (existsSync(jsconfigPath)) {
if (fs.existsSync(jsconfigPath)) {
return jsconfigPath;
}
if (fs.existsSync(path.join(directory, 'package.json'))) {
return undefined;
}
if (!searchUpToPackageRoot) {
return undefined;
}

const parentDirectory = path.join(directory, "../");

Expand All @@ -96,7 +107,7 @@ export function walkForTsConfig(
return undefined;
}

return walkForTsConfig(parentDirectory, existsSync);
return walkForTsConfig(parentDirectory, searchUpToPackageRoot);
}

function resolveConfigFile(baseConfigFile: string, referencedConfigFile: string) {
Expand Down Expand Up @@ -139,6 +150,7 @@ function loadTsConfig(
Object.assign(result, base, { tsConfigPath: configFilePath });
}

const configDir = path.dirname(configFilePath);
if (parsedConfig.compilerOptions?.allowJs !== undefined)
result.allowJs = parsedConfig.compilerOptions.allowJs;
if (parsedConfig.compilerOptions?.paths !== undefined) {
Expand All @@ -148,14 +160,22 @@ function loadTsConfig(
// https://github.com/microsoft/TypeScript/blob/353ccb7688351ae33ccf6e0acb913aa30621eaf4/src/compiler/moduleSpecifiers.ts#L510
result.paths = {
mapping: parsedConfig.compilerOptions.paths,
pathsBasePath: path.dirname(configFilePath),
pathsBasePath: configDir,
};
}
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);
result.absoluteBaseUrl = path.resolve(configDir, parsedConfig.compilerOptions.baseUrl);
}
if (parsedConfig.files)
result.files = parsedConfig.files.map(file => path.resolve(configDir, file));
if (parsedConfig.include)
result.include = parsedConfig.include.map(pattern => path.resolve(configDir, pattern));
if (parsedConfig.exclude)
result.exclude = parsedConfig.exclude.map(pattern => path.resolve(configDir, pattern));
if (parsedConfig.compilerOptions?.outDir)
result.outDir = path.resolve(configDir, parsedConfig.compilerOptions.outDir);

for (const ref of parsedConfig.references || [])
references.push(loadTsConfig(resolveConfigFile(configFilePath, ref.path), references, visited));
Expand Down