1184 lines
53 KiB
Go
1184 lines
53 KiB
Go
package autoimport_test
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"testing"
|
|
|
|
"github.com/microsoft/typescript-go/internal/bundled"
|
|
"github.com/microsoft/typescript-go/internal/collections"
|
|
"github.com/microsoft/typescript-go/internal/core"
|
|
"github.com/microsoft/typescript-go/internal/ls"
|
|
"github.com/microsoft/typescript-go/internal/ls/autoimport"
|
|
"github.com/microsoft/typescript-go/internal/ls/lsconv"
|
|
"github.com/microsoft/typescript-go/internal/ls/lsutil"
|
|
"github.com/microsoft/typescript-go/internal/lsp/lsproto"
|
|
"github.com/microsoft/typescript-go/internal/project"
|
|
"github.com/microsoft/typescript-go/internal/testutil/autoimporttestutil"
|
|
"github.com/microsoft/typescript-go/internal/testutil/projecttestutil"
|
|
"github.com/microsoft/typescript-go/internal/tspath"
|
|
"github.com/microsoft/typescript-go/internal/vfs/vfstest"
|
|
"gotest.tools/v3/assert"
|
|
)
|
|
|
|
func TestRegistryLifecycle(t *testing.T) {
|
|
t.Parallel()
|
|
t.Run("builds project and node_modules buckets", func(t *testing.T) {
|
|
t.Parallel()
|
|
fixture := autoimporttestutil.SetupLifecycleSession(t, lifecycleProjectRoot, 1)
|
|
session := fixture.Session()
|
|
project := fixture.SingleProject()
|
|
mainFile := project.File(0)
|
|
session.DidOpenFile(context.Background(), mainFile.URI(), 1, mainFile.Content(), lsproto.LanguageKindTypeScript)
|
|
|
|
stats := autoImportStats(t, session)
|
|
projectBucket := singleBucket(t, stats.ProjectBuckets)
|
|
nodeModulesBucket := singleBucket(t, stats.NodeModulesBuckets)
|
|
assert.Equal(t, true, projectBucket.State.Dirty())
|
|
assert.Equal(t, 0, projectBucket.FileCount)
|
|
assert.Equal(t, true, nodeModulesBucket.State.Dirty())
|
|
assert.Equal(t, 0, nodeModulesBucket.FileCount)
|
|
|
|
_, err := session.GetCurrentLanguageServiceWithAutoImports(context.Background(), mainFile.URI())
|
|
assert.NilError(t, err)
|
|
|
|
stats = autoImportStats(t, session)
|
|
projectBucket = singleBucket(t, stats.ProjectBuckets)
|
|
nodeModulesBucket = singleBucket(t, stats.NodeModulesBuckets)
|
|
assert.Equal(t, false, projectBucket.State.Dirty())
|
|
assert.Assert(t, projectBucket.ExportCount > 0)
|
|
assert.Equal(t, false, nodeModulesBucket.State.Dirty())
|
|
assert.Assert(t, nodeModulesBucket.ExportCount > 0)
|
|
})
|
|
|
|
t.Run("bucket does not rebuild on same-file change", func(t *testing.T) {
|
|
t.Parallel()
|
|
fixture := autoimporttestutil.SetupLifecycleSession(t, lifecycleProjectRoot, 2)
|
|
session := fixture.Session()
|
|
utils := fixture.Utils()
|
|
project := fixture.SingleProject()
|
|
mainFile := project.File(0)
|
|
secondaryFile := project.File(1)
|
|
session.DidOpenFile(context.Background(), mainFile.URI(), 1, mainFile.Content(), lsproto.LanguageKindTypeScript)
|
|
session.DidOpenFile(context.Background(), secondaryFile.URI(), 1, secondaryFile.Content(), lsproto.LanguageKindTypeScript)
|
|
_, err := session.GetCurrentLanguageServiceWithAutoImports(context.Background(), mainFile.URI())
|
|
assert.NilError(t, err)
|
|
|
|
updatedContent := mainFile.Content() + "// change\n"
|
|
session.DidChangeFile(context.Background(), mainFile.URI(), 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{
|
|
{WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{Text: updatedContent}},
|
|
})
|
|
|
|
_, err = session.GetLanguageService(context.Background(), mainFile.URI())
|
|
assert.NilError(t, err)
|
|
|
|
stats := autoImportStats(t, session)
|
|
projectBucket := singleBucket(t, stats.ProjectBuckets)
|
|
nodeModulesBucket := singleBucket(t, stats.NodeModulesBuckets)
|
|
assert.Equal(t, projectBucket.State.Dirty(), true)
|
|
assert.Equal(t, projectBucket.State.DirtyFile(), utils.ToPath(mainFile.FileName()))
|
|
assert.Equal(t, nodeModulesBucket.State.Dirty(), false)
|
|
assert.Equal(t, nodeModulesBucket.State.DirtyFile(), tspath.Path(""))
|
|
|
|
// Bucket should not recompute when requesting same file changed
|
|
_, err = session.GetCurrentLanguageServiceWithAutoImports(context.Background(), mainFile.URI())
|
|
assert.NilError(t, err)
|
|
stats = autoImportStats(t, session)
|
|
projectBucket = singleBucket(t, stats.ProjectBuckets)
|
|
assert.Equal(t, projectBucket.State.Dirty(), true)
|
|
assert.Equal(t, projectBucket.State.DirtyFile(), utils.ToPath(mainFile.FileName()))
|
|
|
|
// Bucket should recompute when other file has changed
|
|
session.DidChangeFile(context.Background(), secondaryFile.URI(), 1, []lsproto.TextDocumentContentChangePartialOrWholeDocument{
|
|
{WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{Text: "// new content"}},
|
|
})
|
|
_, err = session.GetCurrentLanguageServiceWithAutoImports(context.Background(), mainFile.URI())
|
|
assert.NilError(t, err)
|
|
stats = autoImportStats(t, session)
|
|
projectBucket = singleBucket(t, stats.ProjectBuckets)
|
|
assert.Equal(t, projectBucket.State.Dirty(), false)
|
|
})
|
|
|
|
t.Run("bucket updates on same-file change when new files added to the program", func(t *testing.T) {
|
|
t.Parallel()
|
|
projectRoot := "/home/src/explicit-files-project"
|
|
files := map[string]any{
|
|
projectRoot + "/tsconfig.json": `{
|
|
"compilerOptions": {
|
|
"module": "esnext",
|
|
"target": "esnext",
|
|
"strict": true
|
|
},
|
|
"files": ["index.ts"]
|
|
}`,
|
|
projectRoot + "/index.ts": "",
|
|
projectRoot + "/utils.ts": `export const foo = 1;
|
|
export const bar = 2;`,
|
|
}
|
|
session, _ := projecttestutil.Setup(files)
|
|
t.Cleanup(session.Close)
|
|
|
|
ctx := context.Background()
|
|
indexURI := lsproto.DocumentUri("file://" + projectRoot + "/index.ts")
|
|
|
|
// Open the index.ts file
|
|
session.DidOpenFile(ctx, indexURI, 1, "", lsproto.LanguageKindTypeScript)
|
|
_, err := session.GetCurrentLanguageServiceWithAutoImports(ctx, indexURI)
|
|
assert.NilError(t, err)
|
|
stats := autoImportStats(t, session)
|
|
projectBucket := singleBucket(t, stats.ProjectBuckets)
|
|
assert.Equal(t, 1, projectBucket.FileCount)
|
|
|
|
// Edit index.ts to import foo from utils.ts
|
|
newContent := `import { foo } from "./utils";`
|
|
session.DidChangeFile(ctx, indexURI, 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{
|
|
{WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{Text: newContent}},
|
|
})
|
|
|
|
// Bucket should be rebuilt because new files were added
|
|
_, err = session.GetCurrentLanguageServiceWithAutoImports(ctx, indexURI)
|
|
assert.NilError(t, err)
|
|
stats = autoImportStats(t, session)
|
|
projectBucket = singleBucket(t, stats.ProjectBuckets)
|
|
assert.Equal(t, 2, projectBucket.FileCount)
|
|
})
|
|
|
|
t.Run("package.json dependency changes invalidate node_modules buckets", func(t *testing.T) {
|
|
t.Parallel()
|
|
fixture := autoimporttestutil.SetupLifecycleSession(t, lifecycleProjectRoot, 1)
|
|
session := fixture.Session()
|
|
sessionUtils := fixture.Utils()
|
|
project := fixture.SingleProject()
|
|
mainFile := project.File(0)
|
|
nodePackage := project.NodeModules()[0]
|
|
packageJSON := project.PackageJSONFile()
|
|
ctx := context.Background()
|
|
|
|
session.DidOpenFile(ctx, mainFile.URI(), 1, mainFile.Content(), lsproto.LanguageKindTypeScript)
|
|
_, err := session.GetCurrentLanguageServiceWithAutoImports(ctx, mainFile.URI())
|
|
assert.NilError(t, err)
|
|
stats := autoImportStats(t, session)
|
|
nodeModulesBucket := singleBucket(t, stats.NodeModulesBuckets)
|
|
assert.Equal(t, nodeModulesBucket.State.Dirty(), false)
|
|
|
|
fs := sessionUtils.FS()
|
|
updatePackageJSON := func(content string) {
|
|
assert.NilError(t, fs.WriteFile(packageJSON.FileName(), content))
|
|
session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{
|
|
{Type: lsproto.FileChangeTypeChanged, Uri: packageJSON.URI()},
|
|
})
|
|
}
|
|
|
|
sameDepsContent := fmt.Sprintf("{\n \"name\": \"local-project-stable\",\n \"dependencies\": {\n \"%s\": \"*\"\n }\n}\n", nodePackage.Name)
|
|
updatePackageJSON(sameDepsContent)
|
|
_, err = session.GetLanguageService(ctx, mainFile.URI())
|
|
assert.NilError(t, err)
|
|
stats = autoImportStats(t, session)
|
|
nodeModulesBucket = singleBucket(t, stats.NodeModulesBuckets)
|
|
assert.Equal(t, nodeModulesBucket.State.Dirty(), false)
|
|
|
|
differentDepsContent := fmt.Sprintf("{\n \"name\": \"local-project-stable\",\n \"dependencies\": {\n \"%s\": \"*\",\n \"newpkg\": \"*\"\n }\n}\n", nodePackage.Name)
|
|
updatePackageJSON(differentDepsContent)
|
|
_, err = session.GetCurrentLanguageServiceWithAutoImports(ctx, mainFile.URI())
|
|
assert.NilError(t, err)
|
|
stats = autoImportStats(t, session)
|
|
assert.Check(t, singleBucket(t, stats.NodeModulesBuckets).DependencyNames.Has("newpkg"))
|
|
})
|
|
|
|
t.Run("node_modules buckets get deleted when no open files can reference them", func(t *testing.T) {
|
|
t.Parallel()
|
|
fixture := autoimporttestutil.SetupMonorepoLifecycleSession(t, autoimporttestutil.MonorepoSetupConfig{
|
|
Root: monorepoProjectRoot,
|
|
MonorepoPackageTemplate: autoimporttestutil.MonorepoPackageTemplate{
|
|
Name: "monorepo",
|
|
NodeModuleNames: []string{"pkg-root"},
|
|
},
|
|
Packages: []autoimporttestutil.MonorepoPackageConfig{
|
|
{FileCount: 1, MonorepoPackageTemplate: autoimporttestutil.MonorepoPackageTemplate{Name: "package-a", NodeModuleNames: []string{"pkg-a"}}},
|
|
{FileCount: 1, MonorepoPackageTemplate: autoimporttestutil.MonorepoPackageTemplate{Name: "package-b", NodeModuleNames: []string{"pkg-b"}}},
|
|
},
|
|
})
|
|
session := fixture.Session()
|
|
monorepo := fixture.Monorepo()
|
|
pkgA := monorepo.Package(0)
|
|
pkgB := monorepo.Package(1)
|
|
fileA := pkgA.File(0)
|
|
fileB := pkgB.File(0)
|
|
ctx := context.Background()
|
|
|
|
// Open file in package-a, should create buckets for root and package-a node_modules
|
|
session.DidOpenFile(ctx, fileA.URI(), 1, fileA.Content(), lsproto.LanguageKindTypeScript)
|
|
_, err := session.GetCurrentLanguageServiceWithAutoImports(ctx, fileA.URI())
|
|
assert.NilError(t, err)
|
|
|
|
// Open file in package-b, should also create buckets for package-b
|
|
session.DidOpenFile(ctx, fileB.URI(), 1, fileB.Content(), lsproto.LanguageKindTypeScript)
|
|
_, err = session.GetCurrentLanguageServiceWithAutoImports(ctx, fileB.URI())
|
|
assert.NilError(t, err)
|
|
stats := autoImportStats(t, session)
|
|
assert.Equal(t, len(stats.NodeModulesBuckets), 3)
|
|
assert.Equal(t, len(stats.ProjectBuckets), 2)
|
|
|
|
// Close file in package-a, package-a's node_modules bucket and project bucket should be removed
|
|
session.DidCloseFile(ctx, fileA.URI())
|
|
_, err = session.GetCurrentLanguageServiceWithAutoImports(ctx, fileB.URI())
|
|
assert.NilError(t, err)
|
|
stats = autoImportStats(t, session)
|
|
assert.Equal(t, len(stats.NodeModulesBuckets), 2)
|
|
assert.Equal(t, len(stats.ProjectBuckets), 1)
|
|
})
|
|
|
|
t.Run("deleting node_modules leaves the registry prepared for importing", func(t *testing.T) {
|
|
t.Parallel()
|
|
fixture := autoimporttestutil.SetupLifecycleSession(t, lifecycleProjectRoot, 1)
|
|
session := fixture.Session()
|
|
sessionUtils := fixture.Utils()
|
|
project := fixture.SingleProject()
|
|
mainFile := project.File(0)
|
|
ctx := context.Background()
|
|
|
|
preferences := lsutil.NewDefaultUserPreferences()
|
|
preferences.IncludeCompletionsForModuleExports = core.TSTrue
|
|
preferences.IncludeCompletionsForImportStatements = core.TSTrue
|
|
|
|
// Build auto-imports once so both buckets are clean and prepared.
|
|
session.DidOpenFile(ctx, mainFile.URI(), 1, mainFile.Content(), lsproto.LanguageKindTypeScript)
|
|
_, err := session.GetCurrentLanguageServiceWithAutoImports(ctx, mainFile.URI())
|
|
assert.NilError(t, err)
|
|
|
|
snapshot := session.Snapshot()
|
|
defaultProject := snapshot.GetDefaultProject(mainFile.URI())
|
|
assert.Assert(t, defaultProject != nil)
|
|
projectPath := defaultProject.ConfigFilePath()
|
|
assert.Assert(t, snapshot.AutoImportRegistry().IsPreparedForImportingFile(mainFile.FileName(), projectPath, preferences))
|
|
assert.Equal(t, len(autoImportStats(t, session).NodeModulesBuckets), 1)
|
|
|
|
// Simulate the user deleting node_modules: remove the directory from disk
|
|
// and notify the session of the deletion, which marks the node_modules
|
|
// bucket dirty.
|
|
nodeModulesDir := tspath.CombinePaths(project.Root(), "node_modules")
|
|
assert.NilError(t, sessionUtils.FS().Remove(nodeModulesDir))
|
|
session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{
|
|
{Type: lsproto.FileChangeTypeDeleted, Uri: lsconv.FileNameToDocumentURI(nodeModulesDir)},
|
|
})
|
|
|
|
// Re-preparing auto-imports must succeed and leave the registry prepared.
|
|
// Before the fix, the node_modules bucket's rebuild bailed out with
|
|
// vfs.ErrNotExist, leaving the stale dirty bucket in place so the registry
|
|
// reported "needs rebuild" forever (which crashed the request handler).
|
|
_, err = session.GetCurrentLanguageServiceWithAutoImports(ctx, mainFile.URI())
|
|
assert.NilError(t, err)
|
|
|
|
snapshot = session.Snapshot()
|
|
assert.Assert(t, snapshot.AutoImportRegistry().IsPreparedForImportingFile(mainFile.FileName(), projectPath, preferences),
|
|
"registry should be prepared after node_modules is deleted")
|
|
// The node_modules bucket should be removed entirely, not left behind as an
|
|
// empty bucket.
|
|
assert.Equal(t, len(autoImportStats(t, session).NodeModulesBuckets), 0)
|
|
})
|
|
|
|
t.Run("deleting node_modules alongside a package.json change removes the bucket", func(t *testing.T) {
|
|
t.Parallel()
|
|
fixture := autoimporttestutil.SetupLifecycleSession(t, lifecycleProjectRoot, 1)
|
|
session := fixture.Session()
|
|
sessionUtils := fixture.Utils()
|
|
project := fixture.SingleProject()
|
|
mainFile := project.File(0)
|
|
packageJSON := project.PackageJSONFile()
|
|
ctx := context.Background()
|
|
|
|
preferences := lsutil.NewDefaultUserPreferences()
|
|
preferences.IncludeCompletionsForModuleExports = core.TSTrue
|
|
preferences.IncludeCompletionsForImportStatements = core.TSTrue
|
|
|
|
session.DidOpenFile(ctx, mainFile.URI(), 1, mainFile.Content(), lsproto.LanguageKindTypeScript)
|
|
_, err := session.GetCurrentLanguageServiceWithAutoImports(ctx, mainFile.URI())
|
|
assert.NilError(t, err)
|
|
|
|
snapshot := session.Snapshot()
|
|
defaultProject := snapshot.GetDefaultProject(mainFile.URI())
|
|
assert.Assert(t, defaultProject != nil)
|
|
projectPath := defaultProject.ConfigFilePath()
|
|
assert.Equal(t, len(autoImportStats(t, session).NodeModulesBuckets), 1)
|
|
|
|
// In a single changeset, edit package.json AND delete node_modules. The
|
|
// package.json change must not prevent the now-missing node_modules bucket
|
|
// from being removed.
|
|
assert.NilError(t, sessionUtils.FS().WriteFile(packageJSON.FileName(), `{"name": "app", "dependencies": {}}`))
|
|
nodeModulesDir := tspath.CombinePaths(project.Root(), "node_modules")
|
|
assert.NilError(t, sessionUtils.FS().Remove(nodeModulesDir))
|
|
session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{
|
|
{Type: lsproto.FileChangeTypeChanged, Uri: packageJSON.URI()},
|
|
{Type: lsproto.FileChangeTypeDeleted, Uri: lsconv.FileNameToDocumentURI(nodeModulesDir)},
|
|
})
|
|
|
|
_, err = session.GetCurrentLanguageServiceWithAutoImports(ctx, mainFile.URI())
|
|
assert.NilError(t, err)
|
|
|
|
snapshot = session.Snapshot()
|
|
assert.Assert(t, snapshot.AutoImportRegistry().IsPreparedForImportingFile(mainFile.FileName(), projectPath, preferences))
|
|
assert.Equal(t, len(autoImportStats(t, session).NodeModulesBuckets), 0)
|
|
})
|
|
|
|
t.Run("deleting a package directory inside node_modules invalidates the bucket", func(t *testing.T) {
|
|
t.Parallel()
|
|
fixture := autoimporttestutil.SetupLifecycleSession(t, lifecycleProjectRoot, 1)
|
|
session := fixture.Session()
|
|
sessionUtils := fixture.Utils()
|
|
project := fixture.SingleProject()
|
|
mainFile := project.File(0)
|
|
nodePackage := project.NodeModules()[0]
|
|
ctx := context.Background()
|
|
|
|
session.DidOpenFile(ctx, mainFile.URI(), 1, mainFile.Content(), lsproto.LanguageKindTypeScript)
|
|
_, err := session.GetCurrentLanguageServiceWithAutoImports(ctx, mainFile.URI())
|
|
assert.NilError(t, err)
|
|
assert.Assert(t, singleBucket(t, autoImportStats(t, session).NodeModulesBuckets).ExportCount > 0)
|
|
|
|
// Delete just the package directory, leaving node_modules itself in place. The
|
|
// package's files are read transiently by the registry, so they are never tracked
|
|
// in diskFiles/diskDirectories; only the directory deletion event is reported, and
|
|
// it must survive snapshotfs filtering to invalidate the bucket.
|
|
assert.NilError(t, sessionUtils.FS().Remove(nodePackage.Directory))
|
|
session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{
|
|
{Type: lsproto.FileChangeTypeDeleted, Uri: lsconv.FileNameToDocumentURI(nodePackage.Directory)},
|
|
})
|
|
|
|
_, err = session.GetCurrentLanguageServiceWithAutoImports(ctx, mainFile.URI())
|
|
assert.NilError(t, err)
|
|
assert.Equal(t, singleBucket(t, autoImportStats(t, session).NodeModulesBuckets).ExportCount, 0)
|
|
})
|
|
|
|
t.Run("node_modules bucket dependency selection changes with open files", func(t *testing.T) {
|
|
t.Parallel()
|
|
monorepoRoot := "/home/src/monorepo"
|
|
packageADir := tspath.CombinePaths(monorepoRoot, "packages", "a")
|
|
monorepoIndex := tspath.CombinePaths(monorepoRoot, "index.js")
|
|
packageAIndex := tspath.CombinePaths(packageADir, "index.js")
|
|
|
|
fixture := autoimporttestutil.SetupMonorepoLifecycleSession(t, autoimporttestutil.MonorepoSetupConfig{
|
|
Root: monorepoRoot,
|
|
MonorepoPackageTemplate: autoimporttestutil.MonorepoPackageTemplate{
|
|
Name: "monorepo",
|
|
NodeModuleNames: []string{"pkg1", "pkg2", "pkg3"},
|
|
DependencyNames: []string{"pkg1"},
|
|
},
|
|
Packages: []autoimporttestutil.MonorepoPackageConfig{
|
|
{
|
|
FileCount: 0,
|
|
MonorepoPackageTemplate: autoimporttestutil.MonorepoPackageTemplate{
|
|
Name: "a",
|
|
DependencyNames: []string{"pkg1", "pkg2"},
|
|
},
|
|
},
|
|
},
|
|
ExtraFiles: []autoimporttestutil.TextFileSpec{
|
|
{Path: monorepoIndex, Content: "export const monorepoIndex = 1;\n"},
|
|
{Path: packageAIndex, Content: "export const pkgA = 2;\n"},
|
|
},
|
|
})
|
|
session := fixture.Session()
|
|
monorepoHandle := fixture.ExtraFile(monorepoIndex)
|
|
packageAHandle := fixture.ExtraFile(packageAIndex)
|
|
|
|
ctx := context.Background()
|
|
|
|
// Open monorepo root file: expect dependencies restricted to pkg1
|
|
session.DidOpenFile(ctx, monorepoHandle.URI(), 1, monorepoHandle.Content(), lsproto.LanguageKindJavaScript)
|
|
_, err := session.GetCurrentLanguageServiceWithAutoImports(ctx, monorepoHandle.URI())
|
|
assert.NilError(t, err)
|
|
stats := autoImportStats(t, session)
|
|
assert.Assert(t, singleBucket(t, stats.NodeModulesBuckets).DependencyNames.Equals(collections.NewSetFromItems("pkg1")))
|
|
|
|
// Open package-a file: pkg2 should be added to existing bucket
|
|
session.DidOpenFile(ctx, packageAHandle.URI(), 1, packageAHandle.Content(), lsproto.LanguageKindJavaScript)
|
|
_, err = session.GetCurrentLanguageServiceWithAutoImports(ctx, packageAHandle.URI())
|
|
assert.NilError(t, err)
|
|
stats = autoImportStats(t, session)
|
|
assert.Assert(t, singleBucket(t, stats.NodeModulesBuckets).DependencyNames.Equals(collections.NewSetFromItems("pkg1", "pkg2")))
|
|
|
|
// Close package-a file; only monorepo bucket should remain
|
|
session.DidCloseFile(ctx, packageAHandle.URI())
|
|
_, err = session.GetCurrentLanguageServiceWithAutoImports(ctx, monorepoHandle.URI())
|
|
assert.NilError(t, err)
|
|
stats = autoImportStats(t, session)
|
|
assert.Assert(t, singleBucket(t, stats.NodeModulesBuckets).DependencyNames.Equals(collections.NewSetFromItems("pkg1")))
|
|
|
|
// Close monorepo file; no node_modules buckets should remain
|
|
session.DidCloseFile(ctx, monorepoHandle.URI())
|
|
session.DidOpenFile(ctx, "untitled:Untitled-1", 0, "", lsproto.LanguageKindTypeScript)
|
|
_, err = session.GetLanguageService(ctx, "untitled:Untitled-1")
|
|
assert.NilError(t, err)
|
|
stats = autoImportStats(t, session)
|
|
assert.Equal(t, len(stats.NodeModulesBuckets), 0)
|
|
})
|
|
|
|
t.Run("node_modules bucket includes resolved packages from all projects", func(t *testing.T) {
|
|
// This test verifies that when multiple projects share a node_modules directory,
|
|
// the node_modules bucket includes resolved package names from ALL projects,
|
|
// not just the currently requested file's project.
|
|
//
|
|
// Scenario:
|
|
// - Two separate projects (project-a and project-b) share a root node_modules
|
|
// - pkg-listed is in both package.json dependencies
|
|
// - pkg-unlisted is NOT in any package.json, but project-a imports it directly
|
|
// - When requesting completions for project-b, pkg-unlisted should still be in
|
|
// the node_modules bucket because project-a's resolved packages are included.
|
|
t.Parallel()
|
|
monorepoRoot := "/home/src/cross-project-deps"
|
|
packageADir := tspath.CombinePaths(monorepoRoot, "packages", "a")
|
|
packageBDir := tspath.CombinePaths(monorepoRoot, "packages", "b")
|
|
packageAIndex := tspath.CombinePaths(packageADir, "index.ts")
|
|
packageBIndex := tspath.CombinePaths(packageBDir, "index.ts")
|
|
|
|
fixture := autoimporttestutil.SetupMonorepoLifecycleSession(t, autoimporttestutil.MonorepoSetupConfig{
|
|
Root: monorepoRoot,
|
|
MonorepoPackageTemplate: autoimporttestutil.MonorepoPackageTemplate{
|
|
Name: "monorepo",
|
|
// Both pkg-listed and pkg-unlisted exist in node_modules
|
|
NodeModuleNames: []string{"pkg-listed", "pkg-unlisted"},
|
|
// But only pkg-listed is in the root package.json dependencies
|
|
DependencyNames: []string{"pkg-listed"},
|
|
},
|
|
Packages: []autoimporttestutil.MonorepoPackageConfig{
|
|
{
|
|
FileCount: 0,
|
|
MonorepoPackageTemplate: autoimporttestutil.MonorepoPackageTemplate{
|
|
Name: "a",
|
|
// package-a only lists pkg-listed in its package.json
|
|
DependencyNames: []string{"pkg-listed"},
|
|
},
|
|
},
|
|
{
|
|
FileCount: 0,
|
|
MonorepoPackageTemplate: autoimporttestutil.MonorepoPackageTemplate{
|
|
Name: "b",
|
|
// package-b also only lists pkg-listed in its package.json
|
|
DependencyNames: []string{"pkg-listed"},
|
|
},
|
|
},
|
|
},
|
|
ExtraFiles: []autoimporttestutil.TextFileSpec{
|
|
// project-a directly imports pkg-unlisted (not in package.json)
|
|
{Path: packageAIndex, Content: "import { pkg_unlisted_value } from \"pkg-unlisted\";\nexport const a = pkg_unlisted_value;\n"},
|
|
// project-b does not import pkg-unlisted
|
|
{Path: packageBIndex, Content: "export const b = 1;\n"},
|
|
},
|
|
})
|
|
session := fixture.Session()
|
|
packageAHandle := fixture.ExtraFile(packageAIndex)
|
|
packageBHandle := fixture.ExtraFile(packageBIndex)
|
|
|
|
ctx := context.Background()
|
|
|
|
// Open file in project-a (which imports pkg-unlisted)
|
|
session.DidOpenFile(ctx, packageAHandle.URI(), 1, packageAHandle.Content(), lsproto.LanguageKindTypeScript)
|
|
_, err := session.GetCurrentLanguageServiceWithAutoImports(ctx, packageAHandle.URI())
|
|
assert.NilError(t, err)
|
|
|
|
// Open file in project-b (which does not import pkg-unlisted)
|
|
session.DidOpenFile(ctx, packageBHandle.URI(), 1, packageBHandle.Content(), lsproto.LanguageKindTypeScript)
|
|
// Request auto-imports for project-b
|
|
_, err = session.GetCurrentLanguageServiceWithAutoImports(ctx, packageBHandle.URI())
|
|
assert.NilError(t, err)
|
|
|
|
// Verify that the node_modules bucket includes pkg-unlisted
|
|
// even though we requested auto-imports for project-b which doesn't list it.
|
|
// This is because project-a imports it directly, and the bucket should include
|
|
// resolved packages from all projects that share the node_modules directory.
|
|
stats := autoImportStats(t, session)
|
|
nodeModulesBucket := singleBucket(t, stats.NodeModulesBuckets)
|
|
assert.Assert(t, nodeModulesBucket.DependencyNames.Has("pkg-listed"), "pkg-listed should be in dependencies")
|
|
assert.Assert(t, nodeModulesBucket.DependencyNames.Has("pkg-unlisted"), "pkg-unlisted should be in dependencies because project-a imports it")
|
|
})
|
|
|
|
t.Run("symlinked monorepo invalidates on source file change", func(t *testing.T) {
|
|
// This test verifies that when a source file in a symlinked project reference
|
|
// is modified, the node_modules bucket is properly invalidated.
|
|
//
|
|
// Scenario:
|
|
// 1. project-a imports from project-b (symlinked into node_modules)
|
|
// 2. project-b has projectBFunction export
|
|
// 3. Open project-b's source file and delete the export
|
|
// 4. Verify the node_modules bucket is invalidated and rebuilt
|
|
//
|
|
// We also include a regular (non-symlinked) package "other-pkg" to observe
|
|
// whether a change to project-b triggers a full rebuild of the entire bucket
|
|
// or a more granular update.
|
|
t.Parallel()
|
|
monorepoRoot := "/home/src/symlinked-monorepo-invalidation"
|
|
projectADir := tspath.CombinePaths(monorepoRoot, "packages", "project-a")
|
|
projectBDir := tspath.CombinePaths(monorepoRoot, "packages", "project-b")
|
|
projectAIndex := tspath.CombinePaths(projectADir, "src", "index.ts")
|
|
projectBSrcIndex := tspath.CombinePaths(projectBDir, "src", "index.ts")
|
|
projectBDistIndex := tspath.CombinePaths(projectBDir, "dist", "index.d.ts")
|
|
otherPkgDir := tspath.CombinePaths(projectADir, "node_modules", "other-pkg")
|
|
|
|
files := map[string]any{
|
|
// project-b: the library package
|
|
tspath.CombinePaths(projectBDir, "tsconfig.json"): `{
|
|
"compilerOptions": {
|
|
"composite": true,
|
|
"outDir": "./dist",
|
|
"rootDir": "./src",
|
|
"declaration": true,
|
|
"module": "esnext",
|
|
"strict": true
|
|
},
|
|
"include": ["src"]
|
|
}`,
|
|
tspath.CombinePaths(projectBDir, "package.json"): `{
|
|
"name": "project-b",
|
|
"version": "1.0.0",
|
|
"main": "dist/index.js",
|
|
"types": "dist/index.d.ts"
|
|
}`,
|
|
projectBSrcIndex: `export function projectBFunction(): string { return "hello"; }
|
|
export const projectBValue: number = 42;`,
|
|
projectBDistIndex: `export declare function projectBFunction(): string;
|
|
export declare const projectBValue: number;`,
|
|
// other-pkg: a regular (non-symlinked) package
|
|
tspath.CombinePaths(otherPkgDir, "package.json"): `{
|
|
"name": "other-pkg",
|
|
"version": "1.0.0",
|
|
"main": "index.js",
|
|
"types": "index.d.ts"
|
|
}`,
|
|
tspath.CombinePaths(otherPkgDir, "index.d.ts"): `export declare function otherFunction(): void;
|
|
export declare const otherValue: string;`,
|
|
// project-a: the consumer package
|
|
tspath.CombinePaths(projectADir, "tsconfig.json"): `{
|
|
"compilerOptions": {
|
|
"module": "esnext",
|
|
"strict": true,
|
|
"outDir": "./dist",
|
|
"rootDir": "./src"
|
|
},
|
|
"include": ["src"],
|
|
"references": [{ "path": "../project-b" }]
|
|
}`,
|
|
tspath.CombinePaths(projectADir, "package.json"): `{
|
|
"name": "project-a",
|
|
"dependencies": { "project-b": "*", "other-pkg": "*" }
|
|
}`,
|
|
projectAIndex: `console.log("hello");
|
|
`,
|
|
// Symlink: project-b is accessible via node_modules
|
|
tspath.CombinePaths(projectADir, "node_modules", "project-b"): vfstest.Symlink(projectBDir),
|
|
}
|
|
|
|
session, _ := projecttestutil.Setup(files)
|
|
t.Cleanup(session.Close)
|
|
ctx := context.Background()
|
|
|
|
// Open project-a's index file and get initial auto-imports
|
|
projectAURI := lsconv.FileNameToDocumentURI(projectAIndex)
|
|
projectAContent := files[projectAIndex].(string)
|
|
session.DidOpenFile(ctx, projectAURI, 1, projectAContent, lsproto.LanguageKindTypeScript)
|
|
_, err := session.GetCurrentLanguageServiceWithAutoImports(ctx, projectAURI)
|
|
assert.NilError(t, err)
|
|
|
|
// Verify initial state: bucket is clean with files
|
|
stats := autoImportStats(t, session)
|
|
nodeModulesBucket := singleBucket(t, stats.NodeModulesBuckets)
|
|
initialFileCount := nodeModulesBucket.FileCount
|
|
assert.Equal(t, nodeModulesBucket.State.Dirty(), false, "bucket should be clean initially")
|
|
assert.Assert(t, initialFileCount > 0, "bucket should have files initially")
|
|
|
|
// Open project-b's source file
|
|
projectBURI := lsconv.FileNameToDocumentURI(projectBSrcIndex)
|
|
projectBContent := files[projectBSrcIndex].(string)
|
|
session.DidOpenFile(ctx, projectBURI, 1, projectBContent, lsproto.LanguageKindTypeScript)
|
|
|
|
// Modify the file (delete one export)
|
|
newProjectBContent := `export const projectBValue: number = 42;`
|
|
session.DidChangeFile(ctx, projectBURI, 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{
|
|
{WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{Text: newProjectBContent}},
|
|
})
|
|
|
|
// Check that the node_modules bucket is now dirty
|
|
_, err = session.GetLanguageService(ctx, projectAURI)
|
|
assert.NilError(t, err)
|
|
stats = autoImportStats(t, session)
|
|
nodeModulesBucket = singleBucket(t, stats.NodeModulesBuckets)
|
|
assert.Equal(t, nodeModulesBucket.State.Dirty(), true, "bucket should be dirty after source file change")
|
|
|
|
// Verify that only project-b is marked for update, not other-pkg.
|
|
// This tests that we correctly track which packages need granular updates.
|
|
dirtyPackages := nodeModulesBucket.State.DirtyPackages()
|
|
assert.Assert(t, dirtyPackages != nil, "dirty packages should be tracked")
|
|
assert.Assert(t, dirtyPackages.Has("project-b"), "project-b should be in dirty packages")
|
|
assert.Assert(t, !dirtyPackages.Has("other-pkg"), "other-pkg should NOT be in dirty packages")
|
|
assert.Equal(t, dirtyPackages.Len(), 1, "only one package should be dirty")
|
|
|
|
// Rebuild by requesting auto-imports again.
|
|
// NOTE: Currently the entire bucket is rebuilt, not just the dirty packages.
|
|
// The dirtyPackages tracking is in place for future granular update implementation.
|
|
_, err = session.GetCurrentLanguageServiceWithAutoImports(ctx, projectAURI)
|
|
assert.NilError(t, err)
|
|
|
|
// Verify bucket is clean again after rebuild
|
|
stats = autoImportStats(t, session)
|
|
nodeModulesBucket = singleBucket(t, stats.NodeModulesBuckets)
|
|
assert.Equal(t, nodeModulesBucket.State.Dirty(), false, "bucket should be clean after rebuild")
|
|
})
|
|
|
|
t.Run("pnpm-style symlinks only grant granular updates to workspace packages", func(t *testing.T) {
|
|
// In pnpm, every package in node_modules is symlinked — registry packages
|
|
// are symlinked into node_modules/.pnpm/<pkg>@<version>/node_modules/<pkg>.
|
|
// Only local workspace packages (whose realpaths are outside node_modules
|
|
// and within the workspace root) should be eligible for granular updates.
|
|
// Registry packages should trigger full bucket rebuilds.
|
|
t.Parallel()
|
|
monorepoRoot := "/home/src/pnpm-monorepo"
|
|
projectADir := tspath.CombinePaths(monorepoRoot, "packages", "project-a")
|
|
projectBDir := tspath.CombinePaths(monorepoRoot, "packages", "project-b")
|
|
projectAIndex := tspath.CombinePaths(projectADir, "src", "index.ts")
|
|
projectBSrcIndex := tspath.CombinePaths(projectBDir, "src", "index.ts")
|
|
projectBDistIndex := tspath.CombinePaths(projectBDir, "dist", "index.d.ts")
|
|
|
|
// Simulated pnpm virtual store for a registry package (inside project-a's node_modules).
|
|
// In real pnpm, this would be node_modules/.pnpm/other-pkg@1.0.0/node_modules/other-pkg,
|
|
// but we use a simplified path to avoid creating nested node_modules buckets in tests.
|
|
pnpmStoreDir := tspath.CombinePaths(projectADir, "node_modules", ".pnpm-store", "other-pkg@1.0.0")
|
|
otherPkgIndex := tspath.CombinePaths(pnpmStoreDir, "index.d.ts")
|
|
|
|
files := map[string]any{
|
|
// project-b: a local workspace package
|
|
tspath.CombinePaths(projectBDir, "tsconfig.json"): `{
|
|
"compilerOptions": {
|
|
"composite": true,
|
|
"outDir": "./dist",
|
|
"rootDir": "./src",
|
|
"declaration": true,
|
|
"module": "esnext",
|
|
"strict": true
|
|
},
|
|
"include": ["src"]
|
|
}`,
|
|
tspath.CombinePaths(projectBDir, "package.json"): `{
|
|
"name": "project-b",
|
|
"version": "1.0.0",
|
|
"main": "dist/index.js",
|
|
"types": "dist/index.d.ts"
|
|
}`,
|
|
projectBSrcIndex: `export function projectBFunction(): string { return "hello"; }
|
|
export const projectBValue: number = 42;`,
|
|
projectBDistIndex: `export declare function projectBFunction(): string;
|
|
export declare const projectBValue: number;`,
|
|
// other-pkg: a registry package in pnpm's virtual store
|
|
tspath.CombinePaths(pnpmStoreDir, "package.json"): `{
|
|
"name": "other-pkg",
|
|
"version": "1.0.0",
|
|
"main": "index.js",
|
|
"types": "index.d.ts"
|
|
}`,
|
|
otherPkgIndex: `export declare function otherFunction(): void;
|
|
export declare const otherValue: string;`,
|
|
// project-a: the consumer package
|
|
tspath.CombinePaths(projectADir, "tsconfig.json"): `{
|
|
"compilerOptions": {
|
|
"module": "esnext",
|
|
"strict": true,
|
|
"outDir": "./dist",
|
|
"rootDir": "./src"
|
|
},
|
|
"include": ["src"],
|
|
"references": [{ "path": "../project-b" }]
|
|
}`,
|
|
tspath.CombinePaths(projectADir, "package.json"): `{
|
|
"name": "project-a",
|
|
"dependencies": { "project-b": "*", "other-pkg": "*" }
|
|
}`,
|
|
projectAIndex: `console.log("hello");
|
|
`,
|
|
// Symlink: local workspace package (realpath outside node_modules)
|
|
tspath.CombinePaths(projectADir, "node_modules", "project-b"): vfstest.Symlink(projectBDir),
|
|
// Symlink: pnpm-style registry package (realpath inside node_modules/.pnpm)
|
|
tspath.CombinePaths(projectADir, "node_modules", "other-pkg"): vfstest.Symlink(pnpmStoreDir),
|
|
}
|
|
|
|
session, _ := projecttestutil.SetupWithOptions(files, &project.SessionOptions{
|
|
CurrentDirectory: monorepoRoot,
|
|
DefaultLibraryPath: bundled.LibPath(),
|
|
PositionEncoding: lsproto.PositionEncodingKindUTF8,
|
|
WatchEnabled: true,
|
|
LoggingEnabled: true,
|
|
PushDiagnosticsEnabled: true,
|
|
})
|
|
t.Cleanup(session.Close)
|
|
ctx := context.Background()
|
|
|
|
// Open project-a's index file and build auto-imports
|
|
projectAURI := lsconv.FileNameToDocumentURI(projectAIndex)
|
|
projectAContent := files[projectAIndex].(string)
|
|
session.DidOpenFile(ctx, projectAURI, 1, projectAContent, lsproto.LanguageKindTypeScript)
|
|
_, err := session.GetCurrentLanguageServiceWithAutoImports(ctx, projectAURI)
|
|
assert.NilError(t, err)
|
|
|
|
// Verify initial state: bucket is clean
|
|
stats := autoImportStats(t, session)
|
|
nodeModulesBucket := singleBucket(t, stats.NodeModulesBuckets)
|
|
assert.Equal(t, nodeModulesBucket.State.Dirty(), false, "bucket should be clean initially")
|
|
|
|
// Modify project-b's source file (local workspace package)
|
|
projectBURI := lsconv.FileNameToDocumentURI(projectBSrcIndex)
|
|
projectBContent := files[projectBSrcIndex].(string)
|
|
session.DidOpenFile(ctx, projectBURI, 1, projectBContent, lsproto.LanguageKindTypeScript)
|
|
session.DidChangeFile(ctx, projectBURI, 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{
|
|
{WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{Text: `export const projectBValue: number = 42;`}},
|
|
})
|
|
|
|
// project-b should get a granular update (tracked in dirtyPackages)
|
|
_, err = session.GetLanguageService(ctx, projectAURI)
|
|
assert.NilError(t, err)
|
|
stats = autoImportStats(t, session)
|
|
nodeModulesBucket = singleBucket(t, stats.NodeModulesBuckets)
|
|
assert.Equal(t, nodeModulesBucket.State.Dirty(), true, "bucket should be dirty after workspace package change")
|
|
dirtyPackages := nodeModulesBucket.State.DirtyPackages()
|
|
assert.Assert(t, dirtyPackages != nil, "dirty packages should be tracked for workspace package")
|
|
assert.Assert(t, dirtyPackages.Has("project-b"), "project-b should be in dirty packages")
|
|
assert.Equal(t, dirtyPackages.Len(), 1, "only project-b should be dirty")
|
|
|
|
// Rebuild to clear dirty state
|
|
_, err = session.GetCurrentLanguageServiceWithAutoImports(ctx, projectAURI)
|
|
assert.NilError(t, err)
|
|
stats = autoImportStats(t, session)
|
|
nodeModulesBucket = singleBucket(t, stats.NodeModulesBuckets)
|
|
assert.Equal(t, nodeModulesBucket.State.Dirty(), false, "bucket should be clean after rebuild")
|
|
|
|
// Now modify other-pkg (pnpm registry package, realpath inside node_modules/.pnpm)
|
|
otherPkgURI := lsconv.FileNameToDocumentURI(otherPkgIndex)
|
|
otherPkgContent := files[otherPkgIndex].(string)
|
|
session.DidOpenFile(ctx, otherPkgURI, 1, otherPkgContent, lsproto.LanguageKindTypeScript)
|
|
session.DidChangeFile(ctx, otherPkgURI, 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{
|
|
{WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{Text: `export declare function otherFunction(): void;`}},
|
|
})
|
|
|
|
// other-pkg should trigger a full rebuild (multipleFilesDirty), not a granular update.
|
|
// Read stats from the request-time snapshot (held by ref via WithLanguageServiceAndSnapshot)
|
|
// rather than session.Snapshot(), so a background auto-import warm task — which would
|
|
// walk otherPkgURI's ancestor chain through project-a's node_modules and rebuild that
|
|
// bucket — cannot race with the assertion by adopting a freshly-cleaned snapshot
|
|
// before we read it.
|
|
_, err = session.WithLanguageServiceAndSnapshot(
|
|
ctx,
|
|
projectAURI,
|
|
func(_ *ls.LanguageService, snapshot *project.Snapshot) (func() error, error) {
|
|
stats = snapshot.AutoImportRegistry().GetCacheStats()
|
|
return nil, nil
|
|
},
|
|
)
|
|
assert.NilError(t, err)
|
|
nodeModulesBucket = singleBucket(t, stats.NodeModulesBuckets)
|
|
assert.Equal(t, nodeModulesBucket.State.Dirty(), true, "bucket should be dirty after registry package change")
|
|
dirtyPackages = nodeModulesBucket.State.DirtyPackages()
|
|
// A full rebuild means dirtyPackages is nil (multipleFilesDirty takes precedence)
|
|
// or dirtyPackages doesn't contain "other-pkg" as a granular entry
|
|
if dirtyPackages != nil {
|
|
assert.Assert(t, !dirtyPackages.Has("other-pkg"), "other-pkg should NOT be in dirty packages (should trigger full rebuild)")
|
|
}
|
|
})
|
|
|
|
t.Run("changed fileExcludePatterns triggers bucket rebuild", func(t *testing.T) {
|
|
t.Parallel()
|
|
fixture := autoimporttestutil.SetupLifecycleSession(t, lifecycleProjectRoot, 1)
|
|
session := fixture.Session()
|
|
project := fixture.SingleProject()
|
|
mainFile := project.File(0)
|
|
|
|
ctx := context.Background()
|
|
|
|
// Open file and build auto-imports initially
|
|
session.DidOpenFile(ctx, mainFile.URI(), 1, mainFile.Content(), lsproto.LanguageKindTypeScript)
|
|
_, err := session.GetCurrentLanguageServiceWithAutoImports(ctx, mainFile.URI())
|
|
assert.NilError(t, err)
|
|
|
|
// Verify buckets are clean after initial build
|
|
stats := autoImportStats(t, session)
|
|
projectBucket := singleBucket(t, stats.ProjectBuckets)
|
|
nodeModulesBucket := singleBucket(t, stats.NodeModulesBuckets)
|
|
assert.Equal(t, false, projectBucket.State.Dirty())
|
|
assert.Equal(t, false, nodeModulesBucket.State.Dirty())
|
|
|
|
// IsPreparedForImportingFile should return true with no exclude patterns
|
|
snapshot := session.Snapshot()
|
|
defaultProject := snapshot.GetDefaultProject(mainFile.URI())
|
|
assert.Assert(t, defaultProject != nil)
|
|
projectPath := defaultProject.ConfigFilePath()
|
|
preferences := lsutil.NewDefaultUserPreferences()
|
|
preferences.IncludeCompletionsForModuleExports = core.TSTrue
|
|
preferences.IncludeCompletionsForImportStatements = core.TSTrue
|
|
isPrepared := snapshot.AutoImportRegistry().IsPreparedForImportingFile(mainFile.FileName(), projectPath, preferences)
|
|
assert.Assert(t, isPrepared)
|
|
|
|
// Change the file exclude patterns preference
|
|
newPreferences := lsutil.NewDefaultUserPreferences()
|
|
newPreferences.IncludeCompletionsForModuleExports = core.TSTrue
|
|
newPreferences.IncludeCompletionsForImportStatements = core.TSTrue
|
|
newPreferences.AutoImportFileExcludePatterns = []string{"**/node_modules/**/*.d.ts"}
|
|
session.Configure(newPreferences)
|
|
|
|
// IsPreparedForImportingFile should return false since exclude patterns changed
|
|
snapshot2 := session.Snapshot()
|
|
isPrepared2 := snapshot2.AutoImportRegistry().IsPreparedForImportingFile(mainFile.FileName(), projectPath, newPreferences)
|
|
assert.Assert(t, !isPrepared2)
|
|
|
|
// After GetCurrentLanguageServiceWithAutoImports, buckets should be rebuilt
|
|
_, err = session.GetCurrentLanguageServiceWithAutoImports(ctx, mainFile.URI())
|
|
assert.NilError(t, err)
|
|
|
|
// IsPreparedForImportingFile should return true now that buckets are rebuilt
|
|
snapshot3 := session.Snapshot()
|
|
isPrepared3 := snapshot3.AutoImportRegistry().IsPreparedForImportingFile(mainFile.FileName(), projectPath, newPreferences)
|
|
assert.Assert(t, isPrepared3, "IsPreparedForImportingFile should return true after bucket rebuild with new fileExcludePatterns")
|
|
})
|
|
|
|
t.Run("dedupes packages that resolve to same realpath across ancestor node_modules buckets", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
repoRoot := "/home/src/autoimport-realpath-dedupe"
|
|
appDir := tspath.CombinePaths(repoRoot, "apps", "web")
|
|
sharedPkgDir := tspath.CombinePaths(repoRoot, "node_modules", "shared")
|
|
appIndex := tspath.CombinePaths(appDir, "src", "index.ts")
|
|
|
|
files := map[string]any{
|
|
tspath.CombinePaths(repoRoot, "package.json"): `{
|
|
"name": "repo-root",
|
|
"private": true,
|
|
"dependencies": { "shared": "*" }
|
|
}`,
|
|
tspath.CombinePaths(repoRoot, "tsconfig.json"): `{
|
|
"compilerOptions": {
|
|
"module": "esnext",
|
|
"target": "esnext",
|
|
"strict": true
|
|
},
|
|
"include": ["apps/**/*"]
|
|
}`,
|
|
tspath.CombinePaths(appDir, "package.json"): `{
|
|
"name": "web",
|
|
"private": true,
|
|
"dependencies": { "shared": "*" }
|
|
}`,
|
|
tspath.CombinePaths(appDir, "tsconfig.json"): `{
|
|
"compilerOptions": {
|
|
"module": "esnext",
|
|
"target": "esnext",
|
|
"strict": true
|
|
},
|
|
"include": ["src"]
|
|
}`,
|
|
appIndex: "export const app = 1;\n",
|
|
tspath.CombinePaths(sharedPkgDir, "package.json"): `{
|
|
"name": "shared",
|
|
"version": "1.0.0",
|
|
"types": "index.d.ts"
|
|
}`,
|
|
tspath.CombinePaths(sharedPkgDir, "index.d.ts"): "export declare const sharedValue: 1;\n",
|
|
tspath.CombinePaths(appDir, "node_modules", "shared"): vfstest.Symlink(sharedPkgDir),
|
|
}
|
|
|
|
session, _ := projecttestutil.Setup(files)
|
|
t.Cleanup(session.Close)
|
|
|
|
ctx := context.Background()
|
|
appURI := lsconv.FileNameToDocumentURI(appIndex)
|
|
session.DidOpenFile(ctx, appURI, 1, files[appIndex].(string), lsproto.LanguageKindTypeScript)
|
|
|
|
_, err := session.GetCurrentLanguageServiceWithAutoImports(ctx, appURI)
|
|
assert.NilError(t, err)
|
|
|
|
stats := autoImportStats(t, session)
|
|
assert.Equal(t, len(stats.NodeModulesBuckets), 2, "expected both app and repo node_modules buckets")
|
|
assert.Equal(t, stats.UniquePackageCount, 1, "expected one unique package after realpath dedup")
|
|
})
|
|
}
|
|
|
|
func TestHiddenDirectoriesInNodeModules(t *testing.T) {
|
|
t.Parallel()
|
|
t.Run("deep import through subdirectory package.json in hidden store", func(t *testing.T) {
|
|
// Simulates a realistic scenario where:
|
|
// 1. A package is symlinked from node_modules into a hidden store directory
|
|
// 2. The user does a deep import like `import { debug } from "some-pkg/debug"`
|
|
// 3. The package has NO "exports" field, so resolution uses the nested
|
|
// package.json at some-pkg/debug/package.json
|
|
// 4. That nested package.json has no "name" or "version" (just {"main":"..."}),
|
|
// which is completely normal for subdirectory package.json files
|
|
// 5. getPackageId uses the nested package.json (not the root), fails to get
|
|
// a name/version, so PackageId is empty
|
|
// 6. collectPackageNames falls through to GetPackageNameFromDirectory, which
|
|
// extracts ".yarn-store" from the realpath after /node_modules/
|
|
// See https://github.com/microsoft/typescript-go/issues/2780
|
|
t.Parallel()
|
|
projectRoot := "/home/src/fuse-project"
|
|
storeDir := projectRoot + "/node_modules/.yarn-store"
|
|
pkgStoreDir := storeDir + "/some-pkg-npm-1.0.0-abc123/package"
|
|
|
|
files := map[string]any{
|
|
projectRoot + "/tsconfig.json": `{
|
|
"compilerOptions": {
|
|
"module": "commonjs",
|
|
"target": "es2020",
|
|
"strict": true
|
|
}
|
|
}`,
|
|
projectRoot + "/package.json": `{
|
|
"name": "test-project",
|
|
"dependencies": {
|
|
"some-pkg": "*",
|
|
"real-package": "*"
|
|
}
|
|
}`,
|
|
// Deep import: "some-pkg/debug" — resolves through the subdirectory package.json
|
|
projectRoot + "/index.ts": `import { debug } from "some-pkg/debug";`,
|
|
|
|
// Real package that should be indexed normally
|
|
projectRoot + "/node_modules/real-package/package.json": `{"name":"real-package","version":"1.0.0","types":"index.d.ts"}`,
|
|
projectRoot + "/node_modules/real-package/index.d.ts": "export declare const realExport: number;\n",
|
|
|
|
// Symlink: node_modules/some-pkg -> .yarn-store/.../package/
|
|
projectRoot + "/node_modules/some-pkg": vfstest.Symlink(pkgStoreDir),
|
|
|
|
// Root package.json with name+version but NO "exports" field.
|
|
// This is key: without exports, the resolver resolves deep imports
|
|
// through the subdirectory package.json, not the root.
|
|
pkgStoreDir + "/package.json": `{"name":"some-pkg","version":"1.0.0","types":"index.d.ts"}`,
|
|
pkgStoreDir + "/index.d.ts": "export declare const something: number;\n",
|
|
// Subdirectory package.json for the deep import — no name or version,
|
|
// just a main field. This is normal for packages that expose subpaths
|
|
// without using the "exports" field.
|
|
pkgStoreDir + "/debug/package.json": `{"main":"./debug.js","types":"./debug.d.ts"}`,
|
|
pkgStoreDir + "/debug/debug.d.ts": "export declare function debug(msg: string): void;\n",
|
|
pkgStoreDir + "/debug/debug.js": "exports.debug = function(msg) { console.log(msg); };\n",
|
|
|
|
// Other content in the hidden store that should never be crawled
|
|
storeDir + "/other-pkg-npm-2.0.0-def456/package/package.json": `{"name":"other-pkg","version":"1.0.0","types":"index.d.ts"}`,
|
|
storeDir + "/other-pkg-npm-2.0.0-def456/package/index.d.ts": "export declare const other: string;\n",
|
|
}
|
|
|
|
session, _ := projecttestutil.Setup(files)
|
|
t.Cleanup(session.Close)
|
|
|
|
ctx := context.Background()
|
|
indexURI := lsproto.DocumentUri("file://" + projectRoot + "/index.ts")
|
|
session.DidOpenFile(ctx, indexURI, 1, files[projectRoot+"/index.ts"].(string), lsproto.LanguageKindTypeScript)
|
|
|
|
_, err := session.GetCurrentLanguageServiceWithAutoImports(ctx, indexURI)
|
|
assert.NilError(t, err)
|
|
|
|
stats := autoImportStats(t, session)
|
|
nodeModulesBucket := singleBucket(t, stats.NodeModulesBuckets)
|
|
|
|
// .yarn-store must not appear as a dependency name.
|
|
// If it does, extractPackages will try to process the entire hidden
|
|
// directory (ReadDirectory **/*), which is the CPU/memory blowup.
|
|
assert.Assert(t, nodeModulesBucket.DependencyNames != nil, "DependencyNames should not be nil")
|
|
for name := range nodeModulesBucket.DependencyNames.Keys() {
|
|
assert.Assert(t, name[0] != '.', "hidden directory %q should not appear as a dependency name", name)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestAutoImportEntrypointDirectorySearch(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
projectRoot := "/home/src/entrypoint-search"
|
|
nodeModulesDir := projectRoot + "/node_modules"
|
|
pkgDir := nodeModulesDir + "/my-pkg"
|
|
|
|
files := map[string]any{
|
|
projectRoot + "/tsconfig.json": `{
|
|
"compilerOptions": {
|
|
"module": "commonjs",
|
|
"target": "es2020"
|
|
}
|
|
}`,
|
|
projectRoot + "/package.json": `{
|
|
"name": "test-project",
|
|
"dependencies": { "my-pkg": "*" }
|
|
}`,
|
|
projectRoot + "/index.ts": `import { main } from "my-pkg";`,
|
|
// Package with NO "exports" field and multiple files.
|
|
// Without the preference, only the main entrypoint is found.
|
|
// With the preference, directory search finds all .d.ts files.
|
|
pkgDir + "/package.json": `{"name":"my-pkg","version":"1.0.0","types":"index.d.ts"}`,
|
|
pkgDir + "/index.d.ts": "export declare const main: number;\n",
|
|
pkgDir + "/extra.d.ts": "export declare const extra: string;\n",
|
|
pkgDir + "/nested/deep.d.ts": "export declare const deep: boolean;\n",
|
|
pkgDir + "/nested/deeper.d.ts": "export declare const deeper: boolean;\n",
|
|
}
|
|
|
|
t.Run("default limits to main entrypoint", func(t *testing.T) {
|
|
t.Parallel()
|
|
session, _ := projecttestutil.Setup(files)
|
|
t.Cleanup(session.Close)
|
|
|
|
ctx := context.Background()
|
|
indexURI := lsproto.DocumentUri("file://" + projectRoot + "/index.ts")
|
|
session.DidOpenFile(ctx, indexURI, 1, files[projectRoot+"/index.ts"].(string), lsproto.LanguageKindTypeScript)
|
|
|
|
_, err := session.GetCurrentLanguageServiceWithAutoImports(ctx, indexURI)
|
|
assert.NilError(t, err)
|
|
|
|
stats := autoImportStats(t, session)
|
|
nodeModulesBucket := singleBucket(t, stats.NodeModulesBuckets)
|
|
// Without the preference, only the main entrypoint (index.d.ts) should be found
|
|
assert.Equal(t, 1, nodeModulesBucket.FileCount, "expected only 1 file (main entrypoint) by default")
|
|
})
|
|
|
|
t.Run("autoImportEntrypointDirectorySearch enables all files", func(t *testing.T) {
|
|
t.Parallel()
|
|
session, _ := projecttestutil.Setup(files)
|
|
t.Cleanup(session.Close)
|
|
|
|
prefs := lsutil.NewDefaultUserPreferences()
|
|
prefs.AutoImportEntrypointDirectorySearch = core.TSTrue
|
|
session.Configure(prefs)
|
|
|
|
ctx := context.Background()
|
|
indexURI := lsproto.DocumentUri("file://" + projectRoot + "/index.ts")
|
|
session.DidOpenFile(ctx, indexURI, 1, files[projectRoot+"/index.ts"].(string), lsproto.LanguageKindTypeScript)
|
|
|
|
_, err := session.GetCurrentLanguageServiceWithAutoImports(ctx, indexURI)
|
|
assert.NilError(t, err)
|
|
|
|
stats := autoImportStats(t, session)
|
|
nodeModulesBucket := singleBucket(t, stats.NodeModulesBuckets)
|
|
// With the preference, all 4 .d.ts files should be found via directory search
|
|
assert.Assert(t, nodeModulesBucket.FileCount >= 4, "expected at least 4 files from directory search, got %d", nodeModulesBucket.FileCount)
|
|
})
|
|
|
|
t.Run("changing preference triggers rebuild", func(t *testing.T) {
|
|
t.Parallel()
|
|
session, _ := projecttestutil.Setup(files)
|
|
t.Cleanup(session.Close)
|
|
|
|
ctx := context.Background()
|
|
indexURI := lsproto.DocumentUri("file://" + projectRoot + "/index.ts")
|
|
session.DidOpenFile(ctx, indexURI, 1, files[projectRoot+"/index.ts"].(string), lsproto.LanguageKindTypeScript)
|
|
|
|
// Build auto-imports with default preferences (directory search disabled)
|
|
_, err := session.GetCurrentLanguageServiceWithAutoImports(ctx, indexURI)
|
|
assert.NilError(t, err)
|
|
|
|
stats := autoImportStats(t, session)
|
|
nodeModulesBucket := singleBucket(t, stats.NodeModulesBuckets)
|
|
assert.Equal(t, 1, nodeModulesBucket.FileCount, "expected only 1 file initially")
|
|
|
|
// Now enable directory search
|
|
prefs := lsutil.NewDefaultUserPreferences()
|
|
prefs.AutoImportEntrypointDirectorySearch = core.TSTrue
|
|
session.Configure(prefs)
|
|
|
|
// Registry should report not prepared (preference changed)
|
|
snapshot := session.Snapshot()
|
|
defaultProject := snapshot.GetDefaultProject(indexURI)
|
|
assert.Assert(t, defaultProject != nil)
|
|
projectPath := defaultProject.ConfigFilePath()
|
|
isPrepared := snapshot.AutoImportRegistry().IsPreparedForImportingFile(
|
|
projectRoot+"/index.ts", projectPath, prefs,
|
|
)
|
|
assert.Assert(t, !isPrepared, "registry should not be prepared after preference change")
|
|
|
|
// Rebuild
|
|
_, err = session.GetCurrentLanguageServiceWithAutoImports(ctx, indexURI)
|
|
assert.NilError(t, err)
|
|
|
|
stats = autoImportStats(t, session)
|
|
nodeModulesBucket = singleBucket(t, stats.NodeModulesBuckets)
|
|
assert.Assert(t, nodeModulesBucket.FileCount >= 4, "expected at least 4 files after rebuild with directory search enabled, got %d", nodeModulesBucket.FileCount)
|
|
})
|
|
|
|
t.Run("deep import from program update enables recursive search for that package", func(t *testing.T) {
|
|
t.Parallel()
|
|
session, _ := projecttestutil.Setup(files)
|
|
t.Cleanup(session.Close)
|
|
|
|
ctx := context.Background()
|
|
indexURI := lsproto.DocumentUri("file://" + projectRoot + "/index.ts")
|
|
session.DidOpenFile(ctx, indexURI, 1, files[projectRoot+"/index.ts"].(string), lsproto.LanguageKindTypeScript)
|
|
|
|
// Initial build with top-level import only ("my-pkg", not a deep import)
|
|
_, err := session.GetCurrentLanguageServiceWithAutoImports(ctx, indexURI)
|
|
assert.NilError(t, err)
|
|
|
|
stats := autoImportStats(t, session)
|
|
nodeModulesBucket := singleBucket(t, stats.NodeModulesBuckets)
|
|
assert.Equal(t, 1, nodeModulesBucket.FileCount, "expected only 1 file (main entrypoint) before deep import")
|
|
|
|
// Now update the program to add a deep import from the same package
|
|
newContent := "import { main } from \"my-pkg\";\nimport { deep } from \"my-pkg/nested/deep\";\n"
|
|
session.DidChangeFile(ctx, indexURI, 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{
|
|
{WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{Text: newContent}},
|
|
})
|
|
|
|
// After the program update, auto-imports should detect the deep import and
|
|
// enable recursive directory search for my-pkg, finding all .d.ts files.
|
|
_, err = session.GetCurrentLanguageServiceWithAutoImports(ctx, indexURI)
|
|
assert.NilError(t, err)
|
|
|
|
stats = autoImportStats(t, session)
|
|
nodeModulesBucket = singleBucket(t, stats.NodeModulesBuckets)
|
|
assert.Assert(t, nodeModulesBucket.FileCount >= 4, "expected at least 4 files after deep import triggers recursive search, got %d", nodeModulesBucket.FileCount)
|
|
})
|
|
}
|
|
|
|
const (
|
|
lifecycleProjectRoot = "/home/src/autoimport-lifecycle"
|
|
monorepoProjectRoot = "/home/src/autoimport-monorepo"
|
|
)
|
|
|
|
func TestUpdateIndexesConcurrentMapSafety(t *testing.T) {
|
|
t.Parallel()
|
|
const projectRoot = "/home/src/autoimport-fallback-race"
|
|
const packageCount = 40
|
|
|
|
files := map[string]any{
|
|
projectRoot + "/tsconfig.json": `{
|
|
"compilerOptions": { "module": "esnext", "target": "esnext", "strict": true }
|
|
}`,
|
|
projectRoot + "/index.ts": "export {};\n",
|
|
}
|
|
for i := range packageCount {
|
|
pkgDir := fmt.Sprintf("%s/node_modules/pkg%d", projectRoot, i)
|
|
files[pkgDir+"/package.json"] = fmt.Sprintf(`{"name":"pkg%d","version":"1.0.0","main":"index.js"}`, i)
|
|
files[pkgDir+"/index.js"] = "module.exports = {};\n"
|
|
typesDir := fmt.Sprintf("%s/node_modules/@types/pkg%d", projectRoot, i)
|
|
files[typesDir+"/package.json"] = fmt.Sprintf(`{"name":"@types/pkg%d","version":"1.0.0","types":"index.d.ts"}`, i)
|
|
files[typesDir+"/index.d.ts"] = fmt.Sprintf("export declare const foo%d: number;\n", i)
|
|
}
|
|
|
|
session, _ := projecttestutil.Setup(files)
|
|
t.Cleanup(session.Close)
|
|
|
|
ctx := context.Background()
|
|
indexURI := lsproto.DocumentUri("file://" + projectRoot + "/index.ts")
|
|
session.DidOpenFile(ctx, indexURI, 1, "export {};\n", lsproto.LanguageKindTypeScript)
|
|
|
|
_, err := session.GetCurrentLanguageServiceWithAutoImports(ctx, indexURI)
|
|
assert.NilError(t, err)
|
|
|
|
stats := autoImportStats(t, session)
|
|
nodeModulesBucket := singleBucket(t, stats.NodeModulesBuckets)
|
|
assert.Equal(t, nodeModulesBucket.ExportCount, packageCount)
|
|
}
|
|
|
|
func autoImportStats(t *testing.T, session *project.Session) *autoimport.CacheStats {
|
|
t.Helper()
|
|
snapshot := session.Snapshot()
|
|
registry := snapshot.AutoImportRegistry()
|
|
if registry == nil {
|
|
t.Fatal("auto import registry not initialized")
|
|
}
|
|
return registry.GetCacheStats()
|
|
}
|
|
|
|
func singleBucket(t *testing.T, buckets []autoimport.BucketStats) autoimport.BucketStats {
|
|
t.Helper()
|
|
if len(buckets) != 1 {
|
|
t.Fatalf("expected 1 bucket, got %d", len(buckets))
|
|
}
|
|
return buckets[0]
|
|
}
|