mirror of
https://github.com/microsoft/playwright.git
synced 2024-12-11 12:33:45 +03:00
feat(typescript): align with --moduleResolution=bundler
(#22887)
This relaxes import requirements and allows importing `.ts` files without an extension in CJS and ESM modes. Fixes #22169.
This commit is contained in:
parent
852f1d7881
commit
cd49f5c466
@ -3,73 +3,7 @@ id: test-typescript
|
||||
title: "TypeScript"
|
||||
---
|
||||
|
||||
Playwright Test supports TypeScript out of the box. You just write tests in TypeScript and Playwright Test will read them, transform to JavaScript and run. This works both with [CommonJS modules](https://nodejs.org/api/modules.html) and [ECMAScript modules](https://nodejs.org/api/esm.html).
|
||||
|
||||
## TypeScript with CommonJS
|
||||
|
||||
[Node.js](https://nodejs.org/en/) works with CommonJS modules **by default**. Unless you use `'.mjs'` or `'.mts'` extensions, or specify `type: "module"` in your `package.json`, Playwright Test will treat all TypeScript files as CommonJS. You can then import as usual without an extension.
|
||||
|
||||
Consider this helper module written in TypeScript:
|
||||
|
||||
```js
|
||||
// helper.ts
|
||||
export const username = 'John';
|
||||
export const password = 'secret';
|
||||
```
|
||||
|
||||
You can import from the helper as usual:
|
||||
|
||||
```js
|
||||
// example.spec.ts
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { username, password } from './helper';
|
||||
|
||||
test('example', async ({ page }) => {
|
||||
await page.getByLabel('User Name').fill(username);
|
||||
await page.getByLabel('Password').fill(password);
|
||||
});
|
||||
```
|
||||
|
||||
## TypeScript with ESM
|
||||
|
||||
You can opt into using [ECMAScript modules](https://nodejs.org/api/esm.html) by setting `type: "module"` in your `package.json` file. Playwright Test will switch to the ESM mode once it reads the `playwright.config.ts` file, so make sure you have one.
|
||||
|
||||
Playwright Test follows the [experimental support for ESM in TypeScript](https://www.typescriptlang.org/docs/handbook/esm-node.html) and, according to the specification, **requires a file extension** when importing from a module, either `'.js'` or `'.ts'`.
|
||||
|
||||
First, enable modules in your `package.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-package",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
}
|
||||
```
|
||||
|
||||
Then write the helper module in TypeScript as usual:
|
||||
|
||||
```js
|
||||
// helper.ts
|
||||
export const username = 'John';
|
||||
export const password = 'secret';
|
||||
```
|
||||
|
||||
Specify the extension when importing from a module:
|
||||
|
||||
```js
|
||||
// example.spec.ts
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { username, password } from './helper.ts';
|
||||
|
||||
test('example', async ({ page }) => {
|
||||
await page.getByLabel('User Name').fill(username);
|
||||
await page.getByLabel('Password').fill(password);
|
||||
});
|
||||
```
|
||||
|
||||
:::note
|
||||
TypeScript with ESM requires Node.js 16 or higher.
|
||||
:::
|
||||
Playwright supports TypeScript out of the box. You just write tests in TypeScript, and Playwright will read them, transform to JavaScript and run.
|
||||
|
||||
## tsconfig.json
|
||||
|
||||
|
@ -17,7 +17,7 @@
|
||||
import path from 'path';
|
||||
import type { T, BabelAPI } from '../../playwright-test/src/common/babelBundle';
|
||||
import { types, declare, traverse } from '@playwright/test/lib/common/babelBundle';
|
||||
import { js2ts } from '@playwright/test/lib/util';
|
||||
import { resolveImportSpecifierExtension } from '@playwright/test/lib/util';
|
||||
const t: typeof T = types;
|
||||
|
||||
const fullNames = new Map<string, string | undefined>();
|
||||
@ -176,9 +176,9 @@ export function componentInfo(specifier: T.ImportSpecifier | T.ImportDefaultSpec
|
||||
const isModuleOrAlias = !importSource.startsWith('.');
|
||||
const unresolvedImportPath = path.resolve(path.dirname(filename), importSource);
|
||||
// Support following notations for Button.tsx:
|
||||
// - import { Button } from './Button.js' - via js2ts, it handles tsx too
|
||||
// - import { Button } from './Button.js' - via resolveImportSpecifierExtension
|
||||
// - import { Button } from './Button' - via require.resolve
|
||||
const importPath = isModuleOrAlias ? importSource : js2ts(unresolvedImportPath) || require.resolve(unresolvedImportPath);
|
||||
const importPath = isModuleOrAlias ? importSource : resolveImportSpecifierExtension(unresolvedImportPath) || require.resolve(unresolvedImportPath);
|
||||
const prefix = importPath.replace(/[^\w_\d]/g, '_');
|
||||
const pathInfo = { importPath, isModuleOrAlias };
|
||||
|
||||
|
@ -15,7 +15,6 @@
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { sourceMapSupport, pirates } from '../utilsBundle';
|
||||
import url from 'url';
|
||||
import type { Location } from '../../types/testReporter';
|
||||
@ -23,7 +22,7 @@ import type { TsConfigLoaderResult } from '../third_party/tsconfig-loader';
|
||||
import { tsConfigLoader } from '../third_party/tsconfig-loader';
|
||||
import Module from 'module';
|
||||
import type { BabelTransformFunction } from './babelBundle';
|
||||
import { fileIsModule, js2ts } from '../util';
|
||||
import { fileIsModule, resolveImportSpecifierExtension } from '../util';
|
||||
import { getFromCompilationCache, currentFileDepsCollector, belongsToNodeModules } from './compilationCache';
|
||||
|
||||
type ParsedTsConfigData = {
|
||||
@ -69,7 +68,7 @@ export function resolveHook(filename: string, specifier: string): string | undef
|
||||
return;
|
||||
|
||||
if (isRelativeSpecifier(specifier))
|
||||
return js2ts(path.resolve(path.dirname(filename), specifier));
|
||||
return resolveImportSpecifierExtension(path.resolve(path.dirname(filename), specifier));
|
||||
|
||||
const isTypeScript = filename.endsWith('.ts') || filename.endsWith('.tsx');
|
||||
const tsconfig = loadAndValidateTsconfigForFile(filename);
|
||||
@ -106,22 +105,14 @@ export function resolveHook(filename: string, specifier: string): string | undef
|
||||
continue;
|
||||
|
||||
for (const value of values) {
|
||||
let candidate: string = value;
|
||||
|
||||
let candidate = value;
|
||||
if (value.includes('*'))
|
||||
candidate = candidate.replace('*', matchedPartOfSpecifier);
|
||||
candidate = path.resolve(tsconfig.absoluteBaseUrl, candidate.replace(/\//g, path.sep));
|
||||
const ts = js2ts(candidate);
|
||||
if (ts) {
|
||||
const existing = resolveImportSpecifierExtension(candidate);
|
||||
if (existing) {
|
||||
longestPrefixLength = keyPrefix.length;
|
||||
pathMatchedByLongestPrefix = ts;
|
||||
} else {
|
||||
for (const ext of ['', '.js', '.ts', '.mjs', '.cjs', '.jsx', '.tsx', '.cjs', '.mts', '.cts']) {
|
||||
if (fs.existsSync(candidate + ext)) {
|
||||
longestPrefixLength = keyPrefix.length;
|
||||
pathMatchedByLongestPrefix = candidate + ext;
|
||||
}
|
||||
}
|
||||
pathMatchedByLongestPrefix = existing;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -129,7 +120,11 @@ export function resolveHook(filename: string, specifier: string): string | undef
|
||||
return pathMatchedByLongestPrefix;
|
||||
}
|
||||
|
||||
return js2ts(path.resolve(path.dirname(filename), specifier));
|
||||
if (path.isAbsolute(specifier)) {
|
||||
// Handle absolute file paths like `import '/path/to/file'`
|
||||
// Do not handle module imports like `import 'fs'`
|
||||
return resolveImportSpecifierExtension(specifier);
|
||||
}
|
||||
}
|
||||
|
||||
export function transformHook(code: string, filename: string, moduleUrl?: string): string {
|
||||
|
@ -307,14 +307,26 @@ export function envWithoutExperimentalLoaderOptions(): NodeJS.ProcessEnv {
|
||||
return result;
|
||||
}
|
||||
|
||||
export function js2ts(resolved: string): string | undefined {
|
||||
const match = resolved.match(/(.*)(\.js|\.jsx|\.mjs)$/);
|
||||
if (!match || fs.existsSync(resolved))
|
||||
return;
|
||||
const tsResolved = match[1] + match[2].replace('js', 'ts');
|
||||
if (fs.existsSync(tsResolved))
|
||||
return tsResolved;
|
||||
const tsxResolved = match[1] + match[2].replace('js', 'tsx');
|
||||
if (fs.existsSync(tsxResolved))
|
||||
return tsxResolved;
|
||||
// This follows the --moduleResolution=bundler strategy from tsc.
|
||||
// https://devblogs.microsoft.com/typescript/announcing-typescript-5-0-beta/#moduleresolution-bundler
|
||||
const kExtLookups = new Map([
|
||||
['.js', ['.jsx', '.ts', '.tsx']],
|
||||
['.jsx', ['.tsx']],
|
||||
['.cjs', ['.cts']],
|
||||
['.mjs', ['.mts']],
|
||||
['', ['.js', '.ts', '.jsx', '.tsx', '.cjs', '.mjs', '.cts', '.mts']],
|
||||
]);
|
||||
export function resolveImportSpecifierExtension(resolved: string): string | undefined {
|
||||
if (fs.existsSync(resolved))
|
||||
return resolved;
|
||||
for (const [ext, others] of kExtLookups) {
|
||||
if (!resolved.endsWith(ext))
|
||||
continue;
|
||||
for (const other of others) {
|
||||
const modified = resolved.substring(0, resolved.length - ext.length) + other;
|
||||
if (fs.existsSync(modified))
|
||||
return modified;
|
||||
}
|
||||
break; // Do not try '' when a more specific extesion like '.jsx' matched.
|
||||
}
|
||||
}
|
||||
|
@ -278,6 +278,94 @@ test('should resolve .js import to .tsx file in ESM mode', async ({ runInlineTes
|
||||
expect(result.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
test('should resolve .js import to .jsx file in ESM mode', async ({ runInlineTest, nodeVersion }) => {
|
||||
test.skip(nodeVersion.major < 16);
|
||||
const result = await runInlineTest({
|
||||
'package.json': `{ "type": "module" }`,
|
||||
'playwright.config.ts': `export default { projects: [{name: 'foo'}] };`,
|
||||
'a.test.ts': `
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { gimmeAOne } from './playwright-utils.js';
|
||||
test('pass', ({}) => {
|
||||
expect(gimmeAOne()).toBe(1);
|
||||
});
|
||||
`,
|
||||
'playwright-utils.jsx': `
|
||||
export function gimmeAOne() {
|
||||
return 1;
|
||||
}
|
||||
`,
|
||||
});
|
||||
expect(result.passed).toBe(1);
|
||||
expect(result.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
test('should resolve no-extension import to .ts file in ESM mode', async ({ runInlineTest, nodeVersion }) => {
|
||||
test.skip(nodeVersion.major < 16);
|
||||
const result = await runInlineTest({
|
||||
'package.json': `{ "type": "module" }`,
|
||||
'playwright.config.ts': `export default { projects: [{name: 'foo'}] };`,
|
||||
'a.test.ts': `
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { gimmeAOne } from './playwright-utils';
|
||||
test('pass', ({}) => {
|
||||
expect(gimmeAOne()).toBe(1);
|
||||
});
|
||||
`,
|
||||
'playwright-utils.ts': `
|
||||
export function gimmeAOne() {
|
||||
return 1;
|
||||
}
|
||||
`,
|
||||
});
|
||||
expect(result.passed).toBe(1);
|
||||
expect(result.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
test('should resolve no-extension import to .tsx file in ESM mode', async ({ runInlineTest, nodeVersion }) => {
|
||||
test.skip(nodeVersion.major < 16);
|
||||
const result = await runInlineTest({
|
||||
'package.json': `{ "type": "module" }`,
|
||||
'playwright.config.ts': `export default { projects: [{name: 'foo'}] };`,
|
||||
'a.test.ts': `
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { gimmeAOne } from './playwright-utils';
|
||||
test('pass', ({}) => {
|
||||
expect(gimmeAOne()).toBe(1);
|
||||
});
|
||||
`,
|
||||
'playwright-utils.tsx': `
|
||||
export function gimmeAOne() {
|
||||
return 1;
|
||||
}
|
||||
`,
|
||||
});
|
||||
expect(result.passed).toBe(1);
|
||||
expect(result.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
test('should resolve no-extension import to .jsx file in ESM mode', async ({ runInlineTest, nodeVersion }) => {
|
||||
test.skip(nodeVersion.major < 16);
|
||||
const result = await runInlineTest({
|
||||
'package.json': `{ "type": "module" }`,
|
||||
'playwright.config.ts': `export default { projects: [{name: 'foo'}] };`,
|
||||
'a.test.ts': `
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { gimmeAOne } from './playwright-utils';
|
||||
test('pass', ({}) => {
|
||||
expect(gimmeAOne()).toBe(1);
|
||||
});
|
||||
`,
|
||||
'playwright-utils.jsx': `
|
||||
export function gimmeAOne() {
|
||||
return 1;
|
||||
}
|
||||
`,
|
||||
});
|
||||
expect(result.passed).toBe(1);
|
||||
expect(result.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
test('should resolve .js import to .tsx file in ESM mode for components', async ({ runInlineTest, nodeVersion }) => {
|
||||
test.skip(nodeVersion.major < 16);
|
||||
const result = await runInlineTest({
|
||||
|
@ -632,6 +632,125 @@ test('should import export assignment from ts', async ({ runInlineTest }) => {
|
||||
expect(result.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
test('should resolve no-extension import to .ts file in non-ESM mode', async ({ runInlineTest }) => {
|
||||
const result = await runInlineTest({
|
||||
'a.test.ts': `
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { gimmeAOne } from './playwright-utils';
|
||||
test('pass', ({}) => {
|
||||
expect(gimmeAOne()).toBe(1);
|
||||
});
|
||||
`,
|
||||
'playwright-utils.ts': `
|
||||
export function gimmeAOne() {
|
||||
return 1;
|
||||
}
|
||||
`,
|
||||
});
|
||||
expect(result.passed).toBe(1);
|
||||
expect(result.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
test('should resolve no-extension import to .tsx file in non-ESM mode', async ({ runInlineTest }) => {
|
||||
const result = await runInlineTest({
|
||||
'a.test.ts': `
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { gimmeAOne } from './playwright-utils';
|
||||
test('pass', ({}) => {
|
||||
expect(gimmeAOne()).toBe(1);
|
||||
});
|
||||
`,
|
||||
'playwright-utils.tsx': `
|
||||
export function gimmeAOne() {
|
||||
return 1;
|
||||
}
|
||||
`,
|
||||
});
|
||||
expect(result.passed).toBe(1);
|
||||
expect(result.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
test('should resolve no-extension import to .jsx file in non-ESM mode', async ({ runInlineTest }) => {
|
||||
const result = await runInlineTest({
|
||||
'a.test.ts': `
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { gimmeAOne } from './playwright-utils';
|
||||
test('pass', ({}) => {
|
||||
expect(gimmeAOne()).toBe(1);
|
||||
});
|
||||
`,
|
||||
'playwright-utils.jsx': `
|
||||
export function gimmeAOne() {
|
||||
return 1;
|
||||
}
|
||||
`,
|
||||
});
|
||||
expect(result.passed).toBe(1);
|
||||
expect(result.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
test('should not resolve .mjs import to .ts file in non-ESM mode', async ({ runInlineTest }) => {
|
||||
const result = await runInlineTest({
|
||||
'a.test.ts': `
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { gimmeAOne } from './playwright-utils.mjs';
|
||||
test('pass', ({}) => {
|
||||
expect(gimmeAOne()).toBe(1);
|
||||
});
|
||||
`,
|
||||
'playwright-utils.ts': `
|
||||
export function gimmeAOne() {
|
||||
return 1;
|
||||
}
|
||||
`,
|
||||
});
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.output).toContain(`Cannot find module './playwright-utils.mjs'`);
|
||||
});
|
||||
|
||||
test('should resolve absolute .js import to .ts file', async ({ runInlineTest }) => {
|
||||
const filePath = test.info().outputPath('playwright-utils.js');
|
||||
const result = await runInlineTest({
|
||||
'a.test.ts': `
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { gimmeAOne } from ${JSON.stringify(filePath)};
|
||||
test('pass', ({}) => {
|
||||
expect(gimmeAOne()).toBe(1);
|
||||
});
|
||||
`,
|
||||
'playwright-utils.ts': `
|
||||
export function gimmeAOne() {
|
||||
return 1;
|
||||
}
|
||||
`,
|
||||
});
|
||||
expect(result.passed).toBe(1);
|
||||
expect(result.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
test('should resolve no-extension import of module into .ts file', async ({ runInlineTest }) => {
|
||||
const result = await runInlineTest({
|
||||
'node_modules/playwright-utils/index.js': `
|
||||
exports.foo = 42;
|
||||
`,
|
||||
'a.test.ts': `
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { gimmeAOne } from './playwright-utils';
|
||||
test('pass', ({}) => {
|
||||
expect(gimmeAOne()).toBe(1);
|
||||
});
|
||||
`,
|
||||
'playwright-utils.ts': `
|
||||
import { foo } from 'playwright-utils';
|
||||
export function gimmeAOne() {
|
||||
return foo - 41;
|
||||
}
|
||||
`,
|
||||
});
|
||||
expect(result.passed).toBe(1);
|
||||
expect(result.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
test('should support node imports', async ({ runInlineTest, nodeVersion }) => {
|
||||
// We only support experimental esm mode on Node 16+
|
||||
test.skip(nodeVersion.major < 16);
|
||||
|
Loading…
Reference in New Issue
Block a user