package project import ( "context" "fmt" "testing" "github.com/microsoft/typescript-go/internal/bundled" "github.com/microsoft/typescript-go/internal/lsp/lsproto" "github.com/microsoft/typescript-go/internal/tspath" "github.com/microsoft/typescript-go/internal/vfs/vfstest" "gotest.tools/v3/assert" ) func TestSnapshot(t *testing.T) { t.Parallel() if !bundled.Embedded { t.Skip("bundled files are not embedded") } setup := func(files map[string]any) *Session { fs := bundled.WrapFS(vfstest.FromMap(files, false /*useCaseSensitiveFileNames*/)) session := NewSession(&SessionInit{ BackgroundCtx: context.Background(), Options: &SessionOptions{ CurrentDirectory: "/", DefaultLibraryPath: bundled.LibPath(), TypingsLocation: "/home/src/Library/Caches/typescript", PositionEncoding: lsproto.PositionEncodingKindUTF8, WatchEnabled: false, LoggingEnabled: false, }, FS: fs, }) return session } t.Run("compilerHost gets frozen with snapshot's FS only once", func(t *testing.T) { t.Parallel() files := map[string]any{ "/home/projects/TS/p1/tsconfig.json": "{}", "/home/projects/TS/p1/index.ts": "console.log('Hello, world!');", } session := setup(files) session.DidOpenFile(context.Background(), "file:///home/projects/TS/p1/index.ts", 1, files["/home/projects/TS/p1/index.ts"].(string), lsproto.LanguageKindTypeScript) session.DidOpenFile(context.Background(), "untitled:Untitled-1", 1, "", lsproto.LanguageKindTypeScript) snapshotBefore := session.Snapshot() session.DidChangeFile(context.Background(), "file:///home/projects/TS/p1/index.ts", 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{ { Partial: &lsproto.TextDocumentContentChangePartial{ Text: "\n", Range: lsproto.Range{ Start: lsproto.Position{Line: 0, Character: 24}, End: lsproto.Position{Line: 0, Character: 24}, }, }, }, }) _, err := session.GetLanguageService(context.Background(), "file:///home/projects/TS/p1/index.ts") assert.NilError(t, err) snapshotAfter := session.Snapshot() // Configured project was updated by a clone assert.Equal(t, snapshotAfter.ProjectCollection.ConfiguredProject(tspath.Path("/home/projects/ts/p1/tsconfig.json")).ProgramUpdateKind, ProgramUpdateKindCloned) // Inferred project wasn't updated last snapshot change, so its program update kind is still NewFiles assert.Equal(t, snapshotBefore.ProjectCollection.InferredProject(), snapshotAfter.ProjectCollection.InferredProject()) assert.Equal(t, snapshotAfter.ProjectCollection.InferredProject().ProgramUpdateKind, ProgramUpdateKindNewFiles) // host for inferred project should not change assert.Equal(t, snapshotAfter.ProjectCollection.InferredProject().host.sourceFS.source, snapshotBefore.fs) }) t.Run("cached disk files are cleaned up", func(t *testing.T) { t.Parallel() files := map[string]any{ "/home/projects/TS/p1/tsconfig.json": "{}", "/home/projects/TS/p1/index.ts": "import { a } from './a'; console.log(a);", "/home/projects/TS/p1/a.ts": "export const a = 1;", "/home/projects/TS/p2/tsconfig.json": "{}", "/home/projects/TS/p2/index.ts": "import { b } from './b'; console.log(b);", "/home/projects/TS/p2/b.ts": "export const b = 2;", } session := setup(files) session.DidOpenFile(context.Background(), "file:///home/projects/TS/p1/index.ts", 1, files["/home/projects/TS/p1/index.ts"].(string), lsproto.LanguageKindTypeScript) session.DidOpenFile(context.Background(), "file:///home/projects/TS/p2/index.ts", 1, files["/home/projects/TS/p2/index.ts"].(string), lsproto.LanguageKindTypeScript) snapshotBefore := session.Snapshot() // a.ts and b.ts are cached assert.Check(t, snapshotBefore.fs.diskFiles["/home/projects/ts/p1/a.ts"] != nil) assert.Check(t, snapshotBefore.fs.diskFiles["/home/projects/ts/p2/b.ts"] != nil) // Close p1's only open file session.DidCloseFile(context.Background(), "file:///home/projects/TS/p1/index.ts") // Next open file is unrelated to p1, triggers p1 closing and file cache cleanup session.DidOpenFile(context.Background(), "untitled:Untitled-1", 1, "", lsproto.LanguageKindTypeScript) snapshotAfter := session.Snapshot() // a.ts is cleaned up, b.ts is still cached assert.Check(t, snapshotAfter.fs.diskFiles["/home/projects/ts/p1/a.ts"] == nil) assert.Check(t, snapshotAfter.fs.diskFiles["/home/projects/ts/p2/b.ts"] != nil) }) t.Run("GetFile returns nil for non-existent files", func(t *testing.T) { t.Parallel() files := map[string]any{ "/home/projects/TS/p1/tsconfig.json": "{}", "/home/projects/TS/p1/index.ts": "console.log('Hello, world!');", } session := setup(files) session.DidOpenFile(context.Background(), "file:///home/projects/TS/p1/index.ts", 1, files["/home/projects/TS/p1/index.ts"].(string), lsproto.LanguageKindTypeScript) snapshot := session.Snapshot() handle := snapshot.GetFile("/home/projects/TS/p1/nonexistent.ts") assert.Check(t, handle == nil, "GetFile should return nil for non-existent file") // Test that ReadFile returns false for non-existent file _, ok := snapshot.ReadFile("/home/projects/TS/p1/nonexistent.ts") assert.Check(t, !ok, "ReadFile should return false for non-existent file") }) t.Run("program change loads node_modules dependency and auto-imports includes it", func(t *testing.T) { t.Parallel() files := map[string]any{ "/home/projects/otherproject/tsconfig.json": `{ "compilerOptions": { "module": "commonjs" } }`, "/home/projects/otherproject/index.ts": ``, "/home/projects/node_modules/foo/package.json": `{ "types": "index.d.ts", "typesVersions": { "*": { "bar/*": ["dist/*"], "exact-match": ["dist/index.d.ts"], "foo/*": ["dist/*"], "*": ["dist/*"] } } }`, "/home/projects/node_modules/foo/nope.d.ts": `export const nope = 0;`, "/home/projects/node_modules/foo/dist/index.d.ts": `export const index = 0;`, "/home/projects/node_modules/foo/dist/blah.d.ts": `export const blah = 0;`, "/home/projects/node_modules/foo/dist/foo/onlyInFooFolder.d.ts": `export const foo = 0;`, "/home/projects/node_modules/foo/dist/subfolder/one.d.ts": `export const one = 0;`, } session := setup(files) t.Cleanup(session.Close) ctx := context.Background() otherIndexURI := lsproto.DocumentUri("file:///home/projects/otherproject/index.ts") // Open the file session.DidOpenFile(ctx, otherIndexURI, 1, files["/home/projects/otherproject/index.ts"].(string), lsproto.LanguageKindTypeScript) // Insert import statement: // This will trigger both a program rebuild which will include the node_modules files, // and an auto-import collection which should find the exports from those files. session.DidChangeFile(ctx, otherIndexURI, 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{ { Partial: &lsproto.TextDocumentContentChangePartial{ Text: `import {} from "foo/foo/subfolder/one";`, Range: lsproto.Range{ Start: lsproto.Position{Line: 0, Character: 0}, End: lsproto.Position{Line: 0, Character: 0}, }, }, }, }) // Now trigger snapshot clone with both program update and auto-imports registry building. _, err := session.GetCurrentLanguageServiceWithAutoImports(ctx, otherIndexURI) assert.NilError(t, err) }) t.Run("fallback rebuild with recomputed parse options is safe for later clone", func(t *testing.T) { t.Parallel() testFiles := map[string]any{ "/project/node_modules/pkg/index.ts": `export const pkg = 0;`, "/project/src/other.ts": `export const other = 1;`, } session := setup(testFiles) pkgURI := lsproto.DocumentUri("file:///project/node_modules/pkg/index.ts") otherURI := lsproto.DocumentUri("file:///project/src/other.ts") session.DidOpenFile(context.Background(), pkgURI, 1, testFiles["/project/node_modules/pkg/index.ts"].(string), lsproto.LanguageKindTypeScript) session.DidOpenFile(context.Background(), otherURI, 1, testFiles["/project/src/other.ts"].(string), lsproto.LanguageKindTypeScript) _, err := session.GetLanguageService(context.Background(), pkgURI) assert.NilError(t, err) err = session.fs.fs.WriteFile("/project/node_modules/pkg/package.json", `{ "type": "module" }`) assert.NilError(t, err) session.DidChangeFile(context.Background(), pkgURI, 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{ { WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{ Text: `import "./missing"; export const pkg = 1;`, }, }, }) _, err = session.GetLanguageService(context.Background(), pkgURI) assert.NilError(t, err) session.DidChangeFile(context.Background(), otherURI, 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{ { WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{ Text: `export const other = 2;`, }, }, }) _, err = session.GetLanguageService(context.Background(), otherURI) assert.NilError(t, err) }) } func BenchmarkSnapshotCloneRefCost(b *testing.B) { if !bundled.Embedded { b.Skip("bundled files are not embedded") } for _, largeProjectSize := range []int{100, 1000, 10_000} { b.Run(fmt.Sprintf("largeProject_%d_files", largeProjectSize), func(b *testing.B) { files := map[string]any{ // Small project: 100 files "/small/tsconfig.json": `{"compilerOptions": {"strict": true}}`, // Large project: variable number of files "/large/tsconfig.json": `{"compilerOptions": {"strict": true}}`, } // Generate small project files for i := range 100 { files[fmt.Sprintf("/small/file%d.ts", i)] = fmt.Sprintf("export const small%d = %d;", i, i) } // Generate large project files for i := range largeProjectSize { files[fmt.Sprintf("/large/file%d.ts", i)] = fmt.Sprintf("export const large%d = %d;", i, i) } fs := bundled.WrapFS(vfstest.FromMap(files, false /*useCaseSensitiveFileNames*/)) session := NewSession(&SessionInit{ BackgroundCtx: context.Background(), Options: &SessionOptions{ CurrentDirectory: "/", DefaultLibraryPath: bundled.LibPath(), TypingsLocation: "/home/src/Library/Caches/typescript", PositionEncoding: lsproto.PositionEncodingKindUTF8, WatchEnabled: false, LoggingEnabled: false, }, FS: fs, }) // Open one file from each project to initialize them session.DidOpenFile(context.Background(), "file:///small/file0.ts", 1, files["/small/file0.ts"].(string), lsproto.LanguageKindTypeScript) _, err := session.GetLanguageService(context.Background(), "file:///small/file0.ts") if err != nil { b.Fatal(err) } session.DidOpenFile(context.Background(), "file:///large/file0.ts", 1, files["/large/file0.ts"].(string), lsproto.LanguageKindTypeScript) _, err = session.GetLanguageService(context.Background(), "file:///large/file0.ts") if err != nil { b.Fatal(err) } session.WaitForBackgroundTasks() // Now benchmark: change the small project's tsconfig, then request a language service. // This forces a snapshot clone, which refs every file in both projects. strict := true b.ResetTimer() for b.Loop() { strict = !strict var tsconfigContent string if strict { tsconfigContent = `{"compilerOptions": {"strict": true}}` } else { tsconfigContent = `{"compilerOptions": {"strict": false}}` } err := session.fs.fs.WriteFile("/small/tsconfig.json", tsconfigContent) if err != nil { b.Fatal(err) } session.pendingFileChangesMu.Lock() session.pendingFileChanges = append(session.pendingFileChanges, FileChange{ Kind: FileChangeKindWatchChange, URI: "file:///small/tsconfig.json", }) session.pendingFileChangesMu.Unlock() _, err = session.GetLanguageService(context.Background(), "file:///small/file0.ts") if err != nil { b.Fatal(err) } session.WaitForBackgroundTasks() } }) } }