Skip to content

Commit 13e6e48

Browse files
authored
cherry-pick(#32491): fix(test runner): allow directory imports with path mapping (#32571)
We now hopefully align with `moduleResolution: bundler` tsconfig option, allowing directory imports in every scenario, and allowing proper module imports when not going through the type mapping. This regressed in #32078. Fixes #32480, fixes #31811.
1 parent 8762407 commit 13e6e48

File tree

3 files changed

+484
-46
lines changed

3 files changed

+484
-46
lines changed

packages/playwright/src/transform/transform.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import type { LoadedTsConfig } from '../third_party/tsconfig-loader';
2323
import { tsConfigLoader } from '../third_party/tsconfig-loader';
2424
import Module from 'module';
2525
import type { BabelPlugin, BabelTransformFunction } from './babelBundle';
26-
import { createFileMatcher, fileIsModule, resolveImportSpecifierExtension } from '../util';
26+
import { createFileMatcher, fileIsModule, resolveImportSpecifierAfterMapping } from '../util';
2727
import type { Matcher } from '../util';
2828
import { getFromCompilationCache, currentFileDepsCollector, belongsToNodeModules, installSourceMapSupport } from './compilationCache';
2929

@@ -99,8 +99,13 @@ export function resolveHook(filename: string, specifier: string): string | undef
9999
return;
100100

101101
if (isRelativeSpecifier(specifier))
102-
return resolveImportSpecifierExtension(path.resolve(path.dirname(filename), specifier));
102+
return resolveImportSpecifierAfterMapping(path.resolve(path.dirname(filename), specifier), false);
103103

104+
/**
105+
* TypeScript discourages path-mapping into node_modules:
106+
* https://www.typescriptlang.org/docs/handbook/modules/reference.html#paths-should-not-point-to-monorepo-packages-or-node_modules-packages
107+
* However, if path-mapping doesn't yield a result, TypeScript falls back to the default resolution through node_modules.
108+
*/
104109
const isTypeScript = filename.endsWith('.ts') || filename.endsWith('.tsx');
105110
const tsconfigs = loadAndValidateTsconfigsForFile(filename);
106111
for (const tsconfig of tsconfigs) {
@@ -142,7 +147,7 @@ export function resolveHook(filename: string, specifier: string): string | undef
142147
if (value.includes('*'))
143148
candidate = candidate.replace('*', matchedPartOfSpecifier);
144149
candidate = path.resolve(tsconfig.pathsBase!, candidate);
145-
const existing = resolveImportSpecifierExtension(candidate);
150+
const existing = resolveImportSpecifierAfterMapping(candidate, true);
146151
if (existing) {
147152
longestPrefixLength = keyPrefix.length;
148153
pathMatchedByLongestPrefix = existing;
@@ -156,7 +161,7 @@ export function resolveHook(filename: string, specifier: string): string | undef
156161
if (path.isAbsolute(specifier)) {
157162
// Handle absolute file paths like `import '/path/to/file'`
158163
// Do not handle module imports like `import 'fs'`
159-
return resolveImportSpecifierExtension(specifier);
164+
return resolveImportSpecifierAfterMapping(specifier, false);
160165
}
161166
}
162167

packages/playwright/src/util.ts

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -295,16 +295,31 @@ function folderIsModule(folder: string): boolean {
295295
return require(packageJsonPath).type === 'module';
296296
}
297297

298-
// This follows the --moduleResolution=bundler strategy from tsc.
299-
// https://devblogs.microsoft.com/typescript/announcing-typescript-5-0-beta/#moduleresolution-bundler
298+
const packageJsonMainFieldCache = new Map<string, string | undefined>();
299+
300+
function getMainFieldFromPackageJson(packageJsonPath: string) {
301+
if (!packageJsonMainFieldCache.has(packageJsonPath)) {
302+
let mainField: string | undefined;
303+
try {
304+
mainField = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')).main;
305+
} catch {
306+
}
307+
packageJsonMainFieldCache.set(packageJsonPath, mainField);
308+
}
309+
return packageJsonMainFieldCache.get(packageJsonPath);
310+
}
311+
312+
// This method performs "file extension subsitution" to find the ts, js or similar source file
313+
// based on the import specifier, which might or might not have an extension. See TypeScript docs:
314+
// https://www.typescriptlang.org/docs/handbook/modules/reference.html#file-extension-substitution.
300315
const kExtLookups = new Map([
301316
['.js', ['.jsx', '.ts', '.tsx']],
302317
['.jsx', ['.tsx']],
303318
['.cjs', ['.cts']],
304319
['.mjs', ['.mts']],
305320
['', ['.js', '.ts', '.jsx', '.tsx', '.cjs', '.mjs', '.cts', '.mts']],
306321
]);
307-
export function resolveImportSpecifierExtension(resolved: string): string | undefined {
322+
function resolveImportSpecifierExtension(resolved: string): string | undefined {
308323
if (fileExists(resolved))
309324
return resolved;
310325

@@ -318,13 +333,45 @@ export function resolveImportSpecifierExtension(resolved: string): string | unde
318333
}
319334
break; // Do not try '' when a more specific extension like '.jsx' matched.
320335
}
336+
}
337+
338+
// This method resolves directory imports and performs "file extension subsitution".
339+
// It is intended to be called after the path mapping resolution.
340+
//
341+
// Directory imports follow the --moduleResolution=bundler strategy from tsc.
342+
// https://www.typescriptlang.org/docs/handbook/modules/reference.html#directory-modules-index-file-resolution
343+
// https://www.typescriptlang.org/docs/handbook/modules/reference.html#bundler
344+
//
345+
// See also Node.js "folder as module" behavior:
346+
// https://nodejs.org/dist/latest-v20.x/docs/api/modules.html#folders-as-modules.
347+
export function resolveImportSpecifierAfterMapping(resolved: string, afterPathMapping: boolean): string | undefined {
348+
const resolvedFile = resolveImportSpecifierExtension(resolved);
349+
if (resolvedFile)
350+
return resolvedFile;
321351

322352
if (dirExists(resolved)) {
353+
const packageJsonPath = path.join(resolved, 'package.json');
354+
355+
if (afterPathMapping) {
356+
// Most notably, the module resolution algorithm is not performed after the path mapping.
357+
// This means no node_modules lookup or package.json#exports.
358+
//
359+
// Only the "folder as module" Node.js behavior is respected:
360+
// - consult `package.json#main`;
361+
// - look for `index.js` or similar.
362+
const mainField = getMainFieldFromPackageJson(packageJsonPath);
363+
const mainFieldResolved = mainField ? resolveImportSpecifierExtension(path.resolve(resolved, mainField)) : undefined;
364+
return mainFieldResolved || resolveImportSpecifierExtension(path.join(resolved, 'index'));
365+
}
366+
323367
// If we import a package, let Node.js figure out the correct import based on package.json.
324-
if (fileExists(path.join(resolved, 'package.json')))
368+
// This also covers the "main" field for "folder as module".
369+
if (fileExists(packageJsonPath))
325370
return resolved;
326371

327-
// Otherwise, try to find a corresponding index file.
372+
// Implement the "folder as module" Node.js behavior.
373+
// Note that we do not delegate to Node.js, because we support this for ESM as well,
374+
// following the TypeScript "bundler" mode.
328375
const dirImport = path.join(resolved, 'index');
329376
return resolveImportSpecifierExtension(dirImport);
330377
}

0 commit comments

Comments
 (0)