1529 lines
53 KiB
Go
1529 lines
53 KiB
Go
package project
|
||
|
||
import (
|
||
"slices"
|
||
"sync"
|
||
"testing"
|
||
|
||
"github.com/microsoft/typescript-go/internal/lsp/lsproto"
|
||
"github.com/microsoft/typescript-go/internal/project/dirty"
|
||
"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"
|
||
)
|
||
|
||
func TestSnapshotFSBuilder(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
toPath := func(fileName string) tspath.Path {
|
||
return tspath.Path(fileName)
|
||
}
|
||
|
||
t.Run("builds directory tree on file add", func(t *testing.T) {
|
||
t.Parallel()
|
||
testFS := vfstest.FromMap(map[string]string{
|
||
"/src/foo.ts": "const foo = 1;",
|
||
}, false /* useCaseSensitiveFileNames */)
|
||
|
||
builder := newSnapshotFSBuilder(
|
||
testFS,
|
||
make(map[tspath.Path]*Overlay), // prevOverlays
|
||
make(map[tspath.Path]*Overlay), // overlays
|
||
make(map[tspath.Path]*diskFile),
|
||
make(map[tspath.Path]dirty.CloneableMap[tspath.Path, string]),
|
||
nil, // nodeModulesRealpathAliases
|
||
lsproto.PositionEncodingKindUTF16,
|
||
toPath,
|
||
)
|
||
|
||
// Read the file to add it to the diskFiles
|
||
fh := builder.GetFile("/src/foo.ts")
|
||
assert.Assert(t, fh != nil, "file should exist")
|
||
assert.Equal(t, fh.Content(), "const foo = 1;")
|
||
|
||
// Finalize and check directories
|
||
snapshot, changed := builder.Finalize()
|
||
assert.Assert(t, changed, "should have changed")
|
||
|
||
// Check that directory structure was built
|
||
// /src should contain /src/foo.ts
|
||
srcDir, ok := snapshot.diskDirectories[tspath.Path("/src")]
|
||
assert.Assert(t, ok, "/src directory should exist")
|
||
_, hasFoo := srcDir[tspath.Path("/src/foo.ts")]
|
||
assert.Assert(t, hasFoo, "/src should contain /src/foo.ts")
|
||
|
||
// / should contain /src
|
||
rootDir, ok := snapshot.diskDirectories[tspath.Path("/")]
|
||
assert.Assert(t, ok, "/ directory should exist")
|
||
_, hasSrc := rootDir[tspath.Path("/src")]
|
||
assert.Assert(t, hasSrc, "/ should contain /src")
|
||
})
|
||
|
||
t.Run("builds nested directory tree", func(t *testing.T) {
|
||
t.Parallel()
|
||
testFS := vfstest.FromMap(map[string]string{
|
||
"/src/nested/deep/file.ts": "export const x = 1;",
|
||
}, false /* useCaseSensitiveFileNames */)
|
||
|
||
builder := newSnapshotFSBuilder(
|
||
testFS,
|
||
make(map[tspath.Path]*Overlay), // prevOverlays
|
||
make(map[tspath.Path]*Overlay), // overlays
|
||
make(map[tspath.Path]*diskFile),
|
||
make(map[tspath.Path]dirty.CloneableMap[tspath.Path, string]),
|
||
nil, // nodeModulesRealpathAliases
|
||
lsproto.PositionEncodingKindUTF16,
|
||
toPath,
|
||
)
|
||
|
||
// Read the file to add it to the diskFiles
|
||
fh := builder.GetFile("/src/nested/deep/file.ts")
|
||
assert.Assert(t, fh != nil, "file should exist")
|
||
|
||
snapshot, changed := builder.Finalize()
|
||
assert.Assert(t, changed, "should have changed")
|
||
|
||
// Check the complete directory tree
|
||
_, hasFile := snapshot.diskDirectories[tspath.Path("/src/nested/deep")][tspath.Path("/src/nested/deep/file.ts")]
|
||
assert.Assert(t, hasFile)
|
||
_, hasDeep := snapshot.diskDirectories[tspath.Path("/src/nested")][tspath.Path("/src/nested/deep")]
|
||
assert.Assert(t, hasDeep)
|
||
_, hasNested := snapshot.diskDirectories[tspath.Path("/src")][tspath.Path("/src/nested")]
|
||
assert.Assert(t, hasNested)
|
||
_, hasSrc := snapshot.diskDirectories[tspath.Path("/")][tspath.Path("/src")]
|
||
assert.Assert(t, hasSrc)
|
||
})
|
||
|
||
t.Run("removes directory entries on file delete", func(t *testing.T) {
|
||
t.Parallel()
|
||
testFS := vfstest.FromMap(map[string]string{
|
||
"/src/foo.ts": "const foo = 1;",
|
||
}, false /* useCaseSensitiveFileNames */)
|
||
|
||
// Start with existing diskFiles and directories
|
||
existingDiskFiles := map[tspath.Path]*diskFile{
|
||
tspath.Path("/src/foo.ts"): newDiskFile("/src/foo.ts", "const foo = 1;"),
|
||
}
|
||
existingDirs := map[tspath.Path]dirty.CloneableMap[tspath.Path, string]{
|
||
tspath.Path("/"): {
|
||
tspath.Path("/src"): "src",
|
||
},
|
||
tspath.Path("/src"): {
|
||
tspath.Path("/src/foo.ts"): "foo.ts",
|
||
},
|
||
}
|
||
|
||
builder := newSnapshotFSBuilder(
|
||
testFS,
|
||
make(map[tspath.Path]*Overlay), // prevOverlays
|
||
make(map[tspath.Path]*Overlay), // overlays
|
||
existingDiskFiles,
|
||
existingDirs,
|
||
nil, // nodeModulesRealpathAliases
|
||
lsproto.PositionEncodingKindUTF16,
|
||
toPath,
|
||
)
|
||
|
||
// Mark the file for deletion by loading and deleting
|
||
if entry, ok := builder.diskFiles.Load(tspath.Path("/src/foo.ts")); ok {
|
||
entry.Delete()
|
||
}
|
||
|
||
snapshot, changed := builder.Finalize()
|
||
assert.Assert(t, changed, "should have changed")
|
||
|
||
// File should be deleted
|
||
_, hasFile := snapshot.diskFiles[tspath.Path("/src/foo.ts")]
|
||
assert.Assert(t, !hasFile, "file should be deleted")
|
||
|
||
// Directory tree should be cleaned up
|
||
_, hasSrcDir := snapshot.diskDirectories[tspath.Path("/src")]
|
||
assert.Assert(t, !hasSrcDir, "/src directory should be removed")
|
||
|
||
_, hasRootDir := snapshot.diskDirectories[tspath.Path("/")]
|
||
assert.Assert(t, !hasRootDir, "root directory should be removed")
|
||
})
|
||
|
||
t.Run("removes only empty directories on file delete", func(t *testing.T) {
|
||
t.Parallel()
|
||
testFS := vfstest.FromMap(map[string]string{
|
||
"/src/foo.ts": "const foo = 1;",
|
||
"/src/bar.ts": "const bar = 2;",
|
||
}, false /* useCaseSensitiveFileNames */)
|
||
|
||
// Start with existing diskFiles and directories
|
||
existingDiskFiles := map[tspath.Path]*diskFile{
|
||
tspath.Path("/src/foo.ts"): newDiskFile("/src/foo.ts", "const foo = 1;"),
|
||
tspath.Path("/src/bar.ts"): newDiskFile("/src/bar.ts", "const bar = 2;"),
|
||
}
|
||
existingDirs := map[tspath.Path]dirty.CloneableMap[tspath.Path, string]{
|
||
tspath.Path("/"): {
|
||
tspath.Path("/src"): "src",
|
||
},
|
||
tspath.Path("/src"): {
|
||
tspath.Path("/src/foo.ts"): "foo.ts",
|
||
tspath.Path("/src/bar.ts"): "bar.ts",
|
||
},
|
||
}
|
||
|
||
builder := newSnapshotFSBuilder(
|
||
testFS,
|
||
make(map[tspath.Path]*Overlay), // prevOverlays
|
||
make(map[tspath.Path]*Overlay), // overlays
|
||
existingDiskFiles,
|
||
existingDirs,
|
||
nil, // nodeModulesRealpathAliases
|
||
lsproto.PositionEncodingKindUTF16,
|
||
toPath,
|
||
)
|
||
|
||
// Delete only foo.ts
|
||
if entry, ok := builder.diskFiles.Load(tspath.Path("/src/foo.ts")); ok {
|
||
entry.Delete()
|
||
}
|
||
|
||
snapshot, changed := builder.Finalize()
|
||
assert.Assert(t, changed, "should have changed")
|
||
|
||
// foo.ts should be deleted
|
||
_, hasFile := snapshot.diskFiles[tspath.Path("/src/foo.ts")]
|
||
assert.Assert(t, !hasFile, "foo.ts should be deleted")
|
||
|
||
// bar.ts should still exist
|
||
_, hasBar := snapshot.diskFiles[tspath.Path("/src/bar.ts")]
|
||
assert.Assert(t, hasBar, "bar.ts should still exist")
|
||
|
||
// /src directory should still exist with bar.ts
|
||
srcDir, hasSrcDir := snapshot.diskDirectories[tspath.Path("/src")]
|
||
assert.Assert(t, hasSrcDir, "/src directory should still exist")
|
||
_, hasFoo := srcDir[tspath.Path("/src/foo.ts")]
|
||
assert.Assert(t, !hasFoo, "/src should not contain foo.ts")
|
||
_, hasBarInDir := srcDir[tspath.Path("/src/bar.ts")]
|
||
assert.Assert(t, hasBarInDir, "/src should contain bar.ts")
|
||
|
||
// root should still contain /src
|
||
rootDir, hasRootDir := snapshot.diskDirectories[tspath.Path("/")]
|
||
assert.Assert(t, hasRootDir, "root directory should still exist")
|
||
_, hasSrc := rootDir[tspath.Path("/src")]
|
||
assert.Assert(t, hasSrc, "root should contain /src")
|
||
})
|
||
|
||
t.Run("adds file to existing directory", func(t *testing.T) {
|
||
t.Parallel()
|
||
testFS := vfstest.FromMap(map[string]string{
|
||
"/src/foo.ts": "const foo = 1;",
|
||
"/src/bar.ts": "const bar = 2;",
|
||
}, false /* useCaseSensitiveFileNames */)
|
||
|
||
// Start with existing file and directories
|
||
existingDiskFiles := map[tspath.Path]*diskFile{
|
||
tspath.Path("/src/foo.ts"): newDiskFile("/src/foo.ts", "const foo = 1;"),
|
||
}
|
||
existingDirs := map[tspath.Path]dirty.CloneableMap[tspath.Path, string]{
|
||
tspath.Path("/"): {
|
||
tspath.Path("/src"): "src",
|
||
},
|
||
tspath.Path("/src"): {
|
||
tspath.Path("/src/foo.ts"): "foo.ts",
|
||
},
|
||
}
|
||
|
||
builder := newSnapshotFSBuilder(
|
||
testFS,
|
||
make(map[tspath.Path]*Overlay), // prevOverlays
|
||
make(map[tspath.Path]*Overlay), // overlays
|
||
existingDiskFiles,
|
||
existingDirs,
|
||
nil, // nodeModulesRealpathAliases
|
||
lsproto.PositionEncodingKindUTF16,
|
||
toPath,
|
||
)
|
||
|
||
// Read bar.ts to add it
|
||
fh := builder.GetFile("/src/bar.ts")
|
||
assert.Assert(t, fh != nil, "bar.ts should exist")
|
||
|
||
snapshot, changed := builder.Finalize()
|
||
assert.Assert(t, changed, "should have changed")
|
||
|
||
// /src should contain both files
|
||
srcDir := snapshot.diskDirectories[tspath.Path("/src")]
|
||
_, hasFoo := srcDir[tspath.Path("/src/foo.ts")]
|
||
assert.Assert(t, hasFoo, "/src should contain foo.ts")
|
||
_, hasBar := srcDir[tspath.Path("/src/bar.ts")]
|
||
assert.Assert(t, hasBar, "/src should contain bar.ts")
|
||
})
|
||
|
||
t.Run("no change when no files added or deleted", func(t *testing.T) {
|
||
t.Parallel()
|
||
testFS := vfstest.FromMap(map[string]string{
|
||
"/src/foo.ts": "const foo = 1;",
|
||
}, false /* useCaseSensitiveFileNames */)
|
||
|
||
existingDiskFiles := map[tspath.Path]*diskFile{
|
||
tspath.Path("/src/foo.ts"): newDiskFile("/src/foo.ts", "const foo = 1;"),
|
||
}
|
||
existingDirs := map[tspath.Path]dirty.CloneableMap[tspath.Path, string]{
|
||
tspath.Path("/"): {
|
||
tspath.Path("/src"): "src",
|
||
},
|
||
tspath.Path("/src"): {
|
||
tspath.Path("/src/foo.ts"): "foo.ts",
|
||
},
|
||
}
|
||
|
||
builder := newSnapshotFSBuilder(
|
||
testFS,
|
||
make(map[tspath.Path]*Overlay), // prevOverlays
|
||
make(map[tspath.Path]*Overlay), // overlays
|
||
existingDiskFiles,
|
||
existingDirs,
|
||
nil, // nodeModulesRealpathAliases
|
||
lsproto.PositionEncodingKindUTF16,
|
||
toPath,
|
||
)
|
||
|
||
// Don't add or delete any files
|
||
snapshot, changed := builder.Finalize()
|
||
assert.Assert(t, !changed, "should not have changed")
|
||
|
||
// Directories should remain the same
|
||
srcDir := snapshot.diskDirectories[tspath.Path("/src")]
|
||
_, hasFoo := srcDir[tspath.Path("/src/foo.ts")]
|
||
assert.Assert(t, hasFoo)
|
||
})
|
||
|
||
t.Run("overlay files are returned over disk files", func(t *testing.T) {
|
||
t.Parallel()
|
||
testFS := vfstest.FromMap(map[string]string{
|
||
"/src/foo.ts": "const foo = 1;",
|
||
}, false /* useCaseSensitiveFileNames */)
|
||
|
||
overlays := map[tspath.Path]*Overlay{
|
||
tspath.Path("/src/foo.ts"): {
|
||
fileBase: fileBase{fileName: "/src/foo.ts", content: "const foo = 999;"},
|
||
},
|
||
}
|
||
|
||
builder := newSnapshotFSBuilder(
|
||
testFS,
|
||
make(map[tspath.Path]*Overlay), // prevOverlays
|
||
overlays,
|
||
make(map[tspath.Path]*diskFile),
|
||
make(map[tspath.Path]dirty.CloneableMap[tspath.Path, string]),
|
||
nil, // nodeModulesRealpathAliases
|
||
lsproto.PositionEncodingKindUTF16,
|
||
toPath,
|
||
)
|
||
|
||
// Should return overlay content
|
||
fh := builder.GetFile("/src/foo.ts")
|
||
assert.Assert(t, fh != nil)
|
||
assert.Equal(t, fh.Content(), "const foo = 999;")
|
||
})
|
||
|
||
t.Run("multiple files added and deleted in single cycle", func(t *testing.T) {
|
||
t.Parallel()
|
||
testFS := vfstest.FromMap(map[string]string{
|
||
"/src/a.ts": "const a = 1;",
|
||
"/src/b.ts": "const b = 2;",
|
||
"/lib/utils.ts": "export const util = 1;",
|
||
"/lib/helpers.ts": "export const helper = 1;",
|
||
"/other/single.ts": "const single = 1;",
|
||
}, false /* useCaseSensitiveFileNames */)
|
||
|
||
// Start with some existing files
|
||
existingDiskFiles := map[tspath.Path]*diskFile{
|
||
tspath.Path("/src/a.ts"): newDiskFile("/src/a.ts", "const a = 1;"),
|
||
tspath.Path("/other/single.ts"): newDiskFile("/other/single.ts", "const single = 1;"),
|
||
}
|
||
existingDirs := map[tspath.Path]dirty.CloneableMap[tspath.Path, string]{
|
||
tspath.Path("/"): {
|
||
tspath.Path("/src"): "src",
|
||
tspath.Path("/other"): "other",
|
||
},
|
||
tspath.Path("/src"): {
|
||
tspath.Path("/src/a.ts"): "a.ts",
|
||
},
|
||
tspath.Path("/other"): {
|
||
tspath.Path("/other/single.ts"): "single.ts",
|
||
},
|
||
}
|
||
|
||
builder := newSnapshotFSBuilder(
|
||
testFS,
|
||
make(map[tspath.Path]*Overlay), // prevOverlays
|
||
make(map[tspath.Path]*Overlay), // overlays
|
||
existingDiskFiles,
|
||
existingDirs,
|
||
nil, // nodeModulesRealpathAliases
|
||
lsproto.PositionEncodingKindUTF16,
|
||
toPath,
|
||
)
|
||
|
||
// Add new files
|
||
fh := builder.GetFile("/src/b.ts")
|
||
assert.Assert(t, fh != nil)
|
||
fh = builder.GetFile("/lib/utils.ts")
|
||
assert.Assert(t, fh != nil)
|
||
fh = builder.GetFile("/lib/helpers.ts")
|
||
assert.Assert(t, fh != nil)
|
||
|
||
// Delete existing files
|
||
if entry, ok := builder.diskFiles.Load(tspath.Path("/src/a.ts")); ok {
|
||
entry.Delete()
|
||
}
|
||
if entry, ok := builder.diskFiles.Load(tspath.Path("/other/single.ts")); ok {
|
||
entry.Delete()
|
||
}
|
||
|
||
snapshot, changed := builder.Finalize()
|
||
assert.Assert(t, changed, "should have changed")
|
||
|
||
// Verify deleted files are gone
|
||
_, hasA := snapshot.diskFiles[tspath.Path("/src/a.ts")]
|
||
assert.Assert(t, !hasA, "/src/a.ts should be deleted")
|
||
_, hasSingle := snapshot.diskFiles[tspath.Path("/other/single.ts")]
|
||
assert.Assert(t, !hasSingle, "/other/single.ts should be deleted")
|
||
|
||
// Verify added files exist
|
||
_, hasB := snapshot.diskFiles[tspath.Path("/src/b.ts")]
|
||
assert.Assert(t, hasB, "/src/b.ts should exist")
|
||
_, hasUtils := snapshot.diskFiles[tspath.Path("/lib/utils.ts")]
|
||
assert.Assert(t, hasUtils, "/lib/utils.ts should exist")
|
||
_, hasHelpers := snapshot.diskFiles[tspath.Path("/lib/helpers.ts")]
|
||
assert.Assert(t, hasHelpers, "/lib/helpers.ts should exist")
|
||
|
||
// Verify /other directory is cleaned up (was only entry deleted)
|
||
_, hasOther := snapshot.diskDirectories[tspath.Path("/other")]
|
||
assert.Assert(t, !hasOther, "/other directory should be removed")
|
||
|
||
// Verify /src still exists with b.ts (a.ts deleted, b.ts added)
|
||
srcDir, hasSrc := snapshot.diskDirectories[tspath.Path("/src")]
|
||
assert.Assert(t, hasSrc, "/src directory should exist")
|
||
_, hasAInDir := srcDir[tspath.Path("/src/a.ts")]
|
||
assert.Assert(t, !hasAInDir, "/src should not contain a.ts")
|
||
_, hasBInDir := srcDir[tspath.Path("/src/b.ts")]
|
||
assert.Assert(t, hasBInDir, "/src should contain b.ts")
|
||
|
||
// Verify /lib was created with both files
|
||
libDir, hasLib := snapshot.diskDirectories[tspath.Path("/lib")]
|
||
assert.Assert(t, hasLib, "/lib directory should exist")
|
||
_, hasUtilsInDir := libDir[tspath.Path("/lib/utils.ts")]
|
||
assert.Assert(t, hasUtilsInDir, "/lib should contain utils.ts")
|
||
_, hasHelpersInDir := libDir[tspath.Path("/lib/helpers.ts")]
|
||
assert.Assert(t, hasHelpersInDir, "/lib should contain helpers.ts")
|
||
|
||
// Verify root contains /src and /lib but not /other
|
||
rootDir := snapshot.diskDirectories[tspath.Path("/")]
|
||
_, hasSrcInRoot := rootDir[tspath.Path("/src")]
|
||
assert.Assert(t, hasSrcInRoot, "root should contain /src")
|
||
_, hasLibInRoot := rootDir[tspath.Path("/lib")]
|
||
assert.Assert(t, hasLibInRoot, "root should contain /lib")
|
||
_, hasOtherInRoot := rootDir[tspath.Path("/other")]
|
||
assert.Assert(t, !hasOtherInRoot, "root should not contain /other")
|
||
})
|
||
|
||
t.Run("overlay directories are computed from overlays", func(t *testing.T) {
|
||
t.Parallel()
|
||
testFS := vfstest.FromMap(map[string]string{}, false /* useCaseSensitiveFileNames */)
|
||
|
||
overlays := map[tspath.Path]*Overlay{
|
||
tspath.Path("/src/overlay.ts"): {
|
||
fileBase: fileBase{fileName: "/src/overlay.ts", content: "const x = 1;"},
|
||
},
|
||
tspath.Path("/src/nested/deep.ts"): {
|
||
fileBase: fileBase{fileName: "/src/nested/deep.ts", content: "const y = 2;"},
|
||
},
|
||
}
|
||
|
||
builder := newSnapshotFSBuilder(
|
||
testFS,
|
||
make(map[tspath.Path]*Overlay), // prevOverlays
|
||
overlays,
|
||
make(map[tspath.Path]*diskFile),
|
||
make(map[tspath.Path]dirty.CloneableMap[tspath.Path, string]),
|
||
nil, // nodeModulesRealpathAliases
|
||
lsproto.PositionEncodingKindUTF16,
|
||
toPath,
|
||
)
|
||
|
||
// Check overlayDirectories was built correctly
|
||
srcDir, ok := builder.overlayDirectories[tspath.Path("/src")]
|
||
assert.Assert(t, ok, "/src overlay directory should exist")
|
||
_, hasOverlay := srcDir[tspath.Path("/src/overlay.ts")]
|
||
assert.Assert(t, hasOverlay, "/src should contain overlay.ts")
|
||
_, hasNested := srcDir[tspath.Path("/src/nested")]
|
||
assert.Assert(t, hasNested, "/src should contain nested/")
|
||
|
||
nestedDir, ok := builder.overlayDirectories[tspath.Path("/src/nested")]
|
||
assert.Assert(t, ok, "/src/nested overlay directory should exist")
|
||
_, hasDeep := nestedDir[tspath.Path("/src/nested/deep.ts")]
|
||
assert.Assert(t, hasDeep, "/src/nested should contain deep.ts")
|
||
|
||
rootDir, ok := builder.overlayDirectories[tspath.Path("/")]
|
||
assert.Assert(t, ok, "/ overlay directory should exist")
|
||
_, hasSrc := rootDir[tspath.Path("/src")]
|
||
assert.Assert(t, hasSrc, "/ should contain /src")
|
||
})
|
||
|
||
t.Run("GetAccessibleEntries combines disk and overlay", func(t *testing.T) {
|
||
t.Parallel()
|
||
testFS := vfstest.FromMap(map[string]string{
|
||
"/src/disk.ts": "const disk = 1;",
|
||
}, false /* useCaseSensitiveFileNames */)
|
||
|
||
overlays := map[tspath.Path]*Overlay{
|
||
tspath.Path("/src/overlay.ts"): {
|
||
fileBase: fileBase{fileName: "/src/overlay.ts", content: "const overlay = 1;"},
|
||
},
|
||
}
|
||
|
||
builder := newSnapshotFSBuilder(
|
||
testFS,
|
||
make(map[tspath.Path]*Overlay), // prevOverlays
|
||
overlays,
|
||
make(map[tspath.Path]*diskFile),
|
||
make(map[tspath.Path]dirty.CloneableMap[tspath.Path, string]),
|
||
nil, // nodeModulesRealpathAliases
|
||
lsproto.PositionEncodingKindUTF16,
|
||
toPath,
|
||
)
|
||
|
||
entries := builder.GetAccessibleEntries("/src")
|
||
|
||
// Should contain both disk file and overlay file (both as basenames)
|
||
assert.Assert(t, slices.Contains(entries.Files, "disk.ts"), "should contain disk.ts")
|
||
assert.Assert(t, slices.Contains(entries.Files, "overlay.ts"), "should contain overlay.ts")
|
||
})
|
||
|
||
t.Run("GetAccessibleEntries is safe under concurrent calls", func(t *testing.T) {
|
||
t.Parallel()
|
||
testFS := vfstest.FromMap(map[string]string{
|
||
"/src/a.ts": "",
|
||
"/src/b.ts": "",
|
||
"/src/c.ts": "",
|
||
"/src/d.ts": "",
|
||
"/src/e.ts": "",
|
||
}, false /* useCaseSensitiveFileNames */)
|
||
|
||
overlays := map[tspath.Path]*Overlay{
|
||
tspath.Path("/src/overlay.ts"): {
|
||
fileBase: fileBase{fileName: "/src/overlay.ts", content: ""},
|
||
},
|
||
}
|
||
|
||
builder := newSnapshotFSBuilder(
|
||
testFS,
|
||
make(map[tspath.Path]*Overlay), // prevOverlays
|
||
overlays,
|
||
make(map[tspath.Path]*diskFile),
|
||
make(map[tspath.Path]dirty.CloneableMap[tspath.Path, string]),
|
||
nil, // nodeModulesRealpathAliases
|
||
lsproto.PositionEncodingKindUTF16,
|
||
toPath,
|
||
)
|
||
|
||
_ = builder.fs.GetAccessibleEntries("/src")
|
||
|
||
start := make(chan struct{})
|
||
var wg sync.WaitGroup
|
||
for range 50 {
|
||
wg.Go(func() {
|
||
<-start
|
||
_ = builder.GetAccessibleEntries("/src")
|
||
})
|
||
}
|
||
close(start)
|
||
wg.Wait()
|
||
|
||
// Sanity: the overlay is still reported after all the concurrent calls.
|
||
entries := builder.GetAccessibleEntries("/src")
|
||
assert.Assert(t, slices.Contains(entries.Files, "overlay.ts"), "should contain overlay.ts")
|
||
})
|
||
}
|
||
|
||
func TestSnapshotFS(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
toPath := func(fileName string) tspath.Path {
|
||
return tspath.Path(fileName)
|
||
}
|
||
|
||
t.Run("GetFile returns overlay file", func(t *testing.T) {
|
||
t.Parallel()
|
||
testFS := vfstest.FromMap(map[string]string{
|
||
"/src/foo.ts": "disk content",
|
||
}, false /* useCaseSensitiveFileNames */)
|
||
|
||
overlays := map[tspath.Path]*Overlay{
|
||
tspath.Path("/src/foo.ts"): {
|
||
fileBase: fileBase{fileName: "/src/foo.ts", content: "overlay content"},
|
||
},
|
||
}
|
||
|
||
snapshot := &SnapshotFS{
|
||
toPath: toPath,
|
||
fs: testFS,
|
||
overlays: overlays,
|
||
overlayDirectories: make(map[tspath.Path]map[tspath.Path]string),
|
||
diskFiles: make(map[tspath.Path]*diskFile),
|
||
diskDirectories: make(map[tspath.Path]dirty.CloneableMap[tspath.Path, string]),
|
||
}
|
||
|
||
fh := snapshot.GetFile("/src/foo.ts")
|
||
assert.Assert(t, fh != nil)
|
||
assert.Equal(t, fh.Content(), "overlay content")
|
||
})
|
||
|
||
t.Run("GetFile returns disk file when not in overlay", func(t *testing.T) {
|
||
t.Parallel()
|
||
testFS := vfstest.FromMap(map[string]string{
|
||
"/src/foo.ts": "disk content",
|
||
}, false /* useCaseSensitiveFileNames */)
|
||
|
||
diskFiles := map[tspath.Path]*diskFile{
|
||
tspath.Path("/src/foo.ts"): newDiskFile("/src/foo.ts", "disk content"),
|
||
}
|
||
|
||
snapshot := &SnapshotFS{
|
||
toPath: toPath,
|
||
fs: testFS,
|
||
overlays: make(map[tspath.Path]*Overlay),
|
||
overlayDirectories: make(map[tspath.Path]map[tspath.Path]string),
|
||
diskFiles: diskFiles,
|
||
diskDirectories: make(map[tspath.Path]dirty.CloneableMap[tspath.Path, string]),
|
||
}
|
||
|
||
fh := snapshot.GetFile("/src/foo.ts")
|
||
assert.Assert(t, fh != nil)
|
||
assert.Equal(t, fh.Content(), "disk content")
|
||
})
|
||
|
||
t.Run("GetFile reads from fs when not cached", func(t *testing.T) {
|
||
t.Parallel()
|
||
testFS := vfstest.FromMap(map[string]string{
|
||
"/src/foo.ts": "fs content",
|
||
}, false /* useCaseSensitiveFileNames */)
|
||
|
||
snapshot := &SnapshotFS{
|
||
toPath: toPath,
|
||
fs: testFS,
|
||
overlays: make(map[tspath.Path]*Overlay),
|
||
overlayDirectories: make(map[tspath.Path]map[tspath.Path]string),
|
||
diskFiles: make(map[tspath.Path]*diskFile),
|
||
diskDirectories: make(map[tspath.Path]dirty.CloneableMap[tspath.Path, string]),
|
||
}
|
||
|
||
fh := snapshot.GetFile("/src/foo.ts")
|
||
assert.Assert(t, fh != nil)
|
||
assert.Equal(t, fh.Content(), "fs content")
|
||
})
|
||
|
||
t.Run("GetFile returns nil for non-existent file", func(t *testing.T) {
|
||
t.Parallel()
|
||
testFS := vfstest.FromMap(map[string]string{}, false /* useCaseSensitiveFileNames */)
|
||
|
||
snapshot := &SnapshotFS{
|
||
toPath: toPath,
|
||
fs: testFS,
|
||
overlays: make(map[tspath.Path]*Overlay),
|
||
overlayDirectories: make(map[tspath.Path]map[tspath.Path]string),
|
||
diskFiles: make(map[tspath.Path]*diskFile),
|
||
diskDirectories: make(map[tspath.Path]dirty.CloneableMap[tspath.Path, string]),
|
||
}
|
||
|
||
fh := snapshot.GetFile("/src/nonexistent.ts")
|
||
assert.Assert(t, fh == nil, "should return nil for non-existent file")
|
||
})
|
||
|
||
t.Run("isOpenFile returns true for overlays", func(t *testing.T) {
|
||
t.Parallel()
|
||
testFS := vfstest.FromMap(map[string]string{}, false /* useCaseSensitiveFileNames */)
|
||
|
||
overlays := map[tspath.Path]*Overlay{
|
||
tspath.Path("/src/foo.ts"): {
|
||
fileBase: fileBase{fileName: "/src/foo.ts", content: "overlay content"},
|
||
},
|
||
}
|
||
|
||
snapshot := &SnapshotFS{
|
||
toPath: toPath,
|
||
fs: testFS,
|
||
overlays: overlays,
|
||
overlayDirectories: make(map[tspath.Path]map[tspath.Path]string),
|
||
diskFiles: make(map[tspath.Path]*diskFile),
|
||
diskDirectories: make(map[tspath.Path]dirty.CloneableMap[tspath.Path, string]),
|
||
}
|
||
|
||
assert.Assert(t, snapshot.isOpenFile("/src/foo.ts"), "overlay file should be open")
|
||
assert.Assert(t, !snapshot.isOpenFile("/src/bar.ts"), "non-overlay file should not be open")
|
||
})
|
||
|
||
t.Run("GetFileByPath uses provided path", func(t *testing.T) {
|
||
t.Parallel()
|
||
testFS := vfstest.FromMap(map[string]string{
|
||
"/src/foo.ts": "disk content",
|
||
}, false /* useCaseSensitiveFileNames */)
|
||
|
||
overlays := map[tspath.Path]*Overlay{
|
||
tspath.Path("/src/foo.ts"): {
|
||
fileBase: fileBase{fileName: "/src/foo.ts", content: "overlay content"},
|
||
},
|
||
}
|
||
|
||
snapshot := &SnapshotFS{
|
||
toPath: toPath,
|
||
fs: testFS,
|
||
overlays: overlays,
|
||
overlayDirectories: make(map[tspath.Path]map[tspath.Path]string),
|
||
diskFiles: make(map[tspath.Path]*diskFile),
|
||
diskDirectories: make(map[tspath.Path]dirty.CloneableMap[tspath.Path, string]),
|
||
}
|
||
|
||
// GetFileByPath should use the provided path directly
|
||
fh := snapshot.GetFileByPath("/src/foo.ts", tspath.Path("/src/foo.ts"))
|
||
assert.Assert(t, fh != nil)
|
||
assert.Equal(t, fh.Content(), "overlay content")
|
||
})
|
||
|
||
t.Run("GetAccessibleEntries combines disk and overlay directories", func(t *testing.T) {
|
||
t.Parallel()
|
||
testFS := vfstest.FromMap(map[string]string{}, false /* useCaseSensitiveFileNames */)
|
||
|
||
overlays := map[tspath.Path]*Overlay{
|
||
tspath.Path("/src/overlay.ts"): {
|
||
fileBase: fileBase{fileName: "/src/overlay.ts", content: "overlay content"},
|
||
},
|
||
}
|
||
overlayDirectories := map[tspath.Path]map[tspath.Path]string{
|
||
tspath.Path("/"): {
|
||
tspath.Path("/src"): "src",
|
||
},
|
||
tspath.Path("/src"): {
|
||
tspath.Path("/src/overlay.ts"): "overlay.ts",
|
||
},
|
||
}
|
||
diskFiles := map[tspath.Path]*diskFile{
|
||
tspath.Path("/src/disk.ts"): newDiskFile("/src/disk.ts", "disk content"),
|
||
}
|
||
diskDirectories := map[tspath.Path]dirty.CloneableMap[tspath.Path, string]{
|
||
tspath.Path("/"): {
|
||
tspath.Path("/src"): "src",
|
||
},
|
||
tspath.Path("/src"): {
|
||
tspath.Path("/src/disk.ts"): "disk.ts",
|
||
},
|
||
}
|
||
|
||
snapshot := &SnapshotFS{
|
||
toPath: toPath,
|
||
fs: testFS,
|
||
overlays: overlays,
|
||
overlayDirectories: overlayDirectories,
|
||
diskFiles: diskFiles,
|
||
diskDirectories: diskDirectories,
|
||
}
|
||
|
||
entries := snapshot.GetAccessibleEntries("/src")
|
||
|
||
// Should contain both disk file and overlay file (both as basenames)
|
||
assert.Assert(t, slices.Contains(entries.Files, "disk.ts"), "should contain disk.ts")
|
||
assert.Assert(t, slices.Contains(entries.Files, "overlay.ts"), "should contain overlay.ts")
|
||
})
|
||
}
|
||
|
||
func TestSourceFS(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
toPath := func(fileName string) tspath.Path {
|
||
return tspath.Path(fileName)
|
||
}
|
||
|
||
t.Run("tracks files when tracking enabled", func(t *testing.T) {
|
||
t.Parallel()
|
||
testFS := vfstest.FromMap(map[string]string{
|
||
"/src/foo.ts": "content",
|
||
}, false /* useCaseSensitiveFileNames */)
|
||
|
||
snapshot := &SnapshotFS{
|
||
toPath: toPath,
|
||
fs: testFS,
|
||
overlays: make(map[tspath.Path]*Overlay),
|
||
overlayDirectories: make(map[tspath.Path]map[tspath.Path]string),
|
||
diskFiles: make(map[tspath.Path]*diskFile),
|
||
diskDirectories: make(map[tspath.Path]dirty.CloneableMap[tspath.Path, string]),
|
||
}
|
||
|
||
sourceFS := newSourceFS(true /* tracking */, snapshot, toPath)
|
||
|
||
// File should not be seen yet
|
||
assert.Assert(t, !sourceFS.SeenFile(tspath.Path("/src/foo.ts")))
|
||
|
||
// Read the file
|
||
fh := sourceFS.GetFile("/src/foo.ts")
|
||
assert.Assert(t, fh != nil)
|
||
|
||
// Now it should be seen
|
||
assert.Assert(t, sourceFS.SeenFile(tspath.Path("/src/foo.ts")))
|
||
})
|
||
|
||
t.Run("does not track files when tracking disabled", func(t *testing.T) {
|
||
t.Parallel()
|
||
testFS := vfstest.FromMap(map[string]string{
|
||
"/src/foo.ts": "content",
|
||
}, false /* useCaseSensitiveFileNames */)
|
||
|
||
snapshot := &SnapshotFS{
|
||
toPath: toPath,
|
||
fs: testFS,
|
||
overlays: make(map[tspath.Path]*Overlay),
|
||
overlayDirectories: make(map[tspath.Path]map[tspath.Path]string),
|
||
diskFiles: make(map[tspath.Path]*diskFile),
|
||
diskDirectories: make(map[tspath.Path]dirty.CloneableMap[tspath.Path, string]),
|
||
}
|
||
|
||
sourceFS := newSourceFS(false /* tracking */, snapshot, toPath)
|
||
|
||
// Read the file
|
||
fh := sourceFS.GetFile("/src/foo.ts")
|
||
assert.Assert(t, fh != nil)
|
||
|
||
// Should not be seen since tracking is disabled
|
||
assert.Assert(t, !sourceFS.SeenFile(tspath.Path("/src/foo.ts")))
|
||
})
|
||
|
||
t.Run("DisableTracking stops tracking", func(t *testing.T) {
|
||
t.Parallel()
|
||
testFS := vfstest.FromMap(map[string]string{
|
||
"/src/foo.ts": "content",
|
||
"/src/bar.ts": "content",
|
||
}, false /* useCaseSensitiveFileNames */)
|
||
|
||
snapshot := &SnapshotFS{
|
||
toPath: toPath,
|
||
fs: testFS,
|
||
overlays: make(map[tspath.Path]*Overlay),
|
||
overlayDirectories: make(map[tspath.Path]map[tspath.Path]string),
|
||
diskFiles: make(map[tspath.Path]*diskFile),
|
||
diskDirectories: make(map[tspath.Path]dirty.CloneableMap[tspath.Path, string]),
|
||
}
|
||
|
||
sourceFS := newSourceFS(true /* tracking */, snapshot, toPath)
|
||
|
||
// Read foo while tracking
|
||
sourceFS.GetFile("/src/foo.ts")
|
||
assert.Assert(t, sourceFS.SeenFile(tspath.Path("/src/foo.ts")))
|
||
|
||
// Disable tracking
|
||
sourceFS.DisableTracking()
|
||
|
||
// Read bar after tracking disabled
|
||
sourceFS.GetFile("/src/bar.ts")
|
||
assert.Assert(t, !sourceFS.SeenFile(tspath.Path("/src/bar.ts")))
|
||
})
|
||
|
||
t.Run("FileExists returns true for files in source", func(t *testing.T) {
|
||
t.Parallel()
|
||
testFS := vfstest.FromMap(map[string]string{
|
||
"/src/foo.ts": "content",
|
||
}, false /* useCaseSensitiveFileNames */)
|
||
|
||
snapshot := &SnapshotFS{
|
||
toPath: toPath,
|
||
fs: testFS,
|
||
overlays: make(map[tspath.Path]*Overlay),
|
||
overlayDirectories: make(map[tspath.Path]map[tspath.Path]string),
|
||
diskFiles: make(map[tspath.Path]*diskFile),
|
||
diskDirectories: make(map[tspath.Path]dirty.CloneableMap[tspath.Path, string]),
|
||
}
|
||
|
||
sourceFS := newSourceFS(false /* tracking */, snapshot, toPath)
|
||
|
||
assert.Assert(t, sourceFS.FileExists("/src/foo.ts"))
|
||
assert.Assert(t, !sourceFS.FileExists("/src/nonexistent.ts"))
|
||
})
|
||
|
||
t.Run("ReadFile returns content for files in source", func(t *testing.T) {
|
||
t.Parallel()
|
||
testFS := vfstest.FromMap(map[string]string{
|
||
"/src/foo.ts": "file content",
|
||
}, false /* useCaseSensitiveFileNames */)
|
||
|
||
snapshot := &SnapshotFS{
|
||
toPath: toPath,
|
||
fs: testFS,
|
||
overlays: make(map[tspath.Path]*Overlay),
|
||
overlayDirectories: make(map[tspath.Path]map[tspath.Path]string),
|
||
diskFiles: make(map[tspath.Path]*diskFile),
|
||
diskDirectories: make(map[tspath.Path]dirty.CloneableMap[tspath.Path, string]),
|
||
}
|
||
|
||
sourceFS := newSourceFS(false /* tracking */, snapshot, toPath)
|
||
|
||
content, ok := sourceFS.ReadFile("/src/foo.ts")
|
||
assert.Assert(t, ok)
|
||
assert.Equal(t, content, "file content")
|
||
|
||
_, ok = sourceFS.ReadFile("/src/nonexistent.ts")
|
||
assert.Assert(t, !ok)
|
||
})
|
||
}
|
||
|
||
func TestAutoImportBuilderFS(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
toPath := func(fileName string) tspath.Path {
|
||
return tspath.Path(fileName)
|
||
}
|
||
|
||
// This test demonstrates that autoImportBuilderFS stores files in untrackedFiles keyed
|
||
// by the path derived from the filename. When module resolution reads a file via its
|
||
// symlink path, the file is cached at the symlink path key. If the file is subsequently
|
||
// requested by its realpath (which is the typical case after module resolution resolves
|
||
// the symlink), the cache is bypassed. If the file was deleted from disk between those
|
||
// two operations, the realpath read fails and returns nil. This is the mechanism behind
|
||
// the nil source file crash in aliasResolver.GetSourceFile.
|
||
t.Run("symlink cache mismatch: file cached at symlink path, missed at realpath after deletion", func(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
// Create a VFS with a real file and a symlinked directory pointing to it.
|
||
// /real/pkg/index.d.ts is the real file.
|
||
// /project/node_modules/pkg is a symlink to /real/pkg.
|
||
testFS := vfstest.FromMap(map[string]any{
|
||
"/real/pkg/index.d.ts": "export declare const x: number;",
|
||
"/project/node_modules/pkg": vfstest.Symlink("/real/pkg"),
|
||
}, true /* useCaseSensitiveFileNames */)
|
||
|
||
// Verify symlink works as expected
|
||
symlinkPath := "/project/node_modules/pkg/index.d.ts"
|
||
realpathPath := testFS.Realpath(symlinkPath)
|
||
assert.Equal(t, realpathPath, "/real/pkg/index.d.ts", "Realpath should resolve the symlink to the real path")
|
||
|
||
builder := newSnapshotFSBuilder(
|
||
testFS,
|
||
make(map[tspath.Path]*Overlay),
|
||
make(map[tspath.Path]*Overlay),
|
||
make(map[tspath.Path]*diskFile),
|
||
make(map[tspath.Path]dirty.CloneableMap[tspath.Path, string]),
|
||
nil, // nodeModulesRealpathAliases
|
||
lsproto.PositionEncodingKindUTF16,
|
||
toPath,
|
||
)
|
||
|
||
autoImportFS := &autoImportBuilderFS{
|
||
snapshotFSBuilder: builder,
|
||
}
|
||
|
||
// Step 1: Read the file via its symlink path (simulating what module resolution does
|
||
// during FileExists). This caches the file in untrackedFiles at the symlink path key.
|
||
fh := autoImportFS.GetFile(symlinkPath)
|
||
assert.Assert(t, fh != nil, "File should be readable via symlink path")
|
||
assert.Equal(t, fh.Content(), "export declare const x: number;")
|
||
|
||
// Step 2: Simulate a file deletion from disk (e.g., npm install running concurrently).
|
||
// This deletes both the real file and effectively breaks the symlink.
|
||
err := testFS.Remove("/real/pkg/index.d.ts")
|
||
assert.NilError(t, err)
|
||
|
||
// Step 3: Request the file by its realpath (simulating what GetSourceFile does after
|
||
// the checker resolves the module). This bypasses the symlink-path cache entry in
|
||
// untrackedFiles because the realpath has a different key.
|
||
fh2 := autoImportFS.GetFile(realpathPath)
|
||
// The file was cached at the symlink path, but the realpath lookup misses the cache
|
||
// and goes to disk where the file is now deleted. This returns nil.
|
||
assert.Assert(t, fh2 == nil, "File should be nil when accessed by realpath after deletion from disk")
|
||
})
|
||
}
|
||
|
||
func TestRealpathAliasLifecycle(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
toPath := func(fileName string) tspath.Path {
|
||
return tspath.Path(fileName)
|
||
}
|
||
|
||
t.Run("alias recorded when reading symlinked node_modules file", func(t *testing.T) {
|
||
t.Parallel()
|
||
testFS := vfstest.FromMap(map[string]any{
|
||
"/project/node_modules/mylib": vfstest.Symlink("/packages/mylib"),
|
||
"/packages/mylib/package.json": `{"name": "mylib", "main": "index.js"}`,
|
||
"/packages/mylib/index.d.ts": `export declare const x: number;`,
|
||
"/project/node_modules/nolink/package.json": `{"name": "nolink"}`,
|
||
}, false)
|
||
|
||
builder := newSnapshotFSBuilder(
|
||
testFS,
|
||
make(map[tspath.Path]*Overlay),
|
||
make(map[tspath.Path]*Overlay),
|
||
make(map[tspath.Path]*diskFile),
|
||
make(map[tspath.Path]dirty.CloneableMap[tspath.Path, string]),
|
||
nil,
|
||
lsproto.PositionEncodingKindUTF16,
|
||
toPath,
|
||
)
|
||
|
||
// Read a file through the symlink — should record an alias.
|
||
fh := builder.GetFile("/project/node_modules/mylib/package.json")
|
||
assert.Assert(t, fh != nil)
|
||
assert.Equal(t, fh.Content(), `{"name": "mylib", "main": "index.js"}`)
|
||
|
||
// Read a non-symlinked node_modules file — should NOT record an alias.
|
||
fh2 := builder.GetFile("/project/node_modules/nolink/package.json")
|
||
assert.Assert(t, fh2 != nil)
|
||
|
||
snapshot, _ := builder.Finalize()
|
||
|
||
// Alias exists for the symlinked file.
|
||
aliases, ok := snapshot.nodeModulesRealpathAliases[tspath.Path("/packages/mylib/package.json")]
|
||
assert.Assert(t, ok, "alias should exist for realpath of symlinked file")
|
||
assert.Assert(t, aliases.paths.Has(tspath.Path("/project/node_modules/mylib/package.json")))
|
||
|
||
// No alias for the non-symlinked file.
|
||
_, ok = snapshot.nodeModulesRealpathAliases[tspath.Path("/project/node_modules/nolink/package.json")]
|
||
assert.Assert(t, !ok, "no alias should exist for non-symlinked file")
|
||
})
|
||
|
||
t.Run("no alias recorded for files outside node_modules", func(t *testing.T) {
|
||
t.Parallel()
|
||
testFS := vfstest.FromMap(map[string]any{
|
||
"/project/link": vfstest.Symlink("/elsewhere"),
|
||
"/elsewhere/index.ts": `export const x = 1;`,
|
||
}, false)
|
||
|
||
builder := newSnapshotFSBuilder(
|
||
testFS,
|
||
make(map[tspath.Path]*Overlay),
|
||
make(map[tspath.Path]*Overlay),
|
||
make(map[tspath.Path]*diskFile),
|
||
make(map[tspath.Path]dirty.CloneableMap[tspath.Path, string]),
|
||
nil,
|
||
lsproto.PositionEncodingKindUTF16,
|
||
toPath,
|
||
)
|
||
|
||
fh := builder.GetFile("/project/link/index.ts")
|
||
assert.Assert(t, fh != nil)
|
||
|
||
snapshot, _ := builder.Finalize()
|
||
assert.Equal(t, len(snapshot.nodeModulesRealpathAliases), 0, "no aliases for non-node_modules symlinks")
|
||
})
|
||
|
||
t.Run("aliases carried over across snapshots", func(t *testing.T) {
|
||
t.Parallel()
|
||
testFS := vfstest.FromMap(map[string]any{
|
||
"/project/node_modules/mylib": vfstest.Symlink("/packages/mylib"),
|
||
"/packages/mylib/package.json": `{"name": "mylib"}`,
|
||
}, false)
|
||
|
||
// Build first snapshot.
|
||
builder1 := newSnapshotFSBuilder(
|
||
testFS,
|
||
make(map[tspath.Path]*Overlay),
|
||
make(map[tspath.Path]*Overlay),
|
||
make(map[tspath.Path]*diskFile),
|
||
make(map[tspath.Path]dirty.CloneableMap[tspath.Path, string]),
|
||
nil,
|
||
lsproto.PositionEncodingKindUTF16,
|
||
toPath,
|
||
)
|
||
builder1.GetFile("/project/node_modules/mylib/package.json")
|
||
snapshot1, _ := builder1.Finalize()
|
||
|
||
// Build second snapshot from the first, without reading the file again.
|
||
builder2 := newSnapshotFSBuilder(
|
||
testFS,
|
||
make(map[tspath.Path]*Overlay),
|
||
make(map[tspath.Path]*Overlay),
|
||
snapshot1.diskFiles,
|
||
snapshot1.diskDirectories,
|
||
snapshot1.nodeModulesRealpathAliases,
|
||
lsproto.PositionEncodingKindUTF16,
|
||
toPath,
|
||
)
|
||
snapshot2, _ := builder2.Finalize()
|
||
|
||
// Alias should still be present.
|
||
aliases, ok := snapshot2.nodeModulesRealpathAliases[tspath.Path("/packages/mylib/package.json")]
|
||
assert.Assert(t, ok, "alias should survive across snapshots")
|
||
assert.Assert(t, aliases.paths.Has(tspath.Path("/project/node_modules/mylib/package.json")))
|
||
})
|
||
|
||
t.Run("alias pruned when symlinked file is deleted", func(t *testing.T) {
|
||
t.Parallel()
|
||
testFS := vfstest.FromMap(map[string]any{
|
||
"/project/node_modules/mylib": vfstest.Symlink("/packages/mylib"),
|
||
"/packages/mylib/package.json": `{"name": "mylib"}`,
|
||
"/packages/mylib/index.d.ts": `export declare const x: number;`,
|
||
}, false)
|
||
|
||
// Build first snapshot — read both files.
|
||
builder1 := newSnapshotFSBuilder(
|
||
testFS,
|
||
make(map[tspath.Path]*Overlay),
|
||
make(map[tspath.Path]*Overlay),
|
||
make(map[tspath.Path]*diskFile),
|
||
make(map[tspath.Path]dirty.CloneableMap[tspath.Path, string]),
|
||
nil,
|
||
lsproto.PositionEncodingKindUTF16,
|
||
toPath,
|
||
)
|
||
builder1.GetFile("/project/node_modules/mylib/package.json")
|
||
builder1.GetFile("/project/node_modules/mylib/index.d.ts")
|
||
snapshot1, _ := builder1.Finalize()
|
||
|
||
// Both should be aliased under the same realpath directory but separate files.
|
||
_, ok := snapshot1.nodeModulesRealpathAliases[tspath.Path("/packages/mylib/package.json")]
|
||
assert.Assert(t, ok)
|
||
_, ok = snapshot1.nodeModulesRealpathAliases[tspath.Path("/packages/mylib/index.d.ts")]
|
||
assert.Assert(t, ok)
|
||
|
||
// Build second snapshot — delete one file via markDirtyFiles.
|
||
builder2 := newSnapshotFSBuilder(
|
||
testFS,
|
||
make(map[tspath.Path]*Overlay),
|
||
make(map[tspath.Path]*Overlay),
|
||
snapshot1.diskFiles,
|
||
snapshot1.diskDirectories,
|
||
snapshot1.nodeModulesRealpathAliases,
|
||
lsproto.PositionEncodingKindUTF16,
|
||
toPath,
|
||
)
|
||
|
||
// Simulate deletion of index.d.ts from the disk file cache.
|
||
var entry *dirty.SyncMapEntry[tspath.Path, *diskFile]
|
||
if entry, ok = builder2.diskFiles.Load(tspath.Path("/project/node_modules/mylib/index.d.ts")); ok {
|
||
entry.Delete()
|
||
}
|
||
|
||
snapshot2, _ := builder2.Finalize()
|
||
|
||
// package.json alias should remain.
|
||
aliases, ok := snapshot2.nodeModulesRealpathAliases[tspath.Path("/packages/mylib/package.json")]
|
||
assert.Assert(t, ok, "package.json alias should survive")
|
||
assert.Assert(t, aliases.paths.Has(tspath.Path("/project/node_modules/mylib/package.json")))
|
||
|
||
// index.d.ts alias should be fully pruned (empty set → removed from map).
|
||
_, ok = snapshot2.nodeModulesRealpathAliases[tspath.Path("/packages/mylib/index.d.ts")]
|
||
assert.Assert(t, !ok, "index.d.ts alias should be pruned after deletion")
|
||
})
|
||
|
||
t.Run("multiple symlinks to same realpath", func(t *testing.T) {
|
||
t.Parallel()
|
||
testFS := vfstest.FromMap(map[string]any{
|
||
"/project/node_modules/mylib": vfstest.Symlink("/packages/mylib"),
|
||
"/project/node_modules/alias": vfstest.Symlink("/packages/mylib"),
|
||
"/packages/mylib/package.json": `{"name": "mylib"}`,
|
||
}, false)
|
||
|
||
builder := newSnapshotFSBuilder(
|
||
testFS,
|
||
make(map[tspath.Path]*Overlay),
|
||
make(map[tspath.Path]*Overlay),
|
||
make(map[tspath.Path]*diskFile),
|
||
make(map[tspath.Path]dirty.CloneableMap[tspath.Path, string]),
|
||
nil,
|
||
lsproto.PositionEncodingKindUTF16,
|
||
toPath,
|
||
)
|
||
|
||
// Read via both symlinks.
|
||
fh1 := builder.GetFile("/project/node_modules/mylib/package.json")
|
||
assert.Assert(t, fh1 != nil)
|
||
fh2 := builder.GetFile("/project/node_modules/alias/package.json")
|
||
assert.Assert(t, fh2 != nil)
|
||
|
||
snapshot, _ := builder.Finalize()
|
||
|
||
aliases, ok := snapshot.nodeModulesRealpathAliases[tspath.Path("/packages/mylib/package.json")]
|
||
assert.Assert(t, ok, "alias should exist")
|
||
assert.Assert(t, aliases.paths.Has(tspath.Path("/project/node_modules/mylib/package.json")))
|
||
assert.Assert(t, aliases.paths.Has(tspath.Path("/project/node_modules/alias/package.json")))
|
||
})
|
||
|
||
t.Run("multiple symlinks pruned individually", func(t *testing.T) {
|
||
t.Parallel()
|
||
testFS := vfstest.FromMap(map[string]any{
|
||
"/project/node_modules/mylib": vfstest.Symlink("/packages/mylib"),
|
||
"/project/node_modules/alias": vfstest.Symlink("/packages/mylib"),
|
||
"/packages/mylib/package.json": `{"name": "mylib"}`,
|
||
}, false)
|
||
|
||
// Build first snapshot – read via both symlinks.
|
||
builder1 := newSnapshotFSBuilder(
|
||
testFS,
|
||
make(map[tspath.Path]*Overlay),
|
||
make(map[tspath.Path]*Overlay),
|
||
make(map[tspath.Path]*diskFile),
|
||
make(map[tspath.Path]dirty.CloneableMap[tspath.Path, string]),
|
||
nil,
|
||
lsproto.PositionEncodingKindUTF16,
|
||
toPath,
|
||
)
|
||
builder1.GetFile("/project/node_modules/mylib/package.json")
|
||
builder1.GetFile("/project/node_modules/alias/package.json")
|
||
snapshot1, _ := builder1.Finalize()
|
||
|
||
// Build second snapshot – delete ONE of the symlink disk entries.
|
||
builder2 := newSnapshotFSBuilder(
|
||
testFS,
|
||
make(map[tspath.Path]*Overlay),
|
||
make(map[tspath.Path]*Overlay),
|
||
snapshot1.diskFiles,
|
||
snapshot1.diskDirectories,
|
||
snapshot1.nodeModulesRealpathAliases,
|
||
lsproto.PositionEncodingKindUTF16,
|
||
toPath,
|
||
)
|
||
if entry, ok := builder2.diskFiles.Load(tspath.Path("/project/node_modules/alias/package.json")); ok {
|
||
entry.Delete()
|
||
}
|
||
snapshot2, _ := builder2.Finalize()
|
||
|
||
// The realpath alias set should still exist, but only contain the surviving symlink.
|
||
aliases, ok := snapshot2.nodeModulesRealpathAliases[tspath.Path("/packages/mylib/package.json")]
|
||
assert.Assert(t, ok, "alias set should still exist")
|
||
assert.Assert(t, aliases.paths.Has(tspath.Path("/project/node_modules/mylib/package.json")), "surviving symlink should remain")
|
||
assert.Assert(t, !aliases.paths.Has(tspath.Path("/project/node_modules/alias/package.json")), "deleted symlink should be pruned")
|
||
})
|
||
|
||
t.Run("expandRealpathAliases expands change events", func(t *testing.T) {
|
||
t.Parallel()
|
||
testFS := vfstest.FromMap(map[string]any{
|
||
"/project/node_modules/mylib": vfstest.Symlink("/packages/mylib"),
|
||
"/packages/mylib/package.json": `{"name": "mylib"}`,
|
||
}, false)
|
||
|
||
builder := newSnapshotFSBuilder(
|
||
testFS,
|
||
make(map[tspath.Path]*Overlay),
|
||
make(map[tspath.Path]*Overlay),
|
||
make(map[tspath.Path]*diskFile),
|
||
make(map[tspath.Path]dirty.CloneableMap[tspath.Path, string]),
|
||
nil,
|
||
lsproto.PositionEncodingKindUTF16,
|
||
toPath,
|
||
)
|
||
builder.GetFile("/project/node_modules/mylib/package.json")
|
||
snapshot, _ := builder.Finalize()
|
||
|
||
// Simulate a watch event on the REALPATH.
|
||
change := FileChangeSummary{}
|
||
change.Changed.Add("file:///packages/mylib/package.json")
|
||
|
||
expanded := snapshot.expandRealpathAliases(change)
|
||
|
||
// Should now also contain the symlink path.
|
||
assert.Assert(t, expanded.Changed.Has("file:///packages/mylib/package.json"), "original event should remain")
|
||
assert.Assert(t, expanded.Changed.Has("file:///project/node_modules/mylib/package.json"), "symlink event should be added")
|
||
})
|
||
|
||
t.Run("expandRealpathAliases expands delete events", func(t *testing.T) {
|
||
t.Parallel()
|
||
testFS := vfstest.FromMap(map[string]any{
|
||
"/project/node_modules/mylib": vfstest.Symlink("/packages/mylib"),
|
||
"/packages/mylib/package.json": `{"name": "mylib"}`,
|
||
}, false)
|
||
|
||
builder := newSnapshotFSBuilder(
|
||
testFS,
|
||
make(map[tspath.Path]*Overlay),
|
||
make(map[tspath.Path]*Overlay),
|
||
make(map[tspath.Path]*diskFile),
|
||
make(map[tspath.Path]dirty.CloneableMap[tspath.Path, string]),
|
||
nil,
|
||
lsproto.PositionEncodingKindUTF16,
|
||
toPath,
|
||
)
|
||
builder.GetFile("/project/node_modules/mylib/package.json")
|
||
snapshot, _ := builder.Finalize()
|
||
|
||
// Simulate a delete watch event on the REALPATH.
|
||
change := FileChangeSummary{}
|
||
change.Deleted.Add("file:///packages/mylib/package.json")
|
||
|
||
expanded := snapshot.expandRealpathAliases(change)
|
||
|
||
assert.Assert(t, expanded.Deleted.Has("file:///project/node_modules/mylib/package.json"), "symlink deletion should be added")
|
||
})
|
||
|
||
t.Run("expandRealpathAliases is a no-op with no aliases", func(t *testing.T) {
|
||
t.Parallel()
|
||
snapshot := &SnapshotFS{
|
||
toPath: toPath,
|
||
nodeModulesRealpathAliases: nil,
|
||
}
|
||
|
||
change := FileChangeSummary{}
|
||
change.Changed.Add("file:///some/file.ts")
|
||
|
||
expanded := snapshot.expandRealpathAliases(change)
|
||
assert.Equal(t, expanded.Changed.Len(), 1)
|
||
assert.Assert(t, expanded.Changed.Has("file:///some/file.ts"))
|
||
})
|
||
|
||
t.Run("markDirtyFiles invalidates symlinked file via realpath event", func(t *testing.T) {
|
||
t.Parallel()
|
||
testFS := vfstest.FromMap(map[string]any{
|
||
"/project/node_modules/mylib": vfstest.Symlink("/packages/mylib"),
|
||
"/packages/mylib/package.json": `{"name": "mylib", "main": "index.js"}`,
|
||
}, false)
|
||
|
||
// Build first snapshot — read the symlinked file.
|
||
builder1 := newSnapshotFSBuilder(
|
||
testFS,
|
||
make(map[tspath.Path]*Overlay),
|
||
make(map[tspath.Path]*Overlay),
|
||
make(map[tspath.Path]*diskFile),
|
||
make(map[tspath.Path]dirty.CloneableMap[tspath.Path, string]),
|
||
nil,
|
||
lsproto.PositionEncodingKindUTF16,
|
||
toPath,
|
||
)
|
||
fh := builder1.GetFile("/project/node_modules/mylib/package.json")
|
||
assert.Assert(t, fh != nil)
|
||
assert.Equal(t, fh.Content(), `{"name": "mylib", "main": "index.js"}`)
|
||
snapshot1, _ := builder1.Finalize()
|
||
|
||
// Modify the real file on disk.
|
||
err := testFS.WriteFile("/packages/mylib/package.json", `{"name": "mylib"}`)
|
||
assert.NilError(t, err)
|
||
|
||
// Build second snapshot — simulate realpath change event, expanded via aliases.
|
||
builder2 := newSnapshotFSBuilder(
|
||
testFS,
|
||
make(map[tspath.Path]*Overlay),
|
||
make(map[tspath.Path]*Overlay),
|
||
snapshot1.diskFiles,
|
||
snapshot1.diskDirectories,
|
||
snapshot1.nodeModulesRealpathAliases,
|
||
lsproto.PositionEncodingKindUTF16,
|
||
toPath,
|
||
)
|
||
|
||
change := FileChangeSummary{}
|
||
change.Changed.Add("file:///packages/mylib/package.json")
|
||
|
||
// Expand the realpath event to include the symlink path.
|
||
change = snapshot1.expandRealpathAliases(change)
|
||
// Now mark dirty — should find the file under the symlink key.
|
||
builder2.markDirtyFiles(change)
|
||
|
||
// Trigger reload by reading the file (simulates program construction).
|
||
fh = builder2.GetFile("/project/node_modules/mylib/package.json")
|
||
assert.Assert(t, fh != nil)
|
||
assert.Equal(t, fh.Content(), `{"name": "mylib"}`, "builder should serve updated content after dirty marking")
|
||
|
||
snapshot2, _ := builder2.Finalize()
|
||
|
||
// The file should have been reloaded with new content.
|
||
file, ok := snapshot2.diskFiles[tspath.Path("/project/node_modules/mylib/package.json")]
|
||
assert.Assert(t, ok, "file should still be in diskFiles")
|
||
assert.Equal(t, file.Content(), `{"name": "mylib"}`, "content should be updated")
|
||
})
|
||
|
||
t.Run("alias clone isolation between snapshots", func(t *testing.T) {
|
||
t.Parallel()
|
||
testFS := vfstest.FromMap(map[string]any{
|
||
"/project/node_modules/mylib": vfstest.Symlink("/packages/mylib"),
|
||
"/project/node_modules/other": vfstest.Symlink("/packages/other"),
|
||
"/packages/mylib/package.json": `{"name": "mylib"}`,
|
||
"/packages/other/package.json": `{"name": "other"}`,
|
||
}, false)
|
||
|
||
// Build first snapshot — read only mylib.
|
||
builder1 := newSnapshotFSBuilder(
|
||
testFS,
|
||
make(map[tspath.Path]*Overlay),
|
||
make(map[tspath.Path]*Overlay),
|
||
make(map[tspath.Path]*diskFile),
|
||
make(map[tspath.Path]dirty.CloneableMap[tspath.Path, string]),
|
||
nil,
|
||
lsproto.PositionEncodingKindUTF16,
|
||
toPath,
|
||
)
|
||
builder1.GetFile("/project/node_modules/mylib/package.json")
|
||
snapshot1, _ := builder1.Finalize()
|
||
|
||
// Build second snapshot — also read other.
|
||
builder2 := newSnapshotFSBuilder(
|
||
testFS,
|
||
make(map[tspath.Path]*Overlay),
|
||
make(map[tspath.Path]*Overlay),
|
||
snapshot1.diskFiles,
|
||
snapshot1.diskDirectories,
|
||
snapshot1.nodeModulesRealpathAliases,
|
||
lsproto.PositionEncodingKindUTF16,
|
||
toPath,
|
||
)
|
||
builder2.GetFile("/project/node_modules/other/package.json")
|
||
snapshot2, _ := builder2.Finalize()
|
||
|
||
// snapshot1 should only have mylib alias.
|
||
_, ok := snapshot1.nodeModulesRealpathAliases[tspath.Path("/packages/mylib/package.json")]
|
||
assert.Assert(t, ok, "snapshot1 should have mylib alias")
|
||
_, ok = snapshot1.nodeModulesRealpathAliases[tspath.Path("/packages/other/package.json")]
|
||
assert.Assert(t, !ok, "snapshot1 should NOT have other alias — it was added in a later snapshot")
|
||
|
||
// snapshot2 should have both.
|
||
_, ok = snapshot2.nodeModulesRealpathAliases[tspath.Path("/packages/mylib/package.json")]
|
||
assert.Assert(t, ok, "snapshot2 should have mylib alias")
|
||
_, ok = snapshot2.nodeModulesRealpathAliases[tspath.Path("/packages/other/package.json")]
|
||
assert.Assert(t, ok, "snapshot2 should have other alias")
|
||
})
|
||
|
||
t.Run("adding symlink to inherited realpath key does not mutate previous snapshot", func(t *testing.T) {
|
||
t.Parallel()
|
||
testFS := vfstest.FromMap(map[string]any{
|
||
"/project/node_modules/mylib": vfstest.Symlink("/packages/mylib"),
|
||
"/project/node_modules/alias": vfstest.Symlink("/packages/mylib"),
|
||
"/packages/mylib/package.json": `{"name": "mylib"}`,
|
||
}, false)
|
||
|
||
// Snapshot 1: read via one symlink only.
|
||
builder1 := newSnapshotFSBuilder(
|
||
testFS,
|
||
make(map[tspath.Path]*Overlay),
|
||
make(map[tspath.Path]*Overlay),
|
||
make(map[tspath.Path]*diskFile),
|
||
make(map[tspath.Path]dirty.CloneableMap[tspath.Path, string]),
|
||
nil,
|
||
lsproto.PositionEncodingKindUTF16,
|
||
toPath,
|
||
)
|
||
builder1.GetFile("/project/node_modules/mylib/package.json")
|
||
snapshot1, _ := builder1.Finalize()
|
||
|
||
// Verify snapshot1 has exactly one alias for the realpath.
|
||
aliases1, ok := snapshot1.nodeModulesRealpathAliases[tspath.Path("/packages/mylib/package.json")]
|
||
assert.Assert(t, ok)
|
||
assert.Equal(t, aliases1.paths.Len(), 1)
|
||
assert.Assert(t, aliases1.paths.Has(tspath.Path("/project/node_modules/mylib/package.json")))
|
||
|
||
// Snapshot 2: read via the SECOND symlink, which maps to the same realpath.
|
||
// This exercises the case where LoadOrStore finds the key in the base map
|
||
// and must clone-on-write rather than mutating the shared set.
|
||
builder2 := newSnapshotFSBuilder(
|
||
testFS,
|
||
make(map[tspath.Path]*Overlay),
|
||
make(map[tspath.Path]*Overlay),
|
||
snapshot1.diskFiles,
|
||
snapshot1.diskDirectories,
|
||
snapshot1.nodeModulesRealpathAliases,
|
||
lsproto.PositionEncodingKindUTF16,
|
||
toPath,
|
||
)
|
||
builder2.GetFile("/project/node_modules/alias/package.json")
|
||
snapshot2, _ := builder2.Finalize()
|
||
|
||
// Snapshot 2 should have both symlinks.
|
||
aliases2, ok := snapshot2.nodeModulesRealpathAliases[tspath.Path("/packages/mylib/package.json")]
|
||
assert.Assert(t, ok)
|
||
assert.Equal(t, aliases2.paths.Len(), 2)
|
||
assert.Assert(t, aliases2.paths.Has(tspath.Path("/project/node_modules/mylib/package.json")))
|
||
assert.Assert(t, aliases2.paths.Has(tspath.Path("/project/node_modules/alias/package.json")))
|
||
|
||
// Snapshot 1 must NOT have been mutated — it should still have only one alias.
|
||
assert.Equal(t, aliases1.paths.Len(), 1, "snapshot1 alias set must not be mutated by snapshot2")
|
||
assert.Assert(t, !aliases1.paths.Has(tspath.Path("/project/node_modules/alias/package.json")),
|
||
"snapshot1 must not contain alias added in snapshot2")
|
||
})
|
||
}
|
||
|
||
func TestExpandAndFilterWatchEvents(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
toPath := func(fileName string) tspath.Path {
|
||
return tspath.Path(fileName)
|
||
}
|
||
|
||
newBuilder := func(testFS vfs.FS) *snapshotFSBuilder {
|
||
return newSnapshotFSBuilder(
|
||
testFS,
|
||
make(map[tspath.Path]*Overlay),
|
||
make(map[tspath.Path]*Overlay),
|
||
make(map[tspath.Path]*diskFile),
|
||
make(map[tspath.Path]dirty.CloneableMap[tspath.Path, string]),
|
||
nil,
|
||
lsproto.PositionEncodingKindUTF16,
|
||
toPath,
|
||
)
|
||
}
|
||
|
||
t.Run("preserves node_modules directory deletion even when untracked", func(t *testing.T) {
|
||
t.Parallel()
|
||
// node_modules package files are read transiently and never tracked in
|
||
// diskDirectories, so a node_modules directory deletion can neither be
|
||
// expanded nor matched by extension. It must still be preserved.
|
||
builder := newBuilder(vfstest.FromMap(map[string]string{
|
||
"/project/index.ts": "export const x = 1;",
|
||
}, false))
|
||
|
||
change := FileChangeSummary{}
|
||
change.Deleted.Add("file:///project/node_modules")
|
||
|
||
expanded := builder.expandAndFilterWatchEvents(change)
|
||
assert.Assert(t, expanded.Deleted.Has("file:///project/node_modules"),
|
||
"bare node_modules directory deletion should be preserved")
|
||
})
|
||
|
||
t.Run("preserves deletion of a package directory inside node_modules", func(t *testing.T) {
|
||
t.Parallel()
|
||
builder := newBuilder(vfstest.FromMap(map[string]string{
|
||
"/project/index.ts": "export const x = 1;",
|
||
}, false))
|
||
|
||
change := FileChangeSummary{}
|
||
change.Deleted.Add("file:///project/node_modules/@scope/pkg")
|
||
|
||
expanded := builder.expandAndFilterWatchEvents(change)
|
||
assert.Assert(t, expanded.Deleted.Has("file:///project/node_modules/@scope/pkg"),
|
||
"package directory deletion inside node_modules should be preserved")
|
||
})
|
||
|
||
t.Run("drops irrelevant untracked deletion outside node_modules", func(t *testing.T) {
|
||
t.Parallel()
|
||
builder := newBuilder(vfstest.FromMap(map[string]string{
|
||
"/project/index.ts": "export const x = 1;",
|
||
}, false))
|
||
|
||
change := FileChangeSummary{}
|
||
change.Deleted.Add("file:///project/build")
|
||
|
||
expanded := builder.expandAndFilterWatchEvents(change)
|
||
assert.Equal(t, expanded.Deleted.Len(), 0,
|
||
"untracked non-node_modules directory deletion should be dropped")
|
||
})
|
||
|
||
t.Run("expands tracked directory deletion into file deletions", func(t *testing.T) {
|
||
t.Parallel()
|
||
existingDiskFiles := map[tspath.Path]*diskFile{
|
||
tspath.Path("/src/foo.ts"): newDiskFile("/src/foo.ts", "const foo = 1;"),
|
||
}
|
||
existingDirs := map[tspath.Path]dirty.CloneableMap[tspath.Path, string]{
|
||
tspath.Path("/"): {tspath.Path("/src"): "src"},
|
||
tspath.Path("/src"): {tspath.Path("/src/foo.ts"): "foo.ts"},
|
||
}
|
||
builder := newSnapshotFSBuilder(
|
||
vfstest.FromMap(map[string]string{"/src/foo.ts": "const foo = 1;"}, false),
|
||
make(map[tspath.Path]*Overlay),
|
||
make(map[tspath.Path]*Overlay),
|
||
existingDiskFiles,
|
||
existingDirs,
|
||
nil,
|
||
lsproto.PositionEncodingKindUTF16,
|
||
toPath,
|
||
)
|
||
|
||
change := FileChangeSummary{}
|
||
change.Deleted.Add("file:///src")
|
||
|
||
expanded := builder.expandAndFilterWatchEvents(change)
|
||
assert.Assert(t, expanded.Deleted.Has("file:///src/foo.ts"),
|
||
"tracked directory deletion should expand to contained file deletions")
|
||
assert.Assert(t, !expanded.Deleted.Has("file:///src"),
|
||
"the directory URI itself should be replaced by its files")
|
||
})
|
||
}
|