Note: An AI agent (Cursor) helped me create the reproduction and this regression report. I have read this report in its entirety and reviewed the code in the repro repo.
Last Good Version
1.60.0
First Bad Version
1.61.0 (latest at time of writing)
Steps to reproduce
Minimal reproduction repo: https://github.com/AllySummers/playwright-symlink-bug
The repo is a pnpm workspace with three packages and the import chain
apps/e2e/tests/basic.spec.ts → @repro/core/lib/conversations → @repro/shared/lib/text.utils.
- Clone the repo.
- Install Node
24.16.0 and pnpm 11.5.3 via mise: mise install
pnpm install
mise run test
- The run fails during module loading (before any browser launches).
The conditions that together trigger the bug (removing any one hides it):
@playwright/test@1.61.0 on Node 22+ (so the synchronous
module.registerHooks() loader activates).
- The intermediate/leaf workspace packages are ESM (
"type": "module").
- They are imported via extensionless subpath specifiers pointing at raw
.ts files (e.g. import { greet } from '@repro/shared/lib/text.utils'),
with no exports/main field and no build step.
- It's a pnpm workspace, so each package is a symlink whose real path
differs from the symlink path.
Key files:
// packages/shared/lib/text.utils.ts
export function greet(name: string) {
return `Hello, ${name}`;
}
// packages/core/lib/conversations.ts
export { greet } from '@repro/shared/lib/text.utils';
// apps/e2e/tests/basic.spec.ts
import { test, expect } from '@playwright/test';
import { greet } from '@repro/core/lib/conversations';
test('greet returns expected string', () => {
expect(greet('world')).toBe('Hello, world');
});
Workaround that pins the cause
Forcing the previous async loader makes the same tree pass:
(uses the seemingly undocumented PLAYWRIGHT_FORCE_ASYNC_LOADER=1 environment variable)
mise run test-workaround # passes
Expected behavior
playwright test resolves the extensionless .ts subpath import across the
pnpm workspace symlink and runs the test — exactly as it did in 1.60.0 and as
it still does with PLAYWRIGHT_FORCE_ASYNC_LOADER=1. The test passes:
✓ 1 apps/e2e/tests/basic.spec.ts:4:5 › greet returns expected string
1 passed
Actual behavior
On 1.61.0 with the default synchronous loader, module resolution fails:
Error: Cannot find module '/.../packages/core/node_modules/@repro/shared/lib/text.utils'
imported from /.../packages/core/lib/conversations.ts
Did you mean to import "file:///.../packages/shared/lib/text.utils.ts"?
at basic.spec.ts:2
1 | import { test, expect } from '@playwright/test';
> 2 | import { greet } from '@repro/core/lib/conversations';
| ^
3 |
4 | test('greet returns expected string', () => {
5 | expect(greet('world')).toBe('Hello, world');
at Object.<anonymous> (/.../apps/e2e/tests/basic.spec.ts:2:1)
Error: No tests found
Note Playwright/Node's own "Did you mean…?" hint correctly identifies the
existing .ts file — resolution found the symlinked package, but the ESM
resolver never tries the .ts extension.
Result matrix
| Configuration |
Result |
@playwright/test@1.61.0, default sync loader |
❌ fails (Cannot find module … /lib/text.utils) |
@playwright/test@1.61.0 + PLAYWRIGHT_FORCE_ASYNC_LOADER=1 |
✅ passes |
@playwright/test@1.60.0, default loader |
✅ passes |
Reproduces on both macOS (arm64) and Linux CI (GitHub Actions, Ubuntu 24.04).
Additional context
The regression was introduced when the loader switched from the asynchronous
module.register() (out-of-process worker) to the synchronous
module.registerHooks({ resolve, load }) in PR
#40891 / commit
c758b150.
Likely cause: for a bare/subpath specifier the loader's resolve hook
(playwright/lib/common/index.js) returns undefined and defers to
nextResolve. Because the importing module is ESM, the importer is resolved to
its real path (packages/core/lib/conversations.ts) and Node's native ESM
resolver is handed a file:// URL; that resolver only attempts
.js/.mjs/.cjs and never .ts, so the extensionless TypeScript subpath
import fails. The async loader (still reachable via
PLAYWRIGHT_FORCE_ASYNC_LOADER=1) resolved these TypeScript subpath imports
correctly, which is why 1.60.0 and the forced-async path both pass.
The loader does perform TypeScript extension resolution for relative specifiers
(resolveImportSpecifierAfterMapping), but bare/subpath workspace specifiers
bypass that and fall through to Node's resolver.
Environment
System:
OS: macOS 26.5.1
CPU: (14) arm64 Apple M4 Pro
Memory: 9.33 GB / 48.00 GB
Binaries:
Node: 24.16.0 - mise (node 24.16.0)
npm: 11.13.0
pnpm: 11.5.3 - mise (pnpm 11.5.3)
IDEs:
VSCode: 1.121.0
Languages:
Bash: 5.3.9
npmPackages:
@playwright/test: 1.61.0 => 1.61.0
Also reproduced on Linux CI (GitHub Actions, `ubuntu-2404` runner), Node `24.16.0`, pnpm `11.5.3`.
Note: An AI agent (Cursor) helped me create the reproduction and this regression report. I have read this report in its entirety and reviewed the code in the repro repo.
Last Good Version
1.60.0First Bad Version
1.61.0(latest at time of writing)Steps to reproduce
Minimal reproduction repo: https://github.com/AllySummers/playwright-symlink-bug
The repo is a pnpm workspace with three packages and the import chain
apps/e2e/tests/basic.spec.ts→@repro/core/lib/conversations→@repro/shared/lib/text.utils.24.16.0and pnpm11.5.3via mise:mise installpnpm installmise run testThe conditions that together trigger the bug (removing any one hides it):
@playwright/test@1.61.0on Node 22+ (so the synchronousmodule.registerHooks()loader activates)."type": "module")..tsfiles (e.g.import { greet } from '@repro/shared/lib/text.utils'),with no
exports/mainfield and no build step.differs from the symlink path.
Key files:
Workaround that pins the cause
Forcing the previous async loader makes the same tree pass:
(uses the seemingly undocumented
PLAYWRIGHT_FORCE_ASYNC_LOADER=1environment variable)mise run test-workaround # passesExpected behavior
playwright testresolves the extensionless.tssubpath import across thepnpm workspace symlink and runs the test — exactly as it did in
1.60.0and asit still does with
PLAYWRIGHT_FORCE_ASYNC_LOADER=1. The test passes:Actual behavior
On
1.61.0with the default synchronous loader, module resolution fails:Note Playwright/Node's own "Did you mean…?" hint correctly identifies the
existing
.tsfile — resolution found the symlinked package, but the ESMresolver never tries the
.tsextension.Result matrix
@playwright/test@1.61.0, default sync loaderCannot find module … /lib/text.utils)@playwright/test@1.61.0+PLAYWRIGHT_FORCE_ASYNC_LOADER=1@playwright/test@1.60.0, default loaderReproduces on both macOS (arm64) and Linux CI (GitHub Actions, Ubuntu 24.04).
Additional context
The regression was introduced when the loader switched from the asynchronous
module.register()(out-of-process worker) to the synchronousmodule.registerHooks({ resolve, load })in PR#40891 / commit
c758b150.Likely cause: for a bare/subpath specifier the loader's
resolvehook(
playwright/lib/common/index.js) returnsundefinedand defers tonextResolve. Because the importing module is ESM, the importer is resolved toits real path (
packages/core/lib/conversations.ts) and Node's native ESMresolver is handed a
file://URL; that resolver only attempts.js/.mjs/.cjsand never.ts, so the extensionless TypeScript subpathimport fails. The async loader (still reachable via
PLAYWRIGHT_FORCE_ASYNC_LOADER=1) resolved these TypeScript subpath importscorrectly, which is why
1.60.0and the forced-async path both pass.The loader does perform TypeScript extension resolution for relative specifiers
(
resolveImportSpecifierAfterMapping), but bare/subpath workspace specifiersbypass that and fall through to Node's resolver.
Environment