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

755 lines
33 KiB
Go

package project_test
import (
"context"
"slices"
"strings"
"testing"
"github.com/microsoft/typescript-go/internal/bundled"
"github.com/microsoft/typescript-go/internal/core"
"github.com/microsoft/typescript-go/internal/diagnostics"
"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/projecttestutil"
"github.com/microsoft/typescript-go/internal/tspath"
"gotest.tools/v3/assert"
)
type publishDiagnosticsCall = struct {
Ctx context.Context
Params *lsproto.PublishDiagnosticsParams
}
// filterDiagnosticsByURI returns all PublishDiagnostics calls matching the given URI,
// starting from the given index.
func filterDiagnosticsByURI(calls []publishDiagnosticsCall, uri lsproto.DocumentUri, from int) []publishDiagnosticsCall {
var result []publishDiagnosticsCall
for i := from; i < len(calls); i++ {
if calls[i].Params.Uri == uri {
result = append(result, calls[i])
}
}
return result
}
// These tests explicitly verify ProgramUpdateKind using subtests with shared helpers.
func TestProjectProgramUpdateKind(t *testing.T) {
t.Parallel()
if !bundled.Embedded {
t.Skip("bundled files are not embedded")
}
// Use the default session setup for tests.
t.Run("NewFiles on initial build", func(t *testing.T) {
t.Parallel()
files := map[string]any{
"/src/tsconfig.json": "{}",
"/src/index.ts": "export const x = 1;",
}
session, _ := projecttestutil.Setup(files)
session.DidOpenFile(context.Background(), "file:///src/index.ts", 1, files["/src/index.ts"].(string), lsproto.LanguageKindTypeScript)
_, err := session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///src/index.ts"))
assert.NilError(t, err)
snapshot := session.Snapshot()
configured := snapshot.ProjectCollection.ConfiguredProject(tspath.Path("/src/tsconfig.json"))
assert.Assert(t, configured != nil)
assert.Equal(t, configured.ProgramUpdateKind, project.ProgramUpdateKindNewFiles)
})
t.Run("Cloned on single-file change", func(t *testing.T) {
t.Parallel()
files := map[string]any{
"/src/tsconfig.json": "{}",
"/src/index.ts": "console.log('Hello');",
}
session, _ := projecttestutil.Setup(files)
session.DidOpenFile(context.Background(), "file:///src/index.ts", 1, files["/src/index.ts"].(string), lsproto.LanguageKindTypeScript)
_, err := session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///src/index.ts"))
assert.NilError(t, err)
session.DidChangeFile(context.Background(), "file:///src/index.ts", 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{{
Partial: &lsproto.TextDocumentContentChangePartial{Text: "\n", Range: lsproto.Range{Start: lsproto.Position{Line: 0, Character: 20}, End: lsproto.Position{Line: 0, Character: 20}}},
}})
_, err = session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///src/index.ts"))
assert.NilError(t, err)
snapshot := session.Snapshot()
configured := snapshot.ProjectCollection.ConfiguredProject(tspath.Path("/src/tsconfig.json"))
assert.Assert(t, configured != nil)
assert.Equal(t, configured.ProgramUpdateKind, project.ProgramUpdateKindCloned)
})
t.Run("SameFileNames on config change without root changes", func(t *testing.T) {
t.Parallel()
files := map[string]any{
"/src/tsconfig.json": `{"compilerOptions": {"strict": true}}`,
"/src/index.ts": "export const x = 1;",
}
session, utils := projecttestutil.Setup(files)
session.DidOpenFile(context.Background(), "file:///src/index.ts", 1, files["/src/index.ts"].(string), lsproto.LanguageKindTypeScript)
_, err := session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///src/index.ts"))
assert.NilError(t, err)
err = utils.FS().WriteFile("/src/tsconfig.json", `{"compilerOptions": {"strict": false}}`)
assert.NilError(t, err)
session.DidChangeWatchedFiles(context.Background(), []*lsproto.FileEvent{{Uri: lsproto.DocumentUri("file:///src/tsconfig.json"), Type: lsproto.FileChangeTypeChanged}})
_, err = session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///src/index.ts"))
assert.NilError(t, err)
snapshot := session.Snapshot()
configured := snapshot.ProjectCollection.ConfiguredProject(tspath.Path("/src/tsconfig.json"))
assert.Assert(t, configured != nil)
assert.Equal(t, configured.ProgramUpdateKind, project.ProgramUpdateKindSameFileNames)
})
t.Run("NewFiles on root addition", func(t *testing.T) {
t.Parallel()
files := map[string]any{
"/src/tsconfig.json": "{}",
"/src/index.ts": "export {}",
}
session, utils := projecttestutil.Setup(files)
session.DidOpenFile(context.Background(), "file:///src/index.ts", 1, files["/src/index.ts"].(string), lsproto.LanguageKindTypeScript)
_, err := session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///src/index.ts"))
assert.NilError(t, err)
content := "export const y = 2;"
err = utils.FS().WriteFile("/src/newfile.ts", content)
assert.NilError(t, err)
session.DidChangeWatchedFiles(context.Background(), []*lsproto.FileEvent{{Uri: lsproto.DocumentUri("file:///src/newfile.ts"), Type: lsproto.FileChangeTypeCreated}})
session.DidOpenFile(context.Background(), "file:///src/newfile.ts", 1, content, lsproto.LanguageKindTypeScript)
_, err = session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///src/newfile.ts"))
assert.NilError(t, err)
snapshot := session.Snapshot()
configured := snapshot.ProjectCollection.ConfiguredProject(tspath.Path("/src/tsconfig.json"))
assert.Assert(t, configured != nil)
assert.Equal(t, configured.ProgramUpdateKind, project.ProgramUpdateKindNewFiles)
})
t.Run("SameFileNames when adding an unresolvable import with multi-file change", func(t *testing.T) {
t.Parallel()
files := map[string]any{
"/src/tsconfig.json": "{}",
"/src/index.ts": "export const x = 1;",
"/src/other.ts": "export const z = 3;",
}
session, _ := projecttestutil.Setup(files)
session.DidOpenFile(context.Background(), "file:///src/index.ts", 1, files["/src/index.ts"].(string), lsproto.LanguageKindTypeScript)
_, err := session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///src/index.ts"))
assert.NilError(t, err)
// Change index.ts to add an unresolvable import
session.DidChangeFile(context.Background(), "file:///src/index.ts", 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{{
Partial: &lsproto.TextDocumentContentChangePartial{Text: "\nimport \"./does-not-exist\";\n", Range: lsproto.Range{Start: lsproto.Position{Line: 0, Character: 0}, End: lsproto.Position{Line: 0, Character: 0}}},
}})
_, err = session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///src/index.ts"))
assert.NilError(t, err)
snapshot := session.Snapshot()
configured := snapshot.ProjectCollection.ConfiguredProject(tspath.Path("/src/tsconfig.json"))
assert.Assert(t, configured != nil)
assert.Equal(t, configured.ProgramUpdateKind, project.ProgramUpdateKindSameFileNames)
})
}
func TestProject(t *testing.T) {
t.Parallel()
if !bundled.Embedded {
t.Skip("bundled files are not embedded")
}
t.Run("commandLineWithTypingsFiles is reset on CommandLine change", func(t *testing.T) {
t.Parallel()
files := map[string]any{
"/user/username/projects/project1/app.js": ``,
"/user/username/projects/project1/package.json": `{"name":"p1","dependencies":{"jquery":"^3.1.0"}}`,
"/user/username/projects/project2/app.js": ``,
}
session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{
PackageToFile: map[string]string{
// Provide typings content to be installed for jquery so ATA actually installs something
"jquery": `declare const $: { x: number }`,
},
})
// 1) Open an inferred project file that triggers ATA
uri1 := lsproto.DocumentUri("file:///user/username/projects/project1/app.js")
session.DidOpenFile(context.Background(), uri1, 1, files["/user/username/projects/project1/app.js"].(string), lsproto.LanguageKindJavaScript)
// 2) Wait for ATA/background tasks to finish, then get a language service for the first file
session.WaitForBackgroundTasks()
// Sanity check: ensure ATA performed at least one install
npmCalls := utils.NpmExecutor().NpmInstallCalls()
assert.Assert(t, len(npmCalls) > 0, "expected at least one npm install call from ATA")
_, err := session.GetLanguageService(context.Background(), uri1)
assert.NilError(t, err)
// 3) Open another inferred project file
uri2 := lsproto.DocumentUri("file:///user/username/projects/project2/app.js")
session.DidOpenFile(context.Background(), uri2, 1, ``, lsproto.LanguageKindJavaScript)
// 4) Get a language service for the second file
// If commandLineWithTypingsFiles was not reset, the new program command line
// won't include the newly opened file and this will fail.
_, err = session.GetLanguageService(context.Background(), uri2)
assert.NilError(t, err)
})
t.Run("inferred project rebuilt twice in one snapshot after typings install does not crash", func(t *testing.T) {
t.Parallel()
// Reproduces the nil-command-line crash in (*Program).SingleThreaded (issue #3873).
//
// `getCommandLineWithTypingsFiles` memoizes `commandLineWithTypingsFiles` behind a
// `sync.Once`. When `CommandLine` (or the inferred roots) change, the cached value is
// reset to nil but the `Once` is NOT reset. `Project.Clone()` produces a fresh `Once`,
// so the first mutation of a project within a snapshot normally clears the staleness.
// But if the *same* in-snapshot clone has its program built, then its roots change, then
// its program is built again, the second build hits a consumed `Once` and reads the nil
// cached value, passing a nil command line to `compiler.NewProgram`.
//
// This sequence is produced within a single snapshot by `DidRequestFile`'s
// build -> cleanupInferredProject -> build path, when:
// 1. the inferred project has installed typings (so the `Once` path is taken), and
// 2. a pending content change marks the inferred project dirty (forcing build #1), and
// 3. a pending tsconfig creation moves another open file out of the inferred project,
// so `cleanupInferredProject` changes the inferred roots between the two builds.
files := map[string]any{
"/user/username/projects/project1/a.js": ``,
"/user/username/projects/project1/package.json": `{"name":"p1","dependencies":{"jquery":"^3.1.0"}}`,
"/user/username/projects/project1/b.ts": `export const y = 1;`,
}
session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{
PackageToFile: map[string]string{
"jquery": `declare const $: { x: number }`,
},
})
aURI := lsproto.DocumentUri("file:///user/username/projects/project1/a.js")
bURI := lsproto.DocumentUri("file:///user/username/projects/project1/b.ts")
// 1) Open the file that triggers ATA, plus another file that joins the inferred project.
session.DidOpenFile(context.Background(), aURI, 1, ``, lsproto.LanguageKindJavaScript)
session.DidOpenFile(context.Background(), bURI, 1, files["/user/username/projects/project1/b.ts"].(string), lsproto.LanguageKindTypeScript)
// 2) Let ATA install jquery typings, then build the inferred program so its
// typings files are populated.
session.WaitForBackgroundTasks()
npmCalls := utils.NpmExecutor().NpmInstallCalls()
assert.Assert(t, len(npmCalls) > 0, "expected at least one npm install call from ATA")
_, err := session.GetLanguageService(context.Background(), aURI)
assert.NilError(t, err)
// 3) Queue two pending changes that will be flushed together in the next snapshot:
// - a content change to a.js (marks the inferred project dirty -> first program build)
// - creation of a tsconfig.json that captures b.ts (moves it out of the inferred
// project, so cleanupInferredProject shrinks the inferred roots between builds).
session.DidChangeFile(context.Background(), aURI, 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{
{WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{Text: `// changed`}},
})
err = utils.FS().WriteFile("/user/username/projects/project1/tsconfig.json", `{"compilerOptions":{},"files":["b.ts"]}`)
assert.NilError(t, err)
session.DidChangeWatchedFiles(context.Background(), []*lsproto.FileEvent{
{
Type: lsproto.FileChangeTypeCreated,
Uri: "file:///user/username/projects/project1/tsconfig.json",
},
})
// 4) Flush the pending changes and the request in a single snapshot. With the bug,
// the inferred project's program is built, its roots change, and it is rebuilt with
// a nil command line, panicking in (*Program).SingleThreaded.
defer func() {
if r := recover(); r != nil {
t.Fatalf("crash reproduced (issue #3873): %v", r)
}
}()
_, err = session.GetLanguageService(context.Background(), aURI)
assert.NilError(t, err)
})
}
func TestPushDiagnostics(t *testing.T) {
t.Parallel()
if !bundled.Embedded {
t.Skip("bundled files are not embedded")
}
t.Run("publishes program diagnostics on initial program creation", func(t *testing.T) {
t.Parallel()
files := map[string]any{
"/src/tsconfig.json": `{"compilerOptions": {"baseUrl": "."}}`,
"/src/index.ts": "export const x = 1;",
}
session, utils := projecttestutil.Setup(files)
session.DidOpenFile(context.Background(), "file:///src/index.ts", 1, files["/src/index.ts"].(string), lsproto.LanguageKindTypeScript)
_, err := session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///src/index.ts"))
assert.NilError(t, err)
session.WaitForBackgroundTasks()
calls := utils.Client().PublishDiagnosticsCalls()
assert.Assert(t, len(calls) > 0, "expected at least one PublishDiagnostics call")
// Find the call for tsconfig.json
var tsconfigCall *struct {
Ctx context.Context
Params *lsproto.PublishDiagnosticsParams
}
for i := range calls {
if calls[i].Params.Uri == "file:///src/tsconfig.json" {
tsconfigCall = &calls[i]
break
}
}
assert.Assert(t, tsconfigCall != nil, "expected PublishDiagnostics call for tsconfig.json")
assert.Assert(t, len(tsconfigCall.Params.Diagnostics) > 0, "expected at least one diagnostic")
})
t.Run("publishes config parsing diagnostics on initial program creation", func(t *testing.T) {
t.Parallel()
files := map[string]any{
"/src/tsconfig.json": `{
"compilerOptions": {
"target": "nope"
}
}`,
"/src/index.ts": "export const x = 1;",
}
session, utils := projecttestutil.Setup(files)
session.DidOpenFile(context.Background(), "file:///src/index.ts", 1, files["/src/index.ts"].(string), lsproto.LanguageKindTypeScript)
_, err := session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///src/index.ts"))
assert.NilError(t, err)
session.WaitForBackgroundTasks()
calls := utils.Client().PublishDiagnosticsCalls()
tsconfigCalls := filterDiagnosticsByURI(calls, "file:///src/tsconfig.json", 0)
assert.Assert(t, len(tsconfigCalls) > 0, "expected PublishDiagnostics call for tsconfig.json")
lastTsconfigCall := tsconfigCalls[len(tsconfigCalls)-1]
expectedMessage := "Argument for '--target' option must be:"
assert.Assert(t, slices.ContainsFunc(lastTsconfigCall.Params.Diagnostics, func(diag *lsproto.Diagnostic) bool {
return strings.Contains(diag.Message.AsString(), expectedMessage)
}), "expected invalid target diagnostic on tsconfig.json, got: %v", lastTsconfigCall.Params.Diagnostics)
})
t.Run("clears diagnostics when project is removed", func(t *testing.T) {
t.Parallel()
files := map[string]any{
"/src/tsconfig.json": `{"compilerOptions": {"baseUrl": "."}}`,
"/src/index.ts": "export const x = 1;",
"/src2/tsconfig.json": `{"compilerOptions": {}}`,
"/src2/index.ts": "export const y = 2;",
}
session, utils := projecttestutil.Setup(files)
session.DidOpenFile(context.Background(), "file:///src/index.ts", 1, files["/src/index.ts"].(string), lsproto.LanguageKindTypeScript)
_, err := session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///src/index.ts"))
assert.NilError(t, err)
session.WaitForBackgroundTasks()
// Open a file in a different project to trigger cleanup of the first
session.DidCloseFile(context.Background(), "file:///src/index.ts")
session.DidOpenFile(context.Background(), "file:///src2/index.ts", 1, files["/src2/index.ts"].(string), lsproto.LanguageKindTypeScript)
_, err = session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///src2/index.ts"))
assert.NilError(t, err)
session.WaitForBackgroundTasks()
calls := utils.Client().PublishDiagnosticsCalls()
// Should have at least one call for the first project with diagnostics,
// and one clearing it after switching projects
var firstProjectCalls []struct {
Ctx context.Context
Params *lsproto.PublishDiagnosticsParams
}
for i := range calls {
if calls[i].Params.Uri == "file:///src/tsconfig.json" {
firstProjectCalls = append(firstProjectCalls, calls[i])
}
}
assert.Assert(t, len(firstProjectCalls) >= 2, "expected at least 2 PublishDiagnostics calls for first project")
// Last call should clear diagnostics
lastCall := firstProjectCalls[len(firstProjectCalls)-1]
assert.Equal(t, len(lastCall.Params.Diagnostics), 0, "expected empty diagnostics after project cleanup")
})
t.Run("updates diagnostics when program changes", func(t *testing.T) {
t.Parallel()
files := map[string]any{
"/src/tsconfig.json": `{"compilerOptions": {"baseUrl": "."}}`,
"/src/index.ts": "export const x = 1;",
}
session, utils := projecttestutil.Setup(files)
session.DidOpenFile(context.Background(), "file:///src/index.ts", 1, files["/src/index.ts"].(string), lsproto.LanguageKindTypeScript)
_, err := session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///src/index.ts"))
assert.NilError(t, err)
session.WaitForBackgroundTasks()
initialCallCount := len(utils.Client().PublishDiagnosticsCalls())
// Change the tsconfig to remove baseUrl
err = utils.FS().WriteFile("/src/tsconfig.json", `{"compilerOptions": {}}`)
assert.NilError(t, err)
session.DidChangeWatchedFiles(context.Background(), []*lsproto.FileEvent{{Uri: lsproto.DocumentUri("file:///src/tsconfig.json"), Type: lsproto.FileChangeTypeChanged}})
_, err = session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///src/index.ts"))
assert.NilError(t, err)
session.WaitForBackgroundTasks()
calls := utils.Client().PublishDiagnosticsCalls()
assert.Assert(t, len(calls) > initialCallCount, "expected additional PublishDiagnostics call after change")
// Find the last call for tsconfig.json
var lastTsconfigCall *struct {
Ctx context.Context
Params *lsproto.PublishDiagnosticsParams
}
for i := len(calls) - 1; i >= 0; i-- {
if calls[i].Params.Uri == "file:///src/tsconfig.json" {
lastTsconfigCall = &calls[i]
break
}
}
assert.Assert(t, lastTsconfigCall != nil, "expected PublishDiagnostics call for tsconfig.json")
// After fixing the error, there should be no program diagnostics
assert.Equal(t, len(lastTsconfigCall.Params.Diagnostics), 0, "expected no diagnostics after removing baseUrl option")
})
t.Run("does not publish for inferred projects", func(t *testing.T) {
t.Parallel()
files := map[string]any{
"/src/index.ts": "let x: number = 'not a number';",
}
session, utils := projecttestutil.Setup(files)
session.DidOpenFile(context.Background(), "file:///src/index.ts", 1, files["/src/index.ts"].(string), lsproto.LanguageKindTypeScript)
_, err := session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///src/index.ts"))
assert.NilError(t, err)
session.WaitForBackgroundTasks()
calls := utils.Client().PublishDiagnosticsCalls()
// Should not have any calls since inferred projects don't have tsconfig.json
assert.Equal(t, len(calls), 0, "expected no PublishDiagnostics calls for inferred projects")
})
t.Run("does not publish when validation is disabled", func(t *testing.T) {
t.Parallel()
files := map[string]any{
"/src/tsconfig.json": `{
"compilerOptions": {
"target": "nope"
}
}`,
"/src/index.ts": "export const x = 1;",
}
session, utils := projecttestutil.Setup(files)
prefs := lsutil.NewDefaultUserPreferences()
prefs.EnableValidation = core.TSFalse
session.Configure(prefs)
session.DidOpenFile(context.Background(), "file:///src/index.ts", 1, files["/src/index.ts"].(string), lsproto.LanguageKindTypeScript)
_, err := session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///src/index.ts"))
assert.NilError(t, err)
session.WaitForBackgroundTasks()
calls := utils.Client().PublishDiagnosticsCalls()
for _, call := range calls {
assert.Equal(t, len(call.Params.Diagnostics), 0, "expected only empty PublishDiagnostics calls when validation is disabled")
}
})
t.Run("clears diagnostics when validation is disabled", func(t *testing.T) {
t.Parallel()
files := map[string]any{
"/src/tsconfig.json": `{
"compilerOptions": {
"target": "nope"
}
}`,
"/src/index.ts": "export const x = 1;",
}
session, utils := projecttestutil.Setup(files)
session.DidOpenFile(context.Background(), "file:///src/index.ts", 1, files["/src/index.ts"].(string), lsproto.LanguageKindTypeScript)
_, err := session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///src/index.ts"))
assert.NilError(t, err)
session.WaitForBackgroundTasks()
calls := utils.Client().PublishDiagnosticsCalls()
tsconfigCalls := filterDiagnosticsByURI(calls, "file:///src/tsconfig.json", 0)
assert.Assert(t, len(tsconfigCalls) > 0, "expected initial PublishDiagnostics call for tsconfig.json")
assert.Assert(t, len(tsconfigCalls[len(tsconfigCalls)-1].Params.Diagnostics) > 0, "expected initial diagnostics")
prefs := lsutil.NewDefaultUserPreferences()
prefs.EnableValidation = core.TSFalse
session.Configure(prefs)
_, err = session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///src/index.ts"))
assert.NilError(t, err)
session.WaitForBackgroundTasks()
calls = utils.Client().PublishDiagnosticsCalls()
tsconfigCalls = filterDiagnosticsByURI(calls, "file:///src/tsconfig.json", len(calls)-1)
if len(tsconfigCalls) == 0 {
tsconfigCalls = filterDiagnosticsByURI(calls, "file:///src/tsconfig.json", 0)
}
lastTsconfigCall := tsconfigCalls[len(tsconfigCalls)-1]
assert.Equal(t, len(lastTsconfigCall.Params.Diagnostics), 0, "expected diagnostics to be cleared when validation is disabled")
})
t.Run("publishes global diagnostics after checking", func(t *testing.T) {
t.Parallel()
// Use a target/lib that does not include Disposable, then write code that needs it.
// This triggers a deferred "Cannot find global type 'Disposable'" global diagnostic
// during checking, which should be accumulated and published on the tsconfig URI.
files := map[string]any{
"/src/tsconfig.json": `{
"compilerOptions": {
"target": "es2020"
}
}`,
"/src/index.ts": `export function f() {
using x = { [Symbol.dispose]() {} };
}`,
}
session, utils := projecttestutil.Setup(files)
session.DidOpenFile(context.Background(), "file:///src/index.ts", 1, files["/src/index.ts"].(string), lsproto.LanguageKindTypeScript)
// Request semantic diagnostics to trigger checking, which triggers the global type resolvers.
ls, err := session.GetLanguageService(projecttestutil.WithRequestID(context.Background()), lsproto.DocumentUri("file:///src/index.ts"))
assert.NilError(t, err)
// Drain background tasks from DidOpenFile (publishProgramDiagnostics, etc.)
// before triggering global diagnostics, to avoid racing with publishGlobalDiagnostics.
session.WaitForBackgroundTasks()
_, err = ls.ProvideDiagnostics(projecttestutil.WithRequestID(context.Background()), lsproto.DocumentUri("file:///src/index.ts"))
assert.NilError(t, err)
// Enqueue global diagnostics publishing (normally done by the LSP server after each request).
session.EnqueuePublishGlobalDiagnostics()
session.WaitForBackgroundTasks()
calls := utils.Client().PublishDiagnosticsCalls()
// Find the last call for tsconfig.json
var lastTsconfigCall *struct {
Ctx context.Context
Params *lsproto.PublishDiagnosticsParams
}
for i := len(calls) - 1; i >= 0; i-- {
if calls[i].Params.Uri == "file:///src/tsconfig.json" {
lastTsconfigCall = &calls[i]
break
}
}
assert.Assert(t, lastTsconfigCall != nil, "expected PublishDiagnostics call for tsconfig.json")
// Should have global diagnostics (e.g., Cannot find global type 'Disposable')
hasGlobalDiag := false
for _, diag := range lastTsconfigCall.Params.Diagnostics {
if strings.Contains(diag.Message.AsString(), "Cannot find global") {
hasGlobalDiag = true
break
}
}
assert.Assert(t, hasGlobalDiag, "expected a 'Cannot find global' diagnostic on tsconfig.json, got: %v", lastTsconfigCall.Params.Diagnostics)
})
t.Run("cleans tsconfig diagnostics after TS files close and restores them after TS file is reopened", func(t *testing.T) {
t.Parallel()
files := map[string]any{
"/src/tsconfig.json": `{"compilerOptions": {"baseUrl": "."}}`,
"/src/index.ts": "export const x = 1;",
}
session, utils := projecttestutil.Setup(files)
uri := lsproto.DocumentUri("file:///src/index.ts")
session.DidOpenFile(context.Background(), uri, 1, files["/src/index.ts"].(string), lsproto.LanguageKindTypeScript)
_, err := session.GetLanguageService(context.Background(), uri)
assert.NilError(t, err)
session.WaitForBackgroundTasks()
calls := utils.Client().PublishDiagnosticsCalls()
tsconfigCalls := filterDiagnosticsByURI(calls, "file:///src/tsconfig.json", 0)
assert.Assert(t, len(tsconfigCalls) > 0, "expected PublishDiagnostics call for tsconfig.json after opening file")
assert.Equal(t, len(tsconfigCalls[0].Params.Diagnostics), 1, "expected one diagnostic on tsconfig.json after opening file")
callsBeforeClose := len(calls)
session.DidCloseFile(context.Background(), uri)
session.WaitForBackgroundTasks()
// Cleans up diagnostics after close
calls = utils.Client().PublishDiagnosticsCalls()
clearCalls := filterDiagnosticsByURI(calls, "file:///src/tsconfig.json", callsBeforeClose)
assert.Assert(t, len(clearCalls) > 0, "expected PublishDiagnostics call for tsconfig.json after project close")
lastClearCall := clearCalls[len(clearCalls)-1]
assert.Equal(t, len(lastClearCall.Params.Diagnostics), 0, "expected empty diagnostics after project close")
callsBeforeReopen := len(calls)
session.DidOpenFile(context.Background(), uri, 2, files["/src/index.ts"].(string), lsproto.LanguageKindTypeScript)
_, err = session.GetLanguageService(context.Background(), uri)
assert.NilError(t, err)
session.WaitForBackgroundTasks()
// Restores diagnostics after reopen
calls = utils.Client().PublishDiagnosticsCalls()
reopenedCalls := filterDiagnosticsByURI(calls, "file:///src/tsconfig.json", callsBeforeReopen)
assert.Assert(t, len(reopenedCalls) > 0, "expected PublishDiagnostics call for tsconfig.json after reopening file")
lastReopenedCall := reopenedCalls[len(reopenedCalls)-1]
assert.Equal(t, len(lastReopenedCall.Params.Diagnostics), 1, "expected one diagnostic on tsconfig.json after reopening file")
})
}
func TestDisplayName(t *testing.T) {
t.Parallel()
if !bundled.Embedded {
t.Skip("bundled files are not embedded")
}
t.Run("configured project returns relative config path", func(t *testing.T) {
t.Parallel()
files := map[string]any{
"/home/projects/tsconfig.json": `{}`,
"/home/projects/index.ts": "export const x = 1;",
}
session, _ := projecttestutil.Setup(files)
session.DidOpenFile(context.Background(), "file:///home/projects/index.ts", 1, "export const x = 1;", lsproto.LanguageKindTypeScript)
_, err := session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///home/projects/index.ts"))
assert.NilError(t, err)
snapshot := session.Snapshot()
configured := snapshot.ProjectCollection.ConfiguredProject(tspath.Path("/home/projects/tsconfig.json"))
assert.Assert(t, configured != nil)
assert.Equal(t, configured.DisplayName("/home/projects"), "tsconfig.json")
})
t.Run("configured project with nested config", func(t *testing.T) {
t.Parallel()
files := map[string]any{
"/home/projects/sub/tsconfig.json": `{}`,
"/home/projects/sub/index.ts": "export const x = 1;",
}
session, _ := projecttestutil.Setup(files)
session.DidOpenFile(context.Background(), "file:///home/projects/sub/index.ts", 1, "export const x = 1;", lsproto.LanguageKindTypeScript)
_, err := session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///home/projects/sub/index.ts"))
assert.NilError(t, err)
snapshot := session.Snapshot()
configured := snapshot.ProjectCollection.ConfiguredProject(tspath.Path("/home/projects/sub/tsconfig.json"))
assert.Assert(t, configured != nil)
assert.Equal(t, configured.DisplayName("/home/projects"), "sub/tsconfig.json")
})
t.Run("inferred project returns directory base name", func(t *testing.T) {
t.Parallel()
files := map[string]any{
"/home/projects/index.ts": "export const x = 1;",
}
session, _ := projecttestutil.SetupWithOptions(files, &project.SessionOptions{
CurrentDirectory: "/home/projects",
DefaultLibraryPath: bundled.LibPath(),
PositionEncoding: lsproto.PositionEncodingKindUTF8,
WatchEnabled: true,
LoggingEnabled: true,
PushDiagnosticsEnabled: true,
})
session.DidOpenFile(context.Background(), "file:///home/projects/index.ts", 1, "export const x = 1;", lsproto.LanguageKindTypeScript)
_, err := session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///home/projects/index.ts"))
assert.NilError(t, err)
snapshot := session.Snapshot()
inferred := snapshot.ProjectCollection.InferredProject()
assert.Assert(t, inferred != nil)
name := inferred.DisplayName("/home")
assert.Equal(t, name, "projects")
})
}
func TestProgressNotifications(t *testing.T) {
t.Parallel()
if !bundled.Embedded {
t.Skip("bundled files are not embedded")
}
t.Run("emits progress for configured project loading", func(t *testing.T) {
t.Parallel()
files := map[string]any{
"/home/projects/tsconfig.json": `{}`,
"/home/projects/index.ts": "export const x = 1;",
}
session, utils := projecttestutil.Setup(files)
session.DidOpenFile(context.Background(), "file:///home/projects/index.ts", 1, "export const x = 1;", lsproto.LanguageKindTypeScript)
_, err := session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///home/projects/index.ts"))
assert.NilError(t, err)
startCalls := utils.Client().ProgressStartCalls()
finishCalls := utils.Client().ProgressFinishCalls()
assert.Assert(t, len(startCalls) > 0, "expected at least one ProgressStart call")
assert.Assert(t, len(finishCalls) > 0, "expected at least one ProgressFinish call")
foundProjectStart := false
for _, call := range startCalls {
if call.Message == diagnostics.Project_0 {
foundProjectStart = true
break
}
}
assert.Assert(t, foundProjectStart, "expected ProgressStart with Project_0 message")
foundProjectFinish := false
for _, call := range finishCalls {
if call.Message == diagnostics.Project_0 {
foundProjectFinish = true
break
}
}
assert.Assert(t, foundProjectFinish, "expected ProgressFinish with Project_0 message")
})
t.Run("emits progress for inferred project loading", func(t *testing.T) {
t.Parallel()
files := map[string]any{
"/home/projects/index.ts": "export const x = 1;",
}
session, utils := projecttestutil.Setup(files)
session.DidOpenFile(context.Background(), "file:///home/projects/index.ts", 1, "export const x = 1;", lsproto.LanguageKindTypeScript)
_, err := session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///home/projects/index.ts"))
assert.NilError(t, err)
startCalls := utils.Client().ProgressStartCalls()
finishCalls := utils.Client().ProgressFinishCalls()
assert.Assert(t, len(startCalls) > 0, "expected at least one ProgressStart call")
assert.Assert(t, len(finishCalls) > 0, "expected at least one ProgressFinish call")
foundProjectStart := false
for _, call := range startCalls {
if call.Message == diagnostics.Project_0 {
foundProjectStart = true
break
}
}
assert.Assert(t, foundProjectStart, "expected ProgressStart with Project_0 message")
})
t.Run("each start has a matching finish", func(t *testing.T) {
t.Parallel()
files := map[string]any{
"/home/projects/tsconfig.json": `{}`,
"/home/projects/a.ts": "export const a = 1;",
"/home/projects/b.ts": "export const b = 2;",
}
session, utils := projecttestutil.Setup(files)
session.DidOpenFile(context.Background(), "file:///home/projects/a.ts", 1, "export const a = 1;", lsproto.LanguageKindTypeScript)
_, err := session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///home/projects/a.ts"))
assert.NilError(t, err)
startCalls := utils.Client().ProgressStartCalls()
finishCalls := utils.Client().ProgressFinishCalls()
starts := 0
finishes := 0
for _, call := range startCalls {
if call.Message == diagnostics.Project_0 {
starts++
}
}
for _, call := range finishCalls {
if call.Message == diagnostics.Project_0 {
finishes++
}
}
assert.Equal(t, starts, finishes, "ProgressStart and ProgressFinish calls for Project_0 should be balanced")
})
}