From cd49f5c4668ca6ed4cc024bdacc0d27960049688 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Tue, 9 May 2023 16:26:29 -0700 Subject: [PATCH] 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. --- docs/src/test-typescript-js.md | 68 +--------- .../playwright-ct-core/src/tsxTransform.ts | 6 +- .../playwright-test/src/common/transform.ts | 27 ++-- packages/playwright-test/src/util.ts | 32 +++-- tests/playwright-test/esm.spec.ts | 88 +++++++++++++ tests/playwright-test/loader.spec.ts | 119 ++++++++++++++++++ 6 files changed, 244 insertions(+), 96 deletions(-) diff --git a/docs/src/test-typescript-js.md b/docs/src/test-typescript-js.md index 8ed0ad13c1..c89d9e56f3 100644 --- a/docs/src/test-typescript-js.md +++ b/docs/src/test-typescript-js.md @@ -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 diff --git a/packages/playwright-ct-core/src/tsxTransform.ts b/packages/playwright-ct-core/src/tsxTransform.ts index 43493ab5ab..5c6acd7889 100644 --- a/packages/playwright-ct-core/src/tsxTransform.ts +++ b/packages/playwright-ct-core/src/tsxTransform.ts @@ -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(); @@ -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 }; diff --git a/packages/playwright-test/src/common/transform.ts b/packages/playwright-test/src/common/transform.ts index b1b547e683..84691dea78 100644 --- a/packages/playwright-test/src/common/transform.ts +++ b/packages/playwright-test/src/common/transform.ts @@ -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 { diff --git a/packages/playwright-test/src/util.ts b/packages/playwright-test/src/util.ts index b7b5d1c64e..df64becd94 100644 --- a/packages/playwright-test/src/util.ts +++ b/packages/playwright-test/src/util.ts @@ -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. + } } diff --git a/tests/playwright-test/esm.spec.ts b/tests/playwright-test/esm.spec.ts index 852605a578..6053244545 100644 --- a/tests/playwright-test/esm.spec.ts +++ b/tests/playwright-test/esm.spec.ts @@ -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({ diff --git a/tests/playwright-test/loader.spec.ts b/tests/playwright-test/loader.spec.ts index 5671b09855..8c0caaf868 100644 --- a/tests/playwright-test/loader.spec.ts +++ b/tests/playwright-test/loader.spec.ts @@ -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);