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(typescript): align with --moduleResolution=bundler #22887

Merged
merged 1 commit into from
May 9, 2023
Merged
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
68 changes: 1 addition & 67 deletions docs/src/test-typescript-js.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 3 additions & 3 deletions packages/playwright-ct-core/src/tsxTransform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>();
Expand Down Expand Up @@ -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 };

Expand Down
27 changes: 11 additions & 16 deletions packages/playwright-test/src/common/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,14 @@
*/

import path from 'path';
import fs from 'fs';
import { sourceMapSupport, pirates } from '../utilsBundle';
import url from 'url';
import type { Location } from '../../types/testReporter';
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 = {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -106,30 +105,26 @@ 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;
}
}
}
if (pathMatchedByLongestPrefix)
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 {
Expand Down
32 changes: 22 additions & 10 deletions packages/playwright-test/src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like you could move it outside of the loop: resolved.substring(0, resolved.length - ext.length)

if (fs.existsSync(modified))
return modified;
}
break; // Do not try '' when a more specific extesion like '.jsx' matched.
}
}
88 changes: 88 additions & 0 deletions tests/playwright-test/esm.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down