diff --git a/packages/playwright/src/transform/transform.ts b/packages/playwright/src/transform/transform.ts index 3ad490d19df72..7e407cbbccb9f 100644 --- a/packages/playwright/src/transform/transform.ts +++ b/packages/playwright/src/transform/transform.ts @@ -23,7 +23,7 @@ import type { LoadedTsConfig } from '../third_party/tsconfig-loader'; import { tsConfigLoader } from '../third_party/tsconfig-loader'; import Module from 'module'; import type { BabelPlugin, BabelTransformFunction } from './babelBundle'; -import { createFileMatcher, fileIsModule, resolveImportSpecifierExtension } from '../util'; +import { createFileMatcher, fileIsModule, resolveImportSpecifierAfterMapping } from '../util'; import type { Matcher } from '../util'; import { getFromCompilationCache, currentFileDepsCollector, belongsToNodeModules, installSourceMapSupport } from './compilationCache'; @@ -99,8 +99,13 @@ export function resolveHook(filename: string, specifier: string): string | undef return; if (isRelativeSpecifier(specifier)) - return resolveImportSpecifierExtension(path.resolve(path.dirname(filename), specifier)); + return resolveImportSpecifierAfterMapping(path.resolve(path.dirname(filename), specifier), false); + /** + * TypeScript discourages path-mapping into node_modules: + * https://www.typescriptlang.org/docs/handbook/modules/reference.html#paths-should-not-point-to-monorepo-packages-or-node_modules-packages + * However, if path-mapping doesn't yield a result, TypeScript falls back to the default resolution through node_modules. + */ const isTypeScript = filename.endsWith('.ts') || filename.endsWith('.tsx'); const tsconfigs = loadAndValidateTsconfigsForFile(filename); for (const tsconfig of tsconfigs) { @@ -142,7 +147,7 @@ export function resolveHook(filename: string, specifier: string): string | undef if (value.includes('*')) candidate = candidate.replace('*', matchedPartOfSpecifier); candidate = path.resolve(tsconfig.pathsBase!, candidate); - const existing = resolveImportSpecifierExtension(candidate); + const existing = resolveImportSpecifierAfterMapping(candidate, true); if (existing) { longestPrefixLength = keyPrefix.length; pathMatchedByLongestPrefix = existing; @@ -156,7 +161,7 @@ export function resolveHook(filename: string, specifier: string): string | undef if (path.isAbsolute(specifier)) { // Handle absolute file paths like `import '/path/to/file'` // Do not handle module imports like `import 'fs'` - return resolveImportSpecifierExtension(specifier); + return resolveImportSpecifierAfterMapping(specifier, false); } } diff --git a/packages/playwright/src/util.ts b/packages/playwright/src/util.ts index a4ddce7a3b43b..f8f70451c5afa 100644 --- a/packages/playwright/src/util.ts +++ b/packages/playwright/src/util.ts @@ -295,8 +295,23 @@ function folderIsModule(folder: string): boolean { return require(packageJsonPath).type === 'module'; } -// This follows the --moduleResolution=bundler strategy from tsc. -// https://devblogs.microsoft.com/typescript/announcing-typescript-5-0-beta/#moduleresolution-bundler +const packageJsonMainFieldCache = new Map(); + +function getMainFieldFromPackageJson(packageJsonPath: string) { + if (!packageJsonMainFieldCache.has(packageJsonPath)) { + let mainField: string | undefined; + try { + mainField = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')).main; + } catch { + } + packageJsonMainFieldCache.set(packageJsonPath, mainField); + } + return packageJsonMainFieldCache.get(packageJsonPath); +} + +// This method performs "file extension subsitution" to find the ts, js or similar source file +// based on the import specifier, which might or might not have an extension. See TypeScript docs: +// https://www.typescriptlang.org/docs/handbook/modules/reference.html#file-extension-substitution. const kExtLookups = new Map([ ['.js', ['.jsx', '.ts', '.tsx']], ['.jsx', ['.tsx']], @@ -304,7 +319,7 @@ const kExtLookups = new Map([ ['.mjs', ['.mts']], ['', ['.js', '.ts', '.jsx', '.tsx', '.cjs', '.mjs', '.cts', '.mts']], ]); -export function resolveImportSpecifierExtension(resolved: string): string | undefined { +function resolveImportSpecifierExtension(resolved: string): string | undefined { if (fileExists(resolved)) return resolved; @@ -318,13 +333,45 @@ export function resolveImportSpecifierExtension(resolved: string): string | unde } break; // Do not try '' when a more specific extension like '.jsx' matched. } +} + +// This method resolves directory imports and performs "file extension subsitution". +// It is intended to be called after the path mapping resolution. +// +// Directory imports follow the --moduleResolution=bundler strategy from tsc. +// https://www.typescriptlang.org/docs/handbook/modules/reference.html#directory-modules-index-file-resolution +// https://www.typescriptlang.org/docs/handbook/modules/reference.html#bundler +// +// See also Node.js "folder as module" behavior: +// https://nodejs.org/dist/latest-v20.x/docs/api/modules.html#folders-as-modules. +export function resolveImportSpecifierAfterMapping(resolved: string, afterPathMapping: boolean): string | undefined { + const resolvedFile = resolveImportSpecifierExtension(resolved); + if (resolvedFile) + return resolvedFile; if (dirExists(resolved)) { + const packageJsonPath = path.join(resolved, 'package.json'); + + if (afterPathMapping) { + // Most notably, the module resolution algorithm is not performed after the path mapping. + // This means no node_modules lookup or package.json#exports. + // + // Only the "folder as module" Node.js behavior is respected: + // - consult `package.json#main`; + // - look for `index.js` or similar. + const mainField = getMainFieldFromPackageJson(packageJsonPath); + const mainFieldResolved = mainField ? resolveImportSpecifierExtension(path.resolve(resolved, mainField)) : undefined; + return mainFieldResolved || resolveImportSpecifierExtension(path.join(resolved, 'index')); + } + // If we import a package, let Node.js figure out the correct import based on package.json. - if (fileExists(path.join(resolved, 'package.json'))) + // This also covers the "main" field for "folder as module". + if (fileExists(packageJsonPath)) return resolved; - // Otherwise, try to find a corresponding index file. + // Implement the "folder as module" Node.js behavior. + // Note that we do not delegate to Node.js, because we support this for ESM as well, + // following the TypeScript "bundler" mode. const dirImport = path.join(resolved, 'index'); return resolveImportSpecifierExtension(dirImport); } diff --git a/tests/playwright-test/resolver.spec.ts b/tests/playwright-test/resolver.spec.ts index 5a0e91b099412..7194f7defe04c 100644 --- a/tests/playwright-test/resolver.spec.ts +++ b/tests/playwright-test/resolver.spec.ts @@ -569,43 +569,6 @@ test('should resolve paths relative to the originating config when extending and expect(result.exitCode).toBe(0); }); -test('should import packages with non-index main script through path resolver', async ({ runInlineTest }) => { - const result = await runInlineTest({ - 'app/pkg/main.ts': ` - export const foo = 42; - `, - 'app/pkg/package.json': ` - { "main": "main.ts" } - `, - 'package.json': ` - { "name": "example-project" } - `, - 'playwright.config.ts': ` - export default {}; - `, - 'tsconfig.json': `{ - "compilerOptions": { - "baseUrl": ".", - "paths": { - "app/*": ["app/*"], - }, - }, - }`, - 'example.spec.ts': ` - import { foo } from 'app/pkg'; - import { test, expect } from '@playwright/test'; - test('test', ({}) => { - console.log('foo=' + foo); - }); - `, - }); - - expect(result.exitCode).toBe(0); - expect(result.passed).toBe(1); - expect(result.output).not.toContain(`find module`); - expect(result.output).toContain(`foo=42`); -}); - test('should respect tsconfig project references', async ({ runInlineTest }) => { test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/29256' }); @@ -693,3 +656,426 @@ test('should respect --tsconfig option', async ({ runInlineTest }) => { expect(result.exitCode).toBe(0); expect(result.output).not.toContain(`Could not`); }); + +test.describe('directory imports', () => { + test('should resolve index.js without path mapping in CJS', async ({ runInlineTest, runTSC }) => { + const files = { + 'foo-pkg/index.js': ` + exports.foo = 'bar'; + `, + 'foo-pkg/index.d.ts': ` + export const foo: 'bar'; + `, + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + import { foo } from './foo-pkg'; + test('pass', async () => { + const bar: 'bar' = foo; + expect(bar).toBe('bar'); + }); + `, + }; + + const result = await runInlineTest(files); + expect(result.passed).toBe(1); + expect(result.exitCode).toBe(0); + + const tscResult = await runTSC(files); + expect(tscResult.exitCode).toBe(0); + }); + + test('should resolve index.js without path mapping in ESM', async ({ runInlineTest, runTSC }) => { + const files = { + 'foo-pkg/index.js': ` + export const foo = 'bar'; + `, + 'foo-pkg/index.d.ts': ` + export const foo: 'bar'; + `, + 'package.json': ` + { "type": "module" } + `, + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + import { foo } from './foo-pkg'; + test('pass', async () => { + const bar: 'bar' = foo; + expect(bar).toBe('bar'); + }); + `, + }; + + const result = await runInlineTest(files); + expect(result.passed).toBe(1); + expect(result.exitCode).toBe(0); + + const tscResult = await runTSC(files); + expect(tscResult.exitCode).toBe(0); + }); + + test('should resolve index.js after path mapping in CJS', async ({ runInlineTest, runTSC }) => { + test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/31811' }); + + const files = { + '@acme/lib/index.js': ` + exports.greet = () => 2; + `, + '@acme/lib/index.d.ts': ` + export const greet: () => number; + `, + 'tests/hello.test.ts': ` + import { greet } from '@acme/lib'; + import { test, expect } from '@playwright/test'; + test('hello', async ({}) => { + const foo: number = greet(); + expect(foo).toBe(2); + }); + `, + 'tsconfig.json': ` + { + "compilerOptions": { + "paths": { + "@acme/*": ["./@acme/*"] + }, + "moduleResolution": "bundler", + "module": "preserve", + "noEmit": true, + "noImplicitAny": true + } + } + `, + }; + + const result = await runInlineTest(files); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); + + const tscResult = await runTSC(files); + expect(tscResult.exitCode).toBe(0); + }); + + test('should resolve index.js after path mapping in ESM', async ({ runInlineTest, runTSC }) => { + test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/31811' }); + + const files = { + '@acme/lib/index.js': ` + export const greet = () => 2; + `, + '@acme/lib/index.d.ts': ` + export const greet: () => number; + `, + 'package.json': ` + { "type": "module" } + `, + 'tests/hello.test.ts': ` + import { greet } from '@acme/lib'; + import { test, expect } from '@playwright/test'; + test('hello', async ({}) => { + const foo: number = greet(); + expect(foo).toBe(2); + }); + `, + 'tsconfig.json': ` + { + "compilerOptions": { + "paths": { + "@acme/*": ["./@acme/*"] + }, + "moduleResolution": "bundler", + "module": "preserve", + "noEmit": true, + "noImplicitAny": true + } + } + `, + }; + + const result = await runInlineTest(files); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); + + const tscResult = await runTSC(files); + expect(tscResult.exitCode).toBe(0); + }); + + test('should respect package.json#main after path mapping in CJS', async ({ runInlineTest, runTSC }) => { + const files = { + 'app/pkg/main.ts': ` + export const foo = 42; + `, + 'app/pkg/package.json': ` + { "main": "main.ts" } + `, + 'package.json': ` + { "name": "example-project" } + `, + 'playwright.config.ts': ` + export default {}; + `, + 'tsconfig.json': ` + { + "compilerOptions": { + "baseUrl": ".", + "paths": { + "app/*": ["app/*"] + }, + "moduleResolution": "bundler", + "module": "preserve", + "noEmit": true, + "noImplicitAny": true + } + } + `, + 'example.spec.ts': ` + import { foo } from 'app/pkg'; + import { test, expect } from '@playwright/test'; + test('test', ({}) => { + const bar: number = foo; + expect(bar).toBe(42); + }); + `, + }; + + const result = await runInlineTest(files); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); + expect(result.output).not.toContain(`find module`); + + const tscResult = await runTSC(files); + expect(tscResult.exitCode).toBe(0); + }); + + test('should respect package.json#main after path mapping in ESM', async ({ runInlineTest, runTSC }) => { + const files = { + 'app/pkg/main.ts': ` + export const foo = 42; + `, + 'app/pkg/package.json': ` + { "main": "main.ts", "type": "module" } + `, + 'package.json': ` + { "name": "example-project", "type": "module" } + `, + 'playwright.config.ts': ` + export default {}; + `, + 'tsconfig.json': ` + { + "compilerOptions": { + "baseUrl": ".", + "paths": { + "app/*": ["app/*"] + }, + "moduleResolution": "bundler", + "module": "preserve", + "noEmit": true, + "noImplicitAny": true + }, + } + `, + 'example.spec.ts': ` + import { foo } from 'app/pkg'; + import { test, expect } from '@playwright/test'; + test('test', ({}) => { + const bar: number = foo; + expect(bar).toBe(42); + }); + `, + }; + + const result = await runInlineTest(files); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); + + const tscResult = await runTSC(files); + expect(tscResult.exitCode).toBe(0); + }); + + test('should respect package.json#exports without path mapping in CJS', async ({ runInlineTest, runTSC }) => { + const files = { + 'node_modules/foo-pkg/package.json': ` + { "name": "foo-pkg", "exports": { ".": "./foo.js" } } + `, + 'node_modules/foo-pkg/foo.js': ` + exports.foo = 'bar'; + `, + 'node_modules/foo-pkg/foo.d.ts': ` + export const foo: 'bar'; + `, + 'package.json': ` + { "name": "test-project" } + `, + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + import { foo } from 'foo-pkg'; + test('pass', async () => { + const bar: 'bar' = foo; + expect(bar).toBe('bar'); + }); + `, + 'tsconfig.json': ` + { + "compilerOptions": { + "moduleResolution": "bundler", + "module": "preserve", + "noEmit": true, + "noImplicitAny": true + }, + } + `, + }; + + const result = await runInlineTest(files); + expect(result.passed).toBe(1); + expect(result.exitCode).toBe(0); + + const tscResult = await runTSC(files); + expect(tscResult.exitCode).toBe(0); + }); + + test('should respect package.json#exports without path mapping in ESM', async ({ runInlineTest, runTSC }) => { + const files = { + 'node_modules/foo-pkg/package.json': ` + { "name": "foo-pkg", "type": "module", "exports": { "default": "./foo.js" } } + `, + 'node_modules/foo-pkg/foo.js': ` + export const foo = 'bar'; + `, + 'node_modules/foo-pkg/foo.d.ts': ` + export const foo: 'bar'; + `, + 'package.json': ` + { "name": "test-project", "type": "module" } + `, + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + import { foo } from 'foo-pkg'; + test('pass', async () => { + const bar: 'bar' = foo; + expect(bar).toBe('bar'); + }); + `, + 'tsconfig.json': ` + { + "compilerOptions": { + "moduleResolution": "bundler", + "module": "preserve", + "noEmit": true, + "noImplicitAny": true + }, + } + `, + }; + + const result = await runInlineTest(files); + expect(result.passed).toBe(1); + expect(result.exitCode).toBe(0); + + const tscResult = await runTSC(files); + expect(tscResult.exitCode).toBe(0); + }); + + test('should not respect package.json#exports after type mapping in CJS', async ({ runInlineTest, runTSC }) => { + const files = { + 'app/pkg/main.ts': ` + export const filename: 'main.ts' = 'main.ts'; + `, + 'app/pkg/index.js': ` + export const filename = 'index.js'; + `, + 'app/pkg/index.d.ts': ` + export const filename: 'index.js'; + `, + 'app/pkg/package.json': ` + { "exports": { ".": "./main.ts" } } + `, + 'package.json': ` + { "name": "example-project" } + `, + 'playwright.config.ts': ` + export default {}; + `, + 'tsconfig.json': ` + { + "compilerOptions": { + "baseUrl": ".", + "paths": { + "app/*": ["app/*"] + }, + "moduleResolution": "bundler", + "module": "preserve", + "noEmit": true, + "noImplicitAny": true + } + } + `, + 'example.spec.ts': ` + import { filename } from 'app/pkg'; + import { test, expect } from '@playwright/test'; + test('test', ({}) => { + const foo: 'index.js' = filename; + expect(foo).toBe('index.js'); + }); + `, + }; + + const result = await runInlineTest(files); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); + + const tscResult = await runTSC(files); + expect(tscResult.exitCode).toBe(0); + }); + + test('should not respect package.json#exports after type mapping in ESM', async ({ runInlineTest, runTSC }) => { + const files = { + 'app/pkg/main.ts': ` + export const filename: 'main.ts' = 'main.ts'; + `, + 'app/pkg/index.js': ` + export const filename = 'index.js'; + `, + 'app/pkg/index.d.ts': ` + export const filename: 'index.js'; + `, + 'app/pkg/package.json': ` + { "exports": { ".": "./main.ts" }, "type": "module" } + `, + 'package.json': ` + { "name": "example-project", "type": "module" } + `, + 'playwright.config.ts': ` + export default {}; + `, + 'tsconfig.json': ` + { + "compilerOptions": { + "baseUrl": ".", + "paths": { + "app/*": ["app/*"] + }, + "moduleResolution": "bundler", + "module": "preserve", + "noEmit": true, + "noImplicitAny": true + } + } + `, + 'example.spec.ts': ` + import { filename } from 'app/pkg'; + import { test, expect } from '@playwright/test'; + test('test', ({}) => { + const foo: 'index.js' = filename; + expect(foo).toBe('index.js'); + }); + `, + }; + + const result = await runInlineTest(files); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); + + const tscResult = await runTSC(files); + expect(tscResult.exitCode).toBe(0); + }); +});