Skip to content

Commit

Permalink
feat(typescript): align with --moduleResolution=bundler
Browse files Browse the repository at this point in the history
This relaxes import requirements and allows importing `.ts` files without
an extenstion in CJS and ESM modes.
  • Loading branch information
dgozman committed May 8, 2023
1 parent 1f20920 commit 731a83b
Show file tree
Hide file tree
Showing 6 changed files with 245 additions and 35 deletions.
8 changes: 2 additions & 6 deletions docs/src/test-typescript-js.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +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).
Playwright supports TypeScript out of the box. You just write tests in TypeScript, and Playwright 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

Expand Down Expand Up @@ -34,7 +34,7 @@ test('example', async ({ page }) => {

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'`.
Playwright follows the [`--moduleResolution=bundler`](https://devblogs.microsoft.com/typescript/announcing-typescript-5-0-beta/#moduleresolution-bundler) strategy, and supports imports with and without an extension. This means you can import files as `'helper.js'`, `'helper.ts'` or just `'helper'`.

First, enable modules in your `package.json`:

Expand Down Expand Up @@ -67,10 +67,6 @@ test('example', async ({ page }) => {
});
```

:::note
TypeScript with ESM requires Node.js 16 or higher.
:::

## 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`.
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', '.jsx', '.ts', '.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.
}
}
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
119 changes: 119 additions & 0 deletions tests/playwright-test/loader.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 '${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);
Expand Down

0 comments on commit 731a83b

Please sign in to comment.