256 lines
10 KiB
Go
256 lines
10 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"github.com/microsoft/typescript-go/internal/bundled"
|
|
"github.com/microsoft/typescript-go/internal/lsp/lsproto"
|
|
"github.com/microsoft/typescript-go/internal/testutil/projecttestutil"
|
|
"github.com/microsoft/typescript-go/internal/tspath"
|
|
"gotest.tools/v3/assert"
|
|
)
|
|
|
|
// TestSessionTracksAndReleasesAPIRefs verifies that an API session holds at most
|
|
// one ref per opened project/file (opens are idempotent) and releases exactly
|
|
// those refs when the session is closed, so it never leaks or over-releases refs
|
|
// in the underlying (potentially shared) project session.
|
|
func TestSessionTracksAndReleasesAPIRefs(t *testing.T) {
|
|
t.Parallel()
|
|
if !bundled.Embedded {
|
|
t.Skip("bundled files are not embedded")
|
|
}
|
|
|
|
t.Run("project opens are idempotent and released on close", func(t *testing.T) {
|
|
t.Parallel()
|
|
const configFileName = "/home/projects/p/tsconfig.json"
|
|
files := map[string]any{
|
|
configFileName: `{ "compilerOptions": { "strict": true } }`,
|
|
"/home/projects/p/src/index.ts": `export const x = 1;`,
|
|
}
|
|
projectSession, _ := projecttestutil.Setup(files)
|
|
defer projectSession.Close()
|
|
session := NewSession(projectSession, nil)
|
|
|
|
_, err := session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{
|
|
OpenProjects: []DocumentIdentifier{{FileName: configFileName}},
|
|
})
|
|
assert.NilError(t, err)
|
|
assert.Equal(t, session.openProjects.Len(), 1)
|
|
|
|
// Opening the same project again must not take an additional ref.
|
|
_, err = session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{
|
|
OpenProjects: []DocumentIdentifier{{FileName: configFileName}},
|
|
})
|
|
assert.NilError(t, err)
|
|
assert.Equal(t, session.openProjects.Len(), 1)
|
|
|
|
assert.Assert(t, projectSession.Snapshot().ProjectCollection.ConfiguredProject(tspath.Path(configFileName)) != nil)
|
|
|
|
// Closing the session releases the single API ref, so the project is no
|
|
// longer kept loaded.
|
|
session.Close()
|
|
assert.Equal(t, session.openProjects.Len(), 0)
|
|
assert.Assert(t, projectSession.Snapshot().ProjectCollection.ConfiguredProject(tspath.Path(configFileName)) == nil)
|
|
})
|
|
|
|
t.Run("explicit close releases the project ref", func(t *testing.T) {
|
|
t.Parallel()
|
|
const configFileName = "/home/projects/p/tsconfig.json"
|
|
files := map[string]any{
|
|
configFileName: `{ "compilerOptions": { "strict": true } }`,
|
|
"/home/projects/p/src/index.ts": `export const x = 1;`,
|
|
}
|
|
projectSession, _ := projecttestutil.Setup(files)
|
|
defer projectSession.Close()
|
|
session := NewSession(projectSession, nil)
|
|
defer session.Close()
|
|
|
|
_, err := session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{
|
|
OpenProjects: []DocumentIdentifier{{FileName: configFileName}},
|
|
})
|
|
assert.NilError(t, err)
|
|
assert.Equal(t, session.openProjects.Len(), 1)
|
|
assert.Assert(t, projectSession.Snapshot().ProjectCollection.ConfiguredProject(tspath.Path(configFileName)) != nil)
|
|
|
|
// Closing a project we hold releases the ref and unloads the project.
|
|
_, err = session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{
|
|
CloseProjects: []DocumentIdentifier{{FileName: configFileName}},
|
|
})
|
|
assert.NilError(t, err)
|
|
assert.Equal(t, session.openProjects.Len(), 0)
|
|
assert.Assert(t, projectSession.Snapshot().ProjectCollection.ConfiguredProject(tspath.Path(configFileName)) == nil)
|
|
|
|
// Closing a project we don't hold is a no-op (never over-releases).
|
|
_, err = session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{
|
|
CloseProjects: []DocumentIdentifier{{FileName: configFileName}},
|
|
})
|
|
assert.NilError(t, err)
|
|
assert.Equal(t, session.openProjects.Len(), 0)
|
|
})
|
|
|
|
t.Run("file opens are idempotent and released on close", func(t *testing.T) {
|
|
t.Parallel()
|
|
const fileName = "/home/projects/p/src/index.ts"
|
|
files := map[string]any{
|
|
"/home/projects/p/tsconfig.json": `{ "compilerOptions": { "strict": true } }`,
|
|
fileName: `export const x = 1;`,
|
|
}
|
|
projectSession, _ := projecttestutil.Setup(files)
|
|
defer projectSession.Close()
|
|
session := NewSession(projectSession, nil)
|
|
|
|
_, err := session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{
|
|
OpenFiles: []DocumentIdentifier{{FileName: fileName}},
|
|
})
|
|
assert.NilError(t, err)
|
|
assert.Equal(t, session.openFiles.Len(), 1)
|
|
|
|
// Re-opening the same file must not take an additional ref.
|
|
_, err = session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{
|
|
OpenFiles: []DocumentIdentifier{{FileName: fileName}},
|
|
})
|
|
assert.NilError(t, err)
|
|
assert.Equal(t, session.openFiles.Len(), 1)
|
|
|
|
// The file should resolve to the configured project via ancestor search.
|
|
assert.Assert(t, projectSession.Snapshot().ProjectCollection.ConfiguredProject(tspath.Path("/home/projects/p/tsconfig.json")) != nil)
|
|
|
|
// Closing a file we don't hold is a no-op (never over-releases).
|
|
_, err = session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{
|
|
CloseFiles: []DocumentIdentifier{{FileName: "/home/projects/p/other.ts"}},
|
|
})
|
|
assert.NilError(t, err)
|
|
assert.Equal(t, session.openFiles.Len(), 1)
|
|
|
|
// Explicitly closing the held file releases the ref.
|
|
_, err = session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{
|
|
CloseFiles: []DocumentIdentifier{{FileName: fileName}},
|
|
})
|
|
assert.NilError(t, err)
|
|
assert.Equal(t, session.openFiles.Len(), 0)
|
|
|
|
// Closing the file also tears down the configured project that was
|
|
// auto-loaded to serve it, instead of leaking it.
|
|
assert.Assert(t,
|
|
projectSession.Snapshot().ProjectCollection.ConfiguredProject(tspath.Path("/home/projects/p/tsconfig.json")) == nil,
|
|
"configured project auto-loaded for the API-opened file should be unloaded after close",
|
|
)
|
|
|
|
session.Close()
|
|
assert.Equal(t, session.openFiles.Len(), 0)
|
|
})
|
|
|
|
t.Run("relative file paths normalize consistently for open and close", func(t *testing.T) {
|
|
t.Parallel()
|
|
// The project session's current directory is "/", so a relative path
|
|
// resolves to the corresponding absolute path.
|
|
files := map[string]any{
|
|
"/src/tsconfig.json": `{ "compilerOptions": { "strict": true } }`,
|
|
"/src/index.ts": `export const x = 1;`,
|
|
}
|
|
projectSession, _ := projecttestutil.Setup(files)
|
|
defer projectSession.Close()
|
|
session := NewSession(projectSession, nil)
|
|
defer session.Close()
|
|
|
|
// Open via a relative path; it should be tracked under the absolute path
|
|
// and resolve to the containing configured project.
|
|
openResp, err := session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{
|
|
OpenFiles: []DocumentIdentifier{{FileName: "src/index.ts"}},
|
|
})
|
|
assert.NilError(t, err)
|
|
assert.Equal(t, session.openFiles.Len(), 1)
|
|
assert.Assert(t, session.openFiles.Has(tspath.Path("/src/index.ts")))
|
|
assert.Assert(t, projectSession.Snapshot().ProjectCollection.ConfiguredProject(tspath.Path("/src/tsconfig.json")) != nil)
|
|
|
|
// getDefaultProjectForFile must also resolve a relative path to the same
|
|
// configured project (it builds a URI from the identifier internally).
|
|
proj, err := session.handleGetDefaultProjectForFile(context.Background(), &GetDefaultProjectForFileParams{
|
|
Snapshot: openResp.Snapshot,
|
|
File: DocumentIdentifier{FileName: "src/index.ts"},
|
|
})
|
|
assert.NilError(t, err)
|
|
assert.Assert(t, proj != nil, "relative path should resolve to a default project")
|
|
assert.Equal(t, proj.ConfigFileName, "/src/tsconfig.json")
|
|
|
|
// Re-opening via the absolute path must match the relative open (no new ref).
|
|
_, err = session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{
|
|
OpenFiles: []DocumentIdentifier{{FileName: "/src/index.ts"}},
|
|
})
|
|
assert.NilError(t, err)
|
|
assert.Equal(t, session.openFiles.Len(), 1)
|
|
|
|
// Closing via a relative path must match the path stored when opening.
|
|
_, err = session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{
|
|
CloseFiles: []DocumentIdentifier{{FileName: "src/index.ts"}},
|
|
})
|
|
assert.NilError(t, err)
|
|
assert.Equal(t, session.openFiles.Len(), 0)
|
|
assert.Assert(t,
|
|
projectSession.Snapshot().ProjectCollection.ConfiguredProject(tspath.Path("/src/tsconfig.json")) == nil,
|
|
"configured project should be unloaded after closing the relatively-pathed file",
|
|
)
|
|
})
|
|
}
|
|
|
|
// TestUpdateSnapshotResponseSkipsUnloadedAncestorProject verifies that API
|
|
// updateSnapshot does not report unloaded ancestor project placeholders. This
|
|
// covers the case where opening a file loads its nearest configured project
|
|
// while solution search discovers an ancestor tsconfig placeholder whose command
|
|
// line is still nil.
|
|
func TestUpdateSnapshotResponseSkipsUnloadedAncestorProject(t *testing.T) {
|
|
t.Parallel()
|
|
if !bundled.Embedded {
|
|
t.Skip("bundled files are not embedded")
|
|
}
|
|
|
|
const (
|
|
nestedConfigFileName = "/repo/packages/app/tsconfig.json"
|
|
ancestorConfigFileName = "/repo/packages/tsconfig.json"
|
|
fileName = "/repo/packages/app/src/index.ts"
|
|
)
|
|
files := map[string]any{
|
|
ancestorConfigFileName: `{ "files": [] }`,
|
|
nestedConfigFileName: `{
|
|
"compilerOptions": { "composite": true },
|
|
"include": ["**/*"]
|
|
}`,
|
|
fileName: `let s: string = 1234;`,
|
|
}
|
|
projectSession, _ := projecttestutil.Setup(files)
|
|
defer projectSession.Close()
|
|
|
|
projectSession.DidOpenFile(context.Background(), lsproto.DocumentUri("file://"+fileName), 1, files[fileName].(string), lsproto.LanguageKindTypeScript)
|
|
snapshot := projectSession.Snapshot()
|
|
nestedProject := snapshot.ProjectCollection.ConfiguredProject(tspath.Path(nestedConfigFileName))
|
|
assert.Assert(t, nestedProject != nil)
|
|
assert.Assert(t, nestedProject.CommandLine != nil)
|
|
ancestorProject := snapshot.ProjectCollection.ConfiguredProject(tspath.Path(ancestorConfigFileName))
|
|
assert.Assert(t, ancestorProject != nil)
|
|
assert.Assert(t, ancestorProject.CommandLine == nil)
|
|
|
|
session := NewSession(projectSession, nil)
|
|
defer session.Close()
|
|
|
|
response, err := session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{
|
|
OpenProjects: []DocumentIdentifier{{FileName: nestedConfigFileName}},
|
|
})
|
|
assert.NilError(t, err)
|
|
|
|
var foundNestedProject bool
|
|
var foundAncestorProject bool
|
|
for _, project := range response.Projects {
|
|
switch project.ConfigFileName {
|
|
case nestedConfigFileName:
|
|
foundNestedProject = true
|
|
assert.Assert(t, project.RootFiles != nil)
|
|
assert.Assert(t, project.CompilerOptions != nil)
|
|
case ancestorConfigFileName:
|
|
foundAncestorProject = true
|
|
}
|
|
}
|
|
assert.Assert(t, foundNestedProject)
|
|
assert.Assert(t, !foundAncestorProject)
|
|
}
|