Files
kjol/tools/tsgo/internal/module/resolver_test.go
2026-07-09 16:50:43 -04:00

417 lines
15 KiB
Go

package module_test
import (
"strings"
"sync"
"sync/atomic"
"testing"
"github.com/microsoft/typescript-go/internal/core"
"github.com/microsoft/typescript-go/internal/module"
"github.com/microsoft/typescript-go/internal/vfs"
"github.com/microsoft/typescript-go/internal/vfs/vfstest"
)
type resolutionHostStub struct {
fs vfs.FS
cwd string
}
func (h *resolutionHostStub) FS() vfs.FS { return h.fs }
func (h *resolutionHostStub) GetCurrentDirectory() string { return h.cwd }
// Regression test for https://github.com/microsoft/typescript-go/issues/3526.
//
// Resolving a node_modules import with a trailing slash (e.g. `pkg/`) must
// produce the same result as without one.
func TestResolveModuleNameTrailingSlash(t *testing.T) {
t.Parallel()
fs := vfstest.FromMap(map[string]string{
"/repo/node_modules/pkg/package.json": `{"name":"pkg","main":"main.js","types":"main.d.ts"}`,
"/repo/node_modules/pkg/main.d.ts": "export const x: number;",
"/repo/node_modules/pkg/main.js": "exports.x = 1;",
"/repo/src/file.ts": "",
}, true)
host := &resolutionHostStub{fs: fs, cwd: "/repo"}
opts := &core.CompilerOptions{
ModuleResolution: core.ModuleResolutionKindBundler,
Module: core.ModuleKindESNext,
Target: core.ScriptTargetESNext,
}
resolver := module.NewResolver(host, opts, "", "")
for _, name := range []string{"pkg", "pkg/"} {
r, _ := resolver.ResolveModuleName(name, "/repo/src/file.ts", core.ModuleKindESNext, nil)
if !r.IsResolved() {
t.Errorf("%q failed to resolve", name)
}
}
}
// blockingFS wraps a vfs.FS and forces FileExists calls for `targetPath` to
// block on `gate` until released. Each caller sends on `arrived` when it
// reaches the gate. This is used to deterministically reproduce the
// `package.json` info-cache insert race described in
// https://github.com/microsoft/typescript-go/issues/3526.
type blockingFS struct {
vfs.FS
targetPath string
gate chan struct{}
arrived chan struct{} // each blocked goroutine sends one value
}
// waitForSignal waits for a synchronization point in these race regression
// tests and converts deadlocks into deterministic test failures.
func waitForSignal(t *testing.T, ch <-chan struct{}, description string) {
t.Helper()
select {
case <-ch:
return
case <-t.Context().Done():
t.Fatalf("timed out waiting for %s", description)
}
}
func (f *blockingFS) FileExists(path string) bool {
if path == f.targetPath {
f.arrived <- struct{}{}
<-f.gate
}
return f.FS.FileExists(path)
}
// flipFileExistsFS wraps a vfs.FS and returns false for the first
// FileExists call to `targetPath`, then true for the second. Both calls
// signal arrival via channel then block until released via their respective
// gate channels. ReadFile for the target path also signals arrival then
// blocks, so the "file doesn't exist" Set completes before the "file exists"
// Set (reproducing the LoadOrStore race).
type flipFileExistsFS struct {
vfs.FS
targetPath string
callCount atomic.Int32
firstArrived chan struct{} // closed when the first FileExists caller arrives
secondArrived chan struct{} // closed when the second FileExists caller arrives
firstGate chan struct{}
secondGate chan struct{}
readArrived chan struct{} // closed when ReadFile caller arrives
readGate chan struct{}
}
func (f *flipFileExistsFS) FileExists(path string) bool {
if path == f.targetPath {
n := f.callCount.Add(1)
if n == 1 {
close(f.firstArrived)
<-f.firstGate
return false // first caller: simulate "file not yet visible"
}
if n == 2 {
close(f.secondArrived)
<-f.secondGate
return f.FS.FileExists(path) // second caller: file is visible
}
}
return f.FS.FileExists(path)
}
func (f *flipFileExistsFS) ReadFile(path string) (string, bool) {
if path == f.targetPath {
close(f.readArrived)
<-f.readGate
}
return f.FS.ReadFile(path)
}
// Regression test for https://github.com/microsoft/typescript-go/issues/3526.
//
// Two goroutines resolve the same package via specifiers that differ only by
// a trailing slash (`pkg` and `pkg/`). A blocking FS holds both at the
// `FileExists` check for `package.json` — *after* each has confirmed a
// `package.json` info-cache miss but *before* either has called `Set`. When
// released, both proceed to `LoadOrStore` and one of them loses. Without the
// fix, the loser receives the winner's `InfoCacheEntry` whose
// `PackageDirectory` doesn't match its own `candidate` (because one spelling
// has a trailing slash and the other doesn't), and
// `loadNodeModuleFromDirectoryWorker`'s `ComparePaths` check skips loading
// the package's `main`/`types`. With no `index.*` present, resolution falls
// through to "unresolved" — the phantom TS2307 the issue describes. This
// test deterministically fails when the fix is reverted.
func TestResolveModuleNameTrailingSlashRace(t *testing.T) {
t.Parallel()
const pkgJSONPath = "/repo/node_modules/pkg/package.json"
files := map[string]string{
// `types` points at a file that is not discoverable through any
// fallback path: there is no `index.*` and no `main`. The only way
// to resolve `pkg` (or `pkg/`) is via the package.json `types` field
// inside `loadNodeModuleFromDirectoryWorker`, which is exactly the
// step that the bug skips when `candidate` and
// `packageInfo.PackageDirectory` mismatch.
pkgJSONPath: `{"name":"pkg","types":"./typings/index.d.ts"}`,
"/repo/node_modules/pkg/typings/index.d.ts": "export const x: number;",
// Distinct containing files so each `ResolveModuleName` call has a
// unique module-resolution-cache key.
"/repo/src/a/file.ts": "",
"/repo/src/b/file.ts": "",
}
fs := &blockingFS{
FS: vfstest.FromMap(files, true),
targetPath: pkgJSONPath,
gate: make(chan struct{}),
arrived: make(chan struct{}, 2),
}
host := &resolutionHostStub{fs: fs, cwd: "/repo"}
opts := &core.CompilerOptions{
ModuleResolution: core.ModuleResolutionKindBundler,
Module: core.ModuleKindESNext,
Target: core.ScriptTargetESNext,
}
resolver := module.NewResolver(host, opts, "", "")
type resolutionResult struct {
name string
resolved bool
}
results := make(chan resolutionResult, 2)
var wg sync.WaitGroup
for _, name := range []string{"pkg", "pkg/"} {
containingFile := "/repo/src/a/file.ts"
if strings.HasSuffix(name, "/") {
containingFile = "/repo/src/b/file.ts"
}
wg.Go(func() {
r, _ := resolver.ResolveModuleName(name, containingFile, core.ModuleKindESNext, nil)
results <- resolutionResult{name, r.IsResolved()}
})
}
// Wait for both goroutines to reach the FileExists gate, guaranteeing
// both have observed a package.json info-cache miss.
waitForSignal(t, fs.arrived, "first FileExists gate arrival")
waitForSignal(t, fs.arrived, "second FileExists gate arrival")
close(fs.gate)
wg.Wait()
close(results)
for r := range results {
if !r.resolved {
t.Errorf("%q failed to resolve", r.name)
}
}
}
// Regression test for https://github.com/microsoft/typescript-go/issues/1290.
//
// Two goroutines resolve `pkg/sub` concurrently. Both miss the package.json
// info-cache for the root package directory. A `flipFileExistsFS` forces the
// first goroutine's `FileExists` to return false (simulating the file not yet
// being visible), so it stores a nil-Contents cache entry. The second
// goroutine's `FileExists` returns true, but its `Set` call (`LoadOrStore`)
// returns the first goroutine's nil-Contents entry. Without the `Exists()`
// guard on the `typesVersions` lookup, `packageInfo.Contents.GetVersionPaths`
// dereferences nil and panics. With the guard the nil-Contents entry is safely
// skipped.
func TestResolveSubpathNilContentsRace(t *testing.T) {
t.Parallel()
const rootPkgJSON = "/repo/node_modules/pkg/package.json"
files := map[string]string{
rootPkgJSON: `{"name":"pkg","version":"1.0.0"}`,
"/repo/node_modules/pkg/sub/index.d.ts": "export declare const sub: number;",
"/repo/node_modules/pkg/sub/index.js": "exports.sub = 1;",
"/repo/src/a/file.ts": "",
"/repo/src/b/file.ts": "",
}
fs := &flipFileExistsFS{
FS: vfstest.FromMap(files, true),
targetPath: rootPkgJSON,
firstArrived: make(chan struct{}),
secondArrived: make(chan struct{}),
firstGate: make(chan struct{}),
secondGate: make(chan struct{}),
readArrived: make(chan struct{}),
readGate: make(chan struct{}),
}
host := &resolutionHostStub{fs: fs, cwd: "/repo"}
opts := &core.CompilerOptions{
ModuleResolution: core.ModuleResolutionKindBundler,
Module: core.ModuleKindESNext,
Target: core.ScriptTargetESNext,
}
resolver := module.NewResolver(host, opts, "", "")
var panicked atomic.Bool
type resolutionResult struct {
containingFile string
resolved bool
}
results := make(chan resolutionResult, 2)
var wg sync.WaitGroup
// Two goroutines both resolve "pkg/sub". Each calls getPackageJsonInfo
// for the root package directory, reaching FileExists for rootPkgJSON.
for _, containingFile := range []string{"/repo/src/a/file.ts", "/repo/src/b/file.ts"} {
wg.Go(func() {
resolved := false
defer func() {
if r := recover(); r != nil {
panicked.Store(true)
}
results <- resolutionResult{containingFile: containingFile, resolved: resolved}
}()
r, _ := resolver.ResolveModuleName("pkg/sub", containingFile, core.ModuleKindESNext, nil)
resolved = r.IsResolved()
})
}
// Phase 1: Wait for both goroutines to reach FileExists for the root
// package.json, guaranteeing both have observed a cache miss.
waitForSignal(t, fs.firstArrived, "first root package.json FileExists arrival")
waitForSignal(t, fs.secondArrived, "second root package.json FileExists arrival")
// Phase 2: Release the first FileExists caller (returns false).
// It enters the "file not found" branch and stores a nil-Contents entry
// via Set — this is nearly instant (no ReadFile).
close(fs.firstGate)
// Phase 3: Release the second FileExists caller (returns true).
// It proceeds to ReadFile, which we gate separately to ensure the first
// goroutine's nil-Contents Set has completed.
close(fs.secondGate)
// Phase 4: Wait for the second goroutine to reach ReadFile, then release.
// By this point the first goroutine has stored its nil-Contents entry.
// The second goroutine's Set (LoadOrStore) will return that stale entry.
waitForSignal(t, fs.readArrived, "root package.json ReadFile arrival")
close(fs.readGate)
wg.Wait()
close(results)
if panicked.Load() {
t.Fatal("resolver panicked due to nil Contents dereference in loadModuleFromSpecificNodeModulesDirectory")
}
for r := range results {
if !r.resolved {
t.Fatalf("%q failed to resolve pkg/sub", r.containingFile)
}
}
}
func TestParseNodeModuleFromPath(t *testing.T) {
t.Parallel()
tests := []struct {
name string
path string
isFolder bool
want string
}{
{"file in package", "/a/node_modules/b/lib/index.d.ts", false, "/a/node_modules/b"},
{"file in scoped package", "/a/node_modules/@scope/b/lib/index.d.ts", false, "/a/node_modules/@scope/b"},
{"folder subpath", "/a/node_modules/b/lib/File", true, "/a/node_modules/b"},
{"folder subpath scoped", "/a/node_modules/@scope/b/lib/File", true, "/a/node_modules/@scope/b"},
{"package root folder", "/a/node_modules/b", true, "/a/node_modules/b"},
{"scoped package root folder", "/a/node_modules/@scope/b", true, "/a/node_modules/@scope/b"},
// A bare scope directory has no package name; must not panic (https://github.com/microsoft/typescript-go/issues/4373).
{"scope-only folder", "/a/node_modules/@scope", true, "/a/node_modules/@scope"},
{"types scope-only folder", "/a/node_modules/@types", true, "/a/node_modules/@types"},
{"not in node_modules", "/a/src/index.ts", false, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := module.ParseNodeModuleFromPath(tt.path, tt.isFolder); got != tt.want {
t.Errorf("ParseNodeModuleFromPath(%q, %v) = %q, want %q", tt.path, tt.isFolder, got, tt.want)
}
})
}
}
// Regression test for https://github.com/microsoft/typescript-go/issues/4478.
//
// While resolving a package with peerDependencies, two goroutines look up the
// peer package's package.json concurrently. A `flipFileExistsFS` forces the
// first lookup to cache a nil-Contents entry and the second lookup to receive
// that stale entry from `Set`. The resolver must not dereference the peer
// package.json contents unless the entry actually exists.
func TestResolvePeerDependencyNilContentsRace(t *testing.T) {
t.Parallel()
const peerPkgJSON = "/repo/node_modules/peer/package.json"
files := map[string]string{
"/repo/node_modules/pkg/package.json": `{"name":"pkg","version":"1.0.0","types":"index.d.ts","peerDependencies":{"peer":"*"}}`,
"/repo/node_modules/pkg/index.d.ts": "export declare const x: number;",
peerPkgJSON: `{"name":"peer","version":"2.0.0"}`,
"/repo/src/a/file.ts": "",
"/repo/src/b/file.ts": "",
}
fs := &flipFileExistsFS{
FS: vfstest.FromMap(files, true),
targetPath: peerPkgJSON,
firstArrived: make(chan struct{}),
secondArrived: make(chan struct{}),
firstGate: make(chan struct{}),
secondGate: make(chan struct{}),
readArrived: make(chan struct{}),
readGate: make(chan struct{}),
}
host := &resolutionHostStub{fs: fs, cwd: "/repo"}
opts := &core.CompilerOptions{
ModuleResolution: core.ModuleResolutionKindBundler,
Module: core.ModuleKindESNext,
Target: core.ScriptTargetESNext,
}
resolver := module.NewResolver(host, opts, "", "")
var panicked atomic.Bool
type resolutionResult struct {
containingFile string
resolved bool
}
results := make(chan resolutionResult, 2)
var wg sync.WaitGroup
for _, containingFile := range []string{"/repo/src/a/file.ts", "/repo/src/b/file.ts"} {
wg.Go(func() {
resolved := false
defer func() {
if r := recover(); r != nil {
panicked.Store(true)
}
results <- resolutionResult{containingFile: containingFile, resolved: resolved}
}()
r, _ := resolver.ResolveModuleName("pkg", containingFile, core.ModuleKindESNext, nil)
resolved = r.IsResolved()
})
}
waitForSignal(t, fs.firstArrived, "first peer package.json FileExists arrival")
waitForSignal(t, fs.secondArrived, "second peer package.json FileExists arrival")
close(fs.firstGate)
var firstResult resolutionResult
select {
case result := <-results:
firstResult = result
case <-t.Context().Done():
t.Fatal("timed out waiting for first peer package.json lookup to finish")
}
close(fs.secondGate)
waitForSignal(t, fs.readArrived, "peer package.json ReadFile arrival")
close(fs.readGate)
wg.Wait()
close(results)
if panicked.Load() {
t.Fatal("resolver panicked due to nil Contents dereference in readPackageJsonPeerDependencies")
}
if !firstResult.resolved {
t.Fatalf("%q failed to resolve pkg", firstResult.containingFile)
}
for r := range results {
if !r.resolved {
t.Fatalf("%q failed to resolve pkg", r.containingFile)
}
}
}