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 }