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

392 lines
17 KiB
Go

package project_test
import (
"context"
"fmt"
"testing"
"github.com/microsoft/typescript-go/internal/bundled"
"github.com/microsoft/typescript-go/internal/core"
"github.com/microsoft/typescript-go/internal/lsp/lsproto"
"github.com/microsoft/typescript-go/internal/project"
"github.com/microsoft/typescript-go/internal/testutil/projecttestutil"
"gotest.tools/v3/assert"
)
func TestBulkCacheInvalidation(t *testing.T) {
t.Parallel()
if !bundled.Embedded {
t.Skip("bundled files are not embedded")
}
// Base file structure for testing
baseFiles := map[string]any{
"/project/tsconfig.json": `{
"compilerOptions": {
"strict": true,
"target": "es2015",
"types": ["node"]
},
"include": ["src/**/*"]
}`,
"/project/src/index.ts": `import { helper } from "./helper"; console.log(helper);`,
"/project/src/helper.ts": `export const helper = "test";`,
"/project/src/utils/lib.ts": `export function util() { return "util"; }`,
"/project/node_modules/@types/node/index.d.ts": `import "./fs"; import "./console";`,
"/project/node_modules/@types/node/fs.d.ts": ``,
"/project/node_modules/@types/node/console.d.ts": ``,
}
t.Run("large number of node_modules changes invalidates only node_modules cache", func(t *testing.T) {
t.Parallel()
test := func(t *testing.T, fileEvents []*lsproto.FileEvent, expectNodeModulesInvalidation bool) {
session, utils := projecttestutil.Setup(baseFiles)
// Open a file to create the project
session.DidOpenFile(context.Background(), "file:///project/src/index.ts", 1, baseFiles["/project/src/index.ts"].(string), lsproto.LanguageKindTypeScript)
// Get initial snapshot and verify config
ls, err := session.GetLanguageService(context.Background(), "file:///project/src/index.ts")
assert.NilError(t, err)
assert.Equal(t, ls.GetProgram().Options().Target, core.ScriptTargetES2015)
snapshotBefore := session.Snapshot()
configBefore := snapshotBefore.ConfigFileRegistry
// Update tsconfig.json on disk to test that configs don't get reloaded
err = utils.FS().WriteFile("/project/tsconfig.json", `{
"compilerOptions": {
"strict": true,
"target": "esnext",
"types": ["node"]
},
"include": ["src/**/*"]
}`)
assert.NilError(t, err)
// Update fs.d.ts in node_modules
err = utils.FS().WriteFile("/project/node_modules/@types/node/fs.d.ts", "new text")
assert.NilError(t, err)
// Process the excessive node_modules changes
session.DidChangeWatchedFiles(context.Background(), fileEvents)
// Get language service again to trigger snapshot update
ls, err = session.GetLanguageService(context.Background(), "file:///project/src/index.ts")
assert.NilError(t, err)
snapshotAfter := session.Snapshot()
configAfter := snapshotAfter.ConfigFileRegistry
// Config should NOT have been reloaded (target should remain ES2015, not esnext)
assert.Equal(t, ls.GetProgram().Options().Target, core.ScriptTargetES2015, "Config should not have been reloaded for node_modules-only changes")
// Config registry should be the same instance (no configs reloaded)
assert.Equal(t, configBefore, configAfter, "Config registry should not have changed for node_modules-only changes")
fsDtsText := snapshotAfter.GetFile("/project/node_modules/@types/node/fs.d.ts").Content()
if expectNodeModulesInvalidation {
assert.Equal(t, fsDtsText, "new text")
} else {
assert.Equal(t, fsDtsText, "")
}
}
t.Run("with file existing in cache", func(t *testing.T) {
t.Parallel()
fileEvents := generateFileEvents(1001, "file:///project/node_modules/generated/file%d.js", lsproto.FileChangeTypeCreated)
// Include two files in the program to trigger a full program creation.
// Exclude fs.d.ts to show that its content still gets invalidated.
fileEvents = append(fileEvents, &lsproto.FileEvent{
Uri: "file:///project/node_modules/@types/node/index.d.ts",
Type: lsproto.FileChangeTypeChanged,
}, &lsproto.FileEvent{
Uri: "file:///project/node_modules/@types/node/console.d.ts",
Type: lsproto.FileChangeTypeChanged,
})
test(t, fileEvents, true)
})
t.Run("without file existing in cache", func(t *testing.T) {
t.Parallel()
fileEvents := generateFileEvents(1001, "file:///project/node_modules/generated/file%d.js", lsproto.FileChangeTypeCreated)
test(t, fileEvents, false)
})
})
t.Run("large number of changes outside node_modules", func(t *testing.T) {
t.Parallel()
test := func(t *testing.T, fileEvents []*lsproto.FileEvent, expectConfigReload bool) {
session, utils := projecttestutil.Setup(baseFiles)
// Open a file to create the project
session.DidOpenFile(context.Background(), "file:///project/src/index.ts", 1, baseFiles["/project/src/index.ts"].(string), lsproto.LanguageKindTypeScript)
// Get initial state
ls, err := session.GetLanguageService(context.Background(), "file:///project/src/index.ts")
assert.NilError(t, err)
assert.Equal(t, ls.GetProgram().Options().Target, core.ScriptTargetES2015)
// Update tsconfig.json on disk
err = utils.FS().WriteFile("/project/tsconfig.json", `{
"compilerOptions": {
"strict": true,
"target": "esnext",
"types": ["node"]
},
"include": ["src/**/*"]
}`)
assert.NilError(t, err)
// Add root file
err = utils.FS().WriteFile("/project/src/rootFile.ts", `console.log("root file")`)
assert.NilError(t, err)
session.DidChangeWatchedFiles(context.Background(), fileEvents)
ls, err = session.GetLanguageService(context.Background(), "file:///project/src/index.ts")
assert.NilError(t, err)
if expectConfigReload {
assert.Equal(t, ls.GetProgram().Options().Target, core.ScriptTargetESNext, "Config should have been reloaded for changes outside node_modules")
assert.Check(t, ls.GetProgram().GetSourceFile("/project/src/rootFile.ts") != nil, "New root file should be present")
} else {
assert.Equal(t, ls.GetProgram().Options().Target, core.ScriptTargetES2015, "Config should not have been reloaded for changes outside node_modules")
assert.Check(t, ls.GetProgram().GetSourceFile("/project/src/rootFile.ts") == nil, "New root file should not be present")
}
}
t.Run("with event matching include glob", func(t *testing.T) {
t.Parallel()
fileEvents := generateFileEvents(1001, "file:///project/generated/file%d.ts", lsproto.FileChangeTypeCreated)
fileEvents = append(fileEvents, &lsproto.FileEvent{
Uri: "file:///project/src/rootFile.ts",
Type: lsproto.FileChangeTypeCreated,
})
test(t, fileEvents, true)
})
t.Run("without event matching include glob", func(t *testing.T) {
t.Parallel()
fileEvents := generateFileEvents(1001, "file:///project/generated/file%d.ts", lsproto.FileChangeTypeCreated)
test(t, fileEvents, false)
})
})
t.Run("large number of changes outside node_modules causes project reevaluation", func(t *testing.T) {
t.Parallel()
session, utils := projecttestutil.Setup(baseFiles)
// Open a file that will initially use the root tsconfig
session.DidOpenFile(context.Background(), "file:///project/src/utils/lib.ts", 1, baseFiles["/project/src/utils/lib.ts"].(string), lsproto.LanguageKindTypeScript)
// Initially, the file should use the root project (strict mode)
snapshot := session.Snapshot()
initialProject := snapshot.GetDefaultProject("file:///project/src/utils/lib.ts")
assert.Equal(t, initialProject.Name(), "/project/tsconfig.json", "Should initially use root tsconfig")
// Get language service to verify initial strict mode
ls, err := session.GetLanguageService(context.Background(), "file:///project/src/utils/lib.ts")
assert.NilError(t, err)
assert.Equal(t, ls.GetProgram().Options().Strict, core.TSTrue, "Should initially use strict mode from root config")
// Now create the nested tsconfig (this would normally be detected, but we'll simulate a missed event)
err = utils.FS().WriteFile("/project/src/utils/tsconfig.json", `{
"compilerOptions": {
"strict": false,
"target": "esnext"
}
}`)
assert.NilError(t, err)
// Create excessive changes to trigger bulk invalidation
fileEvents := generateFileEvents(1001, "file:///project/src/generated/file%d.ts", lsproto.FileChangeTypeCreated)
// Process the excessive changes - this should trigger project reevaluation
session.DidChangeWatchedFiles(context.Background(), fileEvents)
// Get language service - this should now find the nested config and switch projects
ls, err = session.GetLanguageService(context.Background(), "file:///project/src/utils/lib.ts")
assert.NilError(t, err)
snapshot = session.Snapshot()
newProject := snapshot.GetDefaultProject("file:///project/src/utils/lib.ts")
// The file should now use the nested tsconfig
assert.Equal(t, newProject.Name(), "/project/src/utils/tsconfig.json", "Should now use nested tsconfig after bulk invalidation")
assert.Equal(t, ls.GetProgram().Options().Strict, core.TSFalse, "Should now use non-strict mode from nested config")
assert.Equal(t, ls.GetProgram().Options().Target, core.ScriptTargetESNext, "Should use esnext target from nested config")
})
t.Run("config file names cache", func(t *testing.T) {
t.Parallel()
test := func(t *testing.T, fileEvents []*lsproto.FileEvent, expectConfigDiscovery bool, testName string) {
files := map[string]any{
"/project/src/index.ts": `console.log("test");`, // No tsconfig initially
}
session, utils := projecttestutil.Setup(files)
// Open file without tsconfig - should create inferred project
session.DidOpenFile(context.Background(), "file:///project/src/index.ts", 1, files["/project/src/index.ts"].(string), lsproto.LanguageKindTypeScript)
snapshot := session.Snapshot()
assert.Assert(t, snapshot.ProjectCollection.InferredProject() != nil, "Should have inferred project")
assert.Equal(t, snapshot.GetDefaultProject("file:///project/src/index.ts").Kind, project.KindInferred)
// Create a tsconfig that would affect this file (simulating a missed creation event)
err := utils.FS().WriteFile("/project/tsconfig.json", `{
"compilerOptions": {
"strict": true
},
"include": ["src/**/*"]
}`)
assert.NilError(t, err)
// Process the changes
session.DidChangeWatchedFiles(context.Background(), fileEvents)
// Get language service to trigger config discovery
_, err = session.GetLanguageService(context.Background(), "file:///project/src/index.ts")
assert.NilError(t, err)
snapshot = session.Snapshot()
newProject := snapshot.GetDefaultProject("file:///project/src/index.ts")
// Check expected behavior
if expectConfigDiscovery {
// Should now use configured project instead of inferred
assert.Equal(t, newProject.Kind, project.KindConfigured, "Should now use configured project after cache invalidation")
assert.Equal(t, newProject.Name(), "/project/tsconfig.json", "Should use the newly discovered tsconfig")
} else {
// Should still use inferred project (config file names cache not cleared)
assert.Assert(t, newProject == snapshot.ProjectCollection.InferredProject(), "Should still use inferred project after node_modules-only changes")
}
}
t.Run("excessive changes only in node_modules does not affect config file names cache", func(t *testing.T) {
t.Parallel()
fileEvents := generateFileEvents(1001, "file:///project/node_modules/generated/file%d.js", lsproto.FileChangeTypeCreated)
test(t, fileEvents, false, "node_modules changes should not clear config cache")
})
t.Run("excessive changes outside node_modules clears config file names cache", func(t *testing.T) {
t.Parallel()
fileEvents := generateFileEvents(1001, "file:///project/src/generated/file%d.ts", lsproto.FileChangeTypeCreated)
// Presence of any tsconfig.json file event triggers rediscovery for config for all open files
fileEvents = append(fileEvents, &lsproto.FileEvent{
Uri: lsproto.DocumentUri("file:///project/src/generated/tsconfig.json"),
Type: lsproto.FileChangeTypeCreated,
})
test(t, fileEvents, true, "non-node_modules changes should clear config cache")
})
})
// Simulate external build tool changing files in dist/ (not included by any project)
t.Run("excessive changes in dist folder do not invalidate", func(t *testing.T) {
t.Parallel()
files := map[string]any{
"/project/src/index.ts": `console.log("test");`, // No tsconfig initially
}
session, utils := projecttestutil.Setup(files)
// Open file without tsconfig - should create inferred project
session.DidOpenFile(context.Background(), "file:///project/src/index.ts", 1, files["/project/src/index.ts"].(string), lsproto.LanguageKindTypeScript)
snapshot := session.Snapshot()
assert.Equal(t, snapshot.GetDefaultProject("file:///project/src/index.ts").Kind, project.KindInferred)
// Create a tsconfig that would affect this file (simulating a missed creation event)
// This should NOT be discovered after dist-folder changes
err := utils.FS().WriteFile("/project/tsconfig.json", `{
"compilerOptions": {
"strict": true
},
"include": ["src/**/*"]
}`)
assert.NilError(t, err)
// Create excessive changes in dist folder only
fileEvents := generateFileEvents(1001, "file:///project/dist/generated/file%d.js", lsproto.FileChangeTypeCreated)
session.DidChangeWatchedFiles(context.Background(), fileEvents)
// File should still use inferred project (config file names cache NOT cleared for dist changes)
_, err = session.GetLanguageService(context.Background(), "file:///project/src/index.ts")
assert.NilError(t, err)
snapshot = session.Snapshot()
newProject := snapshot.GetDefaultProject("file:///project/src/index.ts")
assert.Equal(t, newProject.Kind, project.KindInferred, "dist-folder changes should not cause config discovery")
// This assertion will fail until we implement logic to ignore dist folder changes
})
// Regression test for https://github.com/microsoft/typescript-go/issues/4545
//
// A config file entry can be retained (here, by an open file whose default
// project search fanned out to a referenced config) while its commandLine is
// nil, because the referenced config file does not exist. A bulk cache
// invalidation triggered by an excessive number of watch events ranges over
// all config entries and used to dereference entry.commandLine.ConfigFile
// without a nil check, crashing.
//
// The nil-check-avoiding short circuit `!ok || text != entry.commandLine...`
// only protects the case where the file cannot be read, so the config file
// must exist on disk (readable) while the entry's commandLine is still nil to
// reach the crash.
t.Run("bulk invalidation with retained config whose command line is nil", func(t *testing.T) {
t.Parallel()
appConfig := `{
"compilerOptions": { "composite": true, "target": "esnext" },
"include": ["**/*"]
}`
files := map[string]any{
// Solution config references ./app, but app/tsconfig.json does not exist.
"/project/tsconfig.json": `{
"compilerOptions": { "composite": true },
"files": [],
"references": [{ "path": "./app" }]
}`,
"/project/app/main.ts": `export const main = 1;`,
}
session, utils := projecttestutil.Setup(files)
// Open a file in the (non-existent) referenced project. The default project
// search fans out to app/tsconfig.json, creating a retained config entry
// with commandLine == nil and pendingReload == None.
session.DidOpenFile(context.Background(), "file:///project/app/main.ts", 1, files["/project/app/main.ts"].(string), lsproto.LanguageKindTypeScript)
_, err := session.GetLanguageService(context.Background(), "file:///project/app/main.ts")
assert.NilError(t, err)
// Create the referenced config on disk WITHOUT notifying, so the
// nil-commandLine entry is not reloaded (pendingReload stays None) but the
// file becomes readable.
err = utils.FS().WriteFile("/project/app/tsconfig.json", appConfig)
assert.NilError(t, err)
// Trigger a bulk cache invalidation with an excessive number of watch events.
// The creation of a config file drives the excessive-change path into
// invalidateCache, which ranges over all config entries -- including the one
// whose commandLine is nil -- and used to crash.
fileEvents := generateFileEvents(1001, "file:///project/app/generated/file%d.ts", lsproto.FileChangeTypeCreated)
fileEvents = append(fileEvents, &lsproto.FileEvent{
Uri: "file:///project/newdir/tsconfig.json",
Type: lsproto.FileChangeTypeCreated,
})
session.DidChangeWatchedFiles(context.Background(), fileEvents)
_, err = session.GetLanguageService(context.Background(), "file:///project/app/main.ts")
assert.NilError(t, err)
})
}
// Helper function to generate excessive file change events
func generateFileEvents(count int, pathTemplate string, changeType lsproto.FileChangeType) []*lsproto.FileEvent {
var events []*lsproto.FileEvent
for i := range count {
events = append(events, &lsproto.FileEvent{
Uri: lsproto.DocumentUri(fmt.Sprintf(pathTemplate, i)),
Type: changeType,
})
}
return events
}