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

315 lines
12 KiB
Go

package project
import (
"context"
"fmt"
"testing"
"github.com/microsoft/typescript-go/internal/bundled"
"github.com/microsoft/typescript-go/internal/diagnostics"
"github.com/microsoft/typescript-go/internal/lsp/lsproto"
"github.com/microsoft/typescript-go/internal/project/logging"
"github.com/microsoft/typescript-go/internal/tsoptions"
"github.com/microsoft/typescript-go/internal/tspath"
"github.com/microsoft/typescript-go/internal/vfs"
"github.com/microsoft/typescript-go/internal/vfs/vfstest"
"gotest.tools/v3/assert"
)
type noopClient struct{}
func (noopClient) WatchFiles(ctx context.Context, id WatcherID, watchers []*lsproto.FileSystemWatcher) error {
return nil
}
func (noopClient) UnwatchFiles(ctx context.Context, id WatcherID) error { return nil }
func (noopClient) RefreshDiagnostics(ctx context.Context) error { return nil }
func (noopClient) PublishDiagnostics(ctx context.Context, params *lsproto.PublishDiagnosticsParams) error {
return nil
}
func (noopClient) RefreshInlayHints(ctx context.Context) error { return nil }
func (noopClient) RefreshCodeLens(ctx context.Context) error { return nil }
func (noopClient) ProgressStart(message *diagnostics.Message, args ...any) {}
func (noopClient) ProgressFinish(message *diagnostics.Message, args ...any) {}
func (noopClient) SendTelemetry(ctx context.Context, telemetry lsproto.TelemetryEvent) error {
return nil
}
func (noopClient) IsActive() bool { return true }
// TestExtendedConfigCacheOwnership tests the invariant that each ExtendedSourceFile
// of a config in the ConfigFileRegistry is owned exactly once per snapshot that
// references it, and released exactly once when that snapshot is removed.
func TestExtendedConfigCacheOwnership(t *testing.T) {
t.Parallel()
if !bundled.Embedded {
t.Skip("bundled files are not embedded")
}
setup := func(files map[string]any) *Session {
fsFromMap := vfstest.FromMap(files, false /*useCaseSensitiveFileNames*/)
fs := bundled.WrapFS(fsFromMap)
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,
Client: noopClient{},
Logger: logging.NewTestLogger(),
NpmExecutor: nil,
})
return session
}
untitledSeq := 0
openUntitled := func(session *Session) {
untitledSeq++
uri := lsproto.DocumentUri(fmt.Sprintf("untitled:Untitled-%d", untitledSeq))
session.DidOpenFile(context.Background(), uri, 1, "", lsproto.LanguageKindTypeScript)
}
// flushCloseProject is the canonical way to ensure project close work is applied.
// Close the file, then open an unrelated file.
flushCloseProject := func(session *Session, fileURI lsproto.DocumentUri) {
session.DidCloseFile(context.Background(), fileURI)
openUntitled(session)
}
ownerCount := func(session *Session, path tspath.Path) int {
entry, ok := session.extendedConfigCache.entries.Load(path)
if !ok {
return 0
}
return len(entry.owners)
}
assertNoEntry := func(t *testing.T, session *Session, fileName string) {
t.Helper()
path := session.toPath(fileName)
_, ok := session.extendedConfigCache.entries.Load(path)
assert.Equal(t, ok, false)
}
expectedExtendedOwnerCounts := func(session *Session, snapshot *Snapshot) map[tspath.Path]int {
result := make(map[tspath.Path]int)
for _, cfg := range snapshot.ConfigFileRegistry.configs {
if cfg.commandLine == nil || cfg.commandLine.ConfigFile == nil {
continue
}
for _, file := range cfg.commandLine.ExtendedSourceFiles() {
result[session.toPath(file)]++
}
}
return result
}
assertExtendedOwnerCountsMatchRegistry := func(t *testing.T, session *Session, snapshot *Snapshot) {
t.Helper()
expected := expectedExtendedOwnerCounts(session, snapshot)
for path, want := range expected {
got := ownerCount(session, path)
assert.Equal(t, got, want, "extended config %s owner count mismatch", path)
}
}
t.Run("multi-extends shared ancestor counted once", func(t *testing.T) {
t.Parallel()
// One config extends *two* configs; both extend a shared root.
// Expected behavior: ExtendedSourceFiles() is deduped, so the shared root should only
// be ref'd once for this config.
files := map[string]any{
"/project/tsconfig.json": `{
"extends": ["./tsconfig.base1.json", "./tsconfig.base2.json"]
}`,
"/project/tsconfig.base1.json": `{
"extends": "./tsconfig.root.json",
"compilerOptions": {"strict": true}
}`,
"/project/tsconfig.base2.json": `{
"extends": "./tsconfig.root.json",
"compilerOptions": {"noImplicitAny": true}
}`,
"/project/tsconfig.root.json": `{
"compilerOptions": {"target": "ES2020"}
}`,
"/project/src/main.ts": "export const x = 1;",
}
session := setup(files)
session.DidOpenFile(context.Background(), lsproto.DocumentUri("file:///project/src/main.ts"), 1, files["/project/src/main.ts"].(string), lsproto.LanguageKindTypeScript)
snapshot := session.Snapshot()
config := snapshot.ConfigFileRegistry.GetConfig("/project/tsconfig.json")
assert.Assert(t, config != nil)
// Shared root should only appear once in the flattened list.
var rootCount int
for _, f := range config.ExtendedSourceFiles() {
if f == "/project/tsconfig.root.json" {
rootCount++
}
}
assert.Equal(t, rootCount, 1)
// And the cache owner counts should match the registry's deduped list.
assertExtendedOwnerCountsMatchRegistry(t, session, snapshot)
flushCloseProject(session, lsproto.DocumentUri("file:///project/src/main.ts"))
assertNoEntry(t, session, "/project/tsconfig.base1.json")
assertNoEntry(t, session, "/project/tsconfig.base2.json")
assertNoEntry(t, session, "/project/tsconfig.root.json")
})
t.Run("ExtendedSourceFiles can contain same path twice (case-insensitive)", func(t *testing.T) {
t.Parallel()
// This test is descriptive, not prescriptive. This seems bad and unintentional,
// but is here to show that while the problem exists in the underlying config parsing
// API, it doesn't disrupt cache ownership.
files := map[string]any{
"/project/tsconfig.json": `{
"extends": ["./Shared.json", "./shared.json"]
}`,
"/project/shared.json": `{
"compilerOptions": {"strict": true}
}`,
}
// This test intentionally bypasses the project system's ExtendedConfigCache so we can
// observe how ExtendedSourceFiles behaves when the same underlying file is referenced
// with different casing on a case-insensitive FS.
fsFromMap := vfstest.FromMap(files, false /*useCaseSensitiveFileNames*/)
fs := bundled.WrapFS(fsFromMap)
// Minimal ParseConfigHost implementation.
h := &testParseConfigHost{fs: fs, cwd: "/"}
cmd, diags := tsoptions.GetParsedCommandLineOfConfigFile("/project/tsconfig.json", nil, nil, h, nil /*extendedConfigCache*/)
assert.Equal(t, len(diags), 0)
assert.Assert(t, cmd != nil)
extended := cmd.ExtendedSourceFiles()
assert.Equal(t, len(extended), 2)
assert.Equal(t, extended[0], "/project/Shared.json")
assert.Equal(t, extended[1], "/project/shared.json")
})
t.Run("project system dedupes case-only extends via cache", func(t *testing.T) {
t.Parallel()
files := map[string]any{
"/project/tsconfig.json": `{
"extends": ["./Shared.json", "./shared.json"]
}`,
"/project/shared.json": `{
"compilerOptions": {"strict": true}
}`,
"/project/src/main.ts": "export const x = 1;",
}
session := setup(files)
session.DidOpenFile(context.Background(), lsproto.DocumentUri("file:///project/src/main.ts"), 1, files["/project/src/main.ts"].(string), lsproto.LanguageKindTypeScript)
snapshot := session.Snapshot()
config := snapshot.ConfigFileRegistry.GetConfig("/project/tsconfig.json")
assert.Assert(t, config != nil)
extended := config.ExtendedSourceFiles()
assert.Equal(t, len(extended), 1)
assert.Equal(t, session.toPath(extended[0]), session.toPath("/project/shared.json"))
})
t.Run("transitive extended config ownership with new project", func(t *testing.T) {
t.Parallel()
// Scenario: transitive extends chain where a new project reuses a cached
// extended config without reparsing it, which should still acquire the transitive deps.
//
// projectA/tsconfig.json extends shared/tsconfig.base.json extends shared/tsconfig.common.json
// projectB/tsconfig.json extends shared/tsconfig.base.json extends shared/tsconfig.common.json
//
// When projectB is opened AFTER projectA, tsconfig.base.json is retrieved from cache
// (not reparsed), so tsconfig.common.json still needs projectB's snapshot ownership.
files := map[string]any{
"/user/username/projects/shared/tsconfig.common.json": `{
"compilerOptions": { "strict": true }
}`,
"/user/username/projects/shared/tsconfig.base.json": `{
"extends": "./tsconfig.common.json",
"compilerOptions": { "target": "ES2020" }
}`,
"/user/username/projects/projectA/tsconfig.json": `{
"extends": "../shared/tsconfig.base.json"
}`,
"/user/username/projects/projectA/src/main.ts": "const a = 1;",
"/user/username/projects/projectB/tsconfig.json": `{
"extends": "../shared/tsconfig.base.json"
}`,
"/user/username/projects/projectB/src/main.ts": "const b = 2;",
"/user/username/projects/other/src/main.ts": "const other = 3;",
}
session := setup(files)
// Step 1: Open file in projectA - this parses the full extends chain
session.DidOpenFile(context.Background(), "file:///user/username/projects/projectA/src/main.ts", 1, files["/user/username/projects/projectA/src/main.ts"].(string), lsproto.LanguageKindTypeScript)
// Verify extended configs are in cache with correct owner counts
baseEntry, baseOk := session.extendedConfigCache.entries.Load("/user/username/projects/shared/tsconfig.base.json")
commonEntry, commonOk := session.extendedConfigCache.entries.Load("/user/username/projects/shared/tsconfig.common.json")
assert.Assert(t, baseOk, "tsconfig.base.json should be in cache")
assert.Assert(t, commonOk, "tsconfig.common.json should be in cache")
assert.Equal(t, len(baseEntry.owners), 1)
assert.Equal(t, len(commonEntry.owners), 1)
// Step 2: Open file in projectB - this should acquire tsconfig.base.json from cache
// (not reparse it), and should also acquire tsconfig.common.json.
session.DidOpenFile(context.Background(), "file:///user/username/projects/projectB/src/main.ts", 1, files["/user/username/projects/projectB/src/main.ts"].(string), lsproto.LanguageKindTypeScript)
// Step 3: Close projectA file and open an unrelated file to force projectA cleanup
session.DidCloseFile(context.Background(), "file:///user/username/projects/projectA/src/main.ts")
// Opening another file triggers cleanup of closed projects
session.DidOpenFile(context.Background(), "file:///user/username/projects/other/src/main.ts", 1, files["/user/username/projects/other/src/main.ts"].(string), lsproto.LanguageKindTypeScript)
// Close the other file too so only projectB remains
session.DidCloseFile(context.Background(), "file:///user/username/projects/other/src/main.ts")
// Step 4: Trigger another snapshot clone for projectB
session.DidChangeFile(context.Background(), "file:///user/username/projects/projectB/src/main.ts", 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{
{
Partial: &lsproto.TextDocumentContentChangePartial{
Range: lsproto.Range{
Start: lsproto.Position{Line: 0, Character: 0},
End: lsproto.Position{Line: 0, Character: 12},
},
Text: "const b = 3;",
},
},
})
// This call triggered the panic
_, err := session.GetLanguageService(context.Background(), "file:///user/username/projects/projectB/src/main.ts")
assert.NilError(t, err)
})
}
type testParseConfigHost struct {
fs vfs.FS
cwd string
}
func (h *testParseConfigHost) FS() vfs.FS { return h.fs }
func (h *testParseConfigHost) GetCurrentDirectory() string { return h.cwd }