diff --git a/packages/playwright/src/transform/transform.ts b/packages/playwright/src/transform/transform.ts index c88c228f4188a..6957e516fee56 100644 --- a/packages/playwright/src/transform/transform.ts +++ b/packages/playwright/src/transform/transform.ts @@ -132,6 +132,54 @@ function loadAndValidateTsconfigsForFolder(folder: string): ParsedTsConfigData[] const pathSeparator = process.platform === 'win32' ? ';' : ':'; const builtins = new Set(Module.builtinModules); +function resolvePackageSubpathImport(filename: string, specifier: string): string | undefined { + if (specifier.startsWith('#')) + return; + + const tokens = specifier.split('/'); + const packageName = specifier.startsWith('@') ? tokens.slice(0, 2).join('/') : tokens[0]; + const subpath = specifier.startsWith('@') ? tokens.slice(2).join('/') : tokens.slice(1).join('/'); + if (!packageName || !subpath) + return; + + let currentFolder = path.dirname(filename); + while (true) { + const packageJsonPath = path.join(currentFolder, 'node_modules', packageName, 'package.json'); + if (fs.existsSync(packageJsonPath)) { + const packageJson = readPackageJson(packageJsonPath); + if (!packageJson) + return; + if (packageJson.exports !== undefined) + return; + const resolved = resolveImportSpecifierAfterMapping(path.join(path.dirname(packageJsonPath), subpath), true); + return resolved ? fs.realpathSync(resolved) : undefined; + } + + const parentFolder = path.dirname(currentFolder); + if (currentFolder === parentFolder) + break; + currentFolder = parentFolder; + } +} + +type PackageJsonWithExports = { + exports?: unknown; +}; + +const packageJsonCache = new Map(); + +function readPackageJson(packageJsonPath: string): PackageJsonWithExports | undefined { + if (!packageJsonCache.has(packageJsonPath)) { + let packageJson; + try { + packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + } catch { + } + packageJsonCache.set(packageJsonPath, packageJson); + } + return packageJsonCache.get(packageJsonPath); +} + export function resolveHook(filename: string, specifier: string): string | undefined { if (specifier.startsWith('node:') || builtins.has(specifier)) return; @@ -198,6 +246,10 @@ export function resolveHook(filename: string, specifier: string): string | undef return pathMatchedByLongestPrefix; } + const packageSubpath = resolvePackageSubpathImport(filename, specifier); + if (packageSubpath) + return packageSubpath; + if (path.isAbsolute(specifier)) { // Handle absolute file paths like `import '/path/to/file'` // Do not handle module imports like `import 'fs'` diff --git a/tests/playwright-test/esm.spec.ts b/tests/playwright-test/esm.spec.ts index 0e1deb9585917..f39042fd5af4e 100644 --- a/tests/playwright-test/esm.spec.ts +++ b/tests/playwright-test/esm.spec.ts @@ -14,7 +14,9 @@ * limitations under the License. */ -import { test, expect, playwrightCtConfigText } from './playwright-test-fixtures'; +import { cliEntrypoint, test, expect, playwrightCtConfigText } from './playwright-test-fixtures'; +import fs from 'fs'; +import path from 'path'; test('should load nested as esm when package.json has type module', async ({ runInlineTest }) => { const result = await runInlineTest({ @@ -565,6 +567,75 @@ test('should resolve .js import to .tsx file in ESM mode for components', async expect(result.exitCode).toBe(0); }); +test('should resolve no-extension ts package subpath imports through workspace symlinks in ESM mode', { + annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/41371' }, +}, async ({ writeFiles, childProcess, nodeVersion }) => { + test.skip(nodeVersion.major < 22, 'Sync ESM loader requires module.registerHooks'); + + const baseDir = await writeFiles({ + 'package.json': `{ "type": "module" }`, + 'playwright.config.ts': `export default { testDir: './apps/e2e/tests' };`, + 'apps/e2e/package.json': `{ "type": "module" }`, + 'apps/e2e/tests/basic.spec.ts': ` + import { test, expect } from '@playwright/test'; + import { greet } from '@repro/core/lib/conversations'; + import { value } from '@repro/exported/lib/value'; + import { unscopedValue } from 'unscoped/lib/value'; + + test('pass', () => { + expect(greet('world')).toBe('Hello, world'); + expect(value).toBe('dist'); + expect(unscopedValue).toBe('unscoped'); + }); + `, + 'packages/core/package.json': `{ "name": "@repro/core", "type": "module" }`, + 'packages/core/lib/conversations.ts': ` + export { greet } from '@repro/shared/lib/text.utils'; + `, + 'packages/shared/package.json': `{ "name": "@repro/shared", "type": "module" }`, + 'packages/shared/lib/text.utils.ts': ` + export function greet(name: string) { + return 'Hello, ' + name; + } + `, + 'packages/exported/package.json': JSON.stringify({ + name: '@repro/exported', + type: 'module', + exports: { + './lib/value': './dist/value.js', + }, + }), + 'packages/exported/lib/value.ts': ` + export const value = 'source'; + `, + 'packages/exported/dist/value.js': ` + export const value = 'dist'; + `, + 'packages/unscoped/package.json': `{ "name": "unscoped", "type": "module" }`, + 'packages/unscoped/lib/value.ts': ` + export const unscopedValue = 'unscoped'; + `, + }); + + const linkDirectory = async (target: string, link: string) => { + await fs.promises.mkdir(path.dirname(link), { recursive: true }); + await fs.promises.symlink(process.platform === 'win32' ? target : path.relative(path.dirname(link), target), link, process.platform === 'win32' ? 'junction' : 'dir'); + }; + + await linkDirectory(path.join(baseDir, 'packages/core'), path.join(baseDir, 'apps/e2e/node_modules/@repro/core')); + await linkDirectory(path.join(baseDir, 'packages/shared'), path.join(baseDir, 'packages/core/node_modules/@repro/shared')); + await linkDirectory(path.join(baseDir, 'packages/exported'), path.join(baseDir, 'apps/e2e/node_modules/@repro/exported')); + await linkDirectory(path.join(baseDir, 'packages/unscoped'), path.join(baseDir, 'apps/e2e/node_modules/unscoped')); + + const result = childProcess({ + command: ['node', cliEntrypoint, 'test', '--workers=1'], + cwd: baseDir, + }); + const { exitCode } = await result.exited; + expect(result.output).toContain('1 passed'); + expect(exitCode).toBe(0); +}); + test('should load cjs config and test in non-ESM mode', async ({ runInlineTest }) => { const result = await runInlineTest({ 'package.json': `{ "type": "module" }`,