806 lines
22 KiB
Go
806 lines
22 KiB
Go
package lspwatcher
|
|
|
|
import (
|
|
"errors"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/microsoft/typescript-go/internal/bundled"
|
|
"github.com/microsoft/typescript-go/internal/fswatch"
|
|
"github.com/microsoft/typescript-go/internal/lsp/lsproto"
|
|
"github.com/microsoft/typescript-go/internal/project/logging"
|
|
"github.com/microsoft/typescript-go/internal/tspath"
|
|
"github.com/microsoft/typescript-go/internal/vfs/osvfs"
|
|
)
|
|
|
|
func waitFor(t *testing.T, cond func() bool, msg string) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if cond() {
|
|
return
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
t.Fatalf("timed out waiting for %s", msg)
|
|
}
|
|
|
|
func TestWatcher_CreateChangeDelete(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
|
|
var (
|
|
mu sync.Mutex
|
|
batches [][]*lsproto.FileEvent
|
|
)
|
|
w := New(bundled.WrapFS(osvfs.FS()), func(changes []*lsproto.FileEvent) {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
batches = append(batches, changes)
|
|
}, logging.NewLogger(os.Stderr))
|
|
t.Cleanup(w.Close)
|
|
|
|
pattern := tspath.NormalizeSlashes(dir) + "/**/*"
|
|
kind := lsproto.WatchKindCreate | lsproto.WatchKindChange | lsproto.WatchKindDelete
|
|
if err := w.WatchFiles("test", []*lsproto.FileSystemWatcher{{
|
|
GlobPattern: lsproto.PatternOrRelativePattern{Pattern: &pattern},
|
|
Kind: &kind,
|
|
}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
time.Sleep(200 * time.Millisecond)
|
|
|
|
file := filepath.Join(dir, "a.ts")
|
|
if err := os.WriteFile(file, []byte("export {}"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
collected := func() []*lsproto.FileEvent {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
var all []*lsproto.FileEvent
|
|
for _, b := range batches {
|
|
all = append(all, b...)
|
|
}
|
|
return all
|
|
}
|
|
|
|
waitFor(t, func() bool {
|
|
for _, e := range collected() {
|
|
if e.Type == lsproto.FileChangeTypeChanged {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}, "update event")
|
|
|
|
if err := os.Remove(file); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
waitFor(t, func() bool {
|
|
for _, e := range collected() {
|
|
if e.Type == lsproto.FileChangeTypeDeleted {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}, "delete event")
|
|
|
|
if err := w.UnwatchFiles("test"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestWatcher_KindFilter(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
dirNorm := tspath.NormalizeSlashes(dir)
|
|
|
|
var (
|
|
mu sync.Mutex
|
|
got []*lsproto.FileEvent
|
|
)
|
|
backend := newFakeBackend()
|
|
w := newWithBackend(bundled.WrapFS(osvfs.FS()), backend, func(changes []*lsproto.FileEvent) {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
got = append(got, changes...)
|
|
}, logging.NewLogger(os.Stderr))
|
|
t.Cleanup(w.Close)
|
|
|
|
pattern := dirNorm + "/**/*"
|
|
kind := lsproto.WatchKindDelete
|
|
if err := w.WatchFiles("test", []*lsproto.FileSystemWatcher{{
|
|
GlobPattern: lsproto.PatternOrRelativePattern{Pattern: &pattern},
|
|
Kind: &kind,
|
|
}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
backend.emitAll([]fswatch.Event{
|
|
{Kind: fswatch.EventUpdate, Path: filepath.FromSlash(filepath.Join(dirNorm, "x.ts"))},
|
|
{Kind: fswatch.EventDelete, Path: filepath.FromSlash(filepath.Join(dirNorm, "x.ts"))},
|
|
}, nil)
|
|
|
|
waitFor(t, func() bool {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
for _, e := range got {
|
|
if e.Type == lsproto.FileChangeTypeDeleted {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}, "delete event")
|
|
|
|
mu.Lock()
|
|
for _, e := range got {
|
|
if e.Type != lsproto.FileChangeTypeDeleted {
|
|
t.Errorf("unexpected non-delete event: %+v", e)
|
|
}
|
|
}
|
|
mu.Unlock()
|
|
}
|
|
|
|
func TestRootFromGlob(t *testing.T) {
|
|
t.Parallel()
|
|
cases := []struct {
|
|
pattern string
|
|
want string
|
|
}{
|
|
{"/abs/path/**/*", "/abs/path"},
|
|
{"/abs/path/", "/abs/path"},
|
|
{"/abs/path/?.ts", "/abs/path"},
|
|
{"/abs/path/{a,b}/*", "/abs/path"},
|
|
}
|
|
for _, c := range cases {
|
|
if got := rootFromGlob(c.pattern); got != c.want {
|
|
t.Errorf("rootFromGlob(%q) = %q, want %q", c.pattern, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
type fakeBackend struct {
|
|
mu sync.Mutex
|
|
byDir map[string]fswatch.WatchCallback
|
|
closed map[string]int
|
|
optCount map[string]int
|
|
failDirs map[string]error
|
|
}
|
|
|
|
func newFakeBackend() *fakeBackend {
|
|
return &fakeBackend{
|
|
byDir: make(map[string]fswatch.WatchCallback),
|
|
closed: make(map[string]int),
|
|
optCount: make(map[string]int),
|
|
failDirs: make(map[string]error),
|
|
}
|
|
}
|
|
|
|
func (f *fakeBackend) WatchDirectory(dir string, fn fswatch.WatchCallback, opts ...fswatch.WatchOption) (io.Closer, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if err := f.failDirs[dir]; err != nil {
|
|
return nil, err
|
|
}
|
|
f.byDir[dir] = fn
|
|
f.optCount[dir] = len(opts)
|
|
return fakeWatch{closeFn: func() error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
delete(f.byDir, dir)
|
|
f.closed[dir]++
|
|
return nil
|
|
}}, nil
|
|
}
|
|
|
|
// watchedDirs returns the directories currently subscribed, for assertions.
|
|
func (f *fakeBackend) watchedDirs() []string {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
dirs := make([]string, 0, len(f.byDir))
|
|
for d := range f.byDir {
|
|
dirs = append(dirs, d)
|
|
}
|
|
return dirs
|
|
}
|
|
|
|
func (f *fakeBackend) isWatching(dir string) bool {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
_, ok := f.byDir[dir]
|
|
return ok
|
|
}
|
|
|
|
func (f *fakeBackend) emit(dir string, events []fswatch.Event, err error) {
|
|
f.mu.Lock()
|
|
cb := f.byDir[dir]
|
|
f.mu.Unlock()
|
|
if cb != nil {
|
|
cb(events, err)
|
|
}
|
|
}
|
|
|
|
func (f *fakeBackend) emitAll(events []fswatch.Event, err error) {
|
|
f.mu.Lock()
|
|
cbs := make([]fswatch.WatchCallback, 0, len(f.byDir))
|
|
for _, cb := range f.byDir {
|
|
cbs = append(cbs, cb)
|
|
}
|
|
f.mu.Unlock()
|
|
for _, cb := range cbs {
|
|
cb(events, err)
|
|
}
|
|
}
|
|
|
|
type fakeWatch struct{ closeFn func() error }
|
|
|
|
func (w fakeWatch) Close() error { return w.closeFn() }
|
|
|
|
func TestWatcher_BookkeepingAndOverflow(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
dirNorm := tspath.NormalizeSlashes(dir)
|
|
pattern := dirNorm + "/**/*"
|
|
|
|
fs := bundled.WrapFS(osvfs.FS())
|
|
backend := newFakeBackend()
|
|
var (
|
|
mu sync.Mutex
|
|
got []*lsproto.FileEvent
|
|
)
|
|
w := newWithBackend(fs, backend, func(changes []*lsproto.FileEvent) {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
got = append(got, changes...)
|
|
}, logging.NewLogger(os.Stderr))
|
|
|
|
if err := w.WatchFiles("id", []*lsproto.FileSystemWatcher{{
|
|
GlobPattern: lsproto.PatternOrRelativePattern{Pattern: &pattern},
|
|
}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := w.WatchFiles("id", []*lsproto.FileSystemWatcher{{
|
|
GlobPattern: lsproto.PatternOrRelativePattern{Pattern: &pattern},
|
|
}}); err == nil {
|
|
t.Fatal("expected duplicate-id error")
|
|
}
|
|
|
|
backend.emitAll([]fswatch.Event{
|
|
{Kind: fswatch.EventUpdate, Path: filepath.FromSlash(filepath.Join(dirNorm, "a.ts"))},
|
|
}, fswatch.ErrOverflow)
|
|
waitFor(t, func() bool {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
return len(got) > 0
|
|
}, "events after overflow")
|
|
|
|
if err := w.UnwatchFiles("missing"); err == nil {
|
|
t.Fatal("expected unknown-id error")
|
|
}
|
|
if err := w.UnwatchFiles("id"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := w.WatchFiles("id2", nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
w.Close()
|
|
if err := w.WatchFiles("id3", nil); err == nil {
|
|
t.Fatal("expected closed error")
|
|
}
|
|
}
|
|
|
|
func TestWatcher_NonRecursiveGlobIsNotRecursive(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
dirNorm := tspath.NormalizeSlashes(dir)
|
|
if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
subNorm := tspath.NormalizeSlashes(filepath.Join(dir, "sub"))
|
|
|
|
fs := bundled.WrapFS(osvfs.FS())
|
|
backend := newFakeBackend()
|
|
w := newWithBackend(fs, backend, func([]*lsproto.FileEvent) {}, logging.NewLogger(os.Stderr))
|
|
t.Cleanup(w.Close)
|
|
|
|
recursive := dirNorm + "/**/*"
|
|
nonRecursive := subNorm + "/*"
|
|
if err := w.WatchFiles("id", []*lsproto.FileSystemWatcher{
|
|
{GlobPattern: lsproto.PatternOrRelativePattern{Pattern: &recursive}},
|
|
{GlobPattern: lsproto.PatternOrRelativePattern{Pattern: &nonRecursive}},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
backend.mu.Lock()
|
|
defer backend.mu.Unlock()
|
|
if got := backend.optCount[dirNorm]; got != 1 {
|
|
t.Errorf("recursive glob %q: expected 1 watch option (WithRecursive), got %d", recursive, got)
|
|
}
|
|
if got := backend.optCount[subNorm]; got != 0 {
|
|
t.Errorf("non-recursive glob %q: expected 0 watch options, got %d", nonRecursive, got)
|
|
}
|
|
}
|
|
|
|
func TestWatcher_RealBackend_MissingThenCreate(t *testing.T) {
|
|
t.Parallel()
|
|
base := t.TempDir()
|
|
fs := bundled.WrapFS(osvfs.FS())
|
|
|
|
var (
|
|
mu sync.Mutex
|
|
batches [][]*lsproto.FileEvent
|
|
)
|
|
w := New(fs, func(changes []*lsproto.FileEvent) {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
batches = append(batches, changes)
|
|
}, logging.NewLogger(os.Stderr))
|
|
t.Cleanup(w.Close)
|
|
|
|
// Watch a directory that does not exist yet.
|
|
target := tspath.NormalizeSlashes(filepath.Join(base, "pkg"))
|
|
pattern := target + "/*"
|
|
kind := lsproto.WatchKindCreate | lsproto.WatchKindChange | lsproto.WatchKindDelete
|
|
if err := w.WatchFiles("test", []*lsproto.FileSystemWatcher{{
|
|
GlobPattern: lsproto.PatternOrRelativePattern{Pattern: &pattern},
|
|
Kind: &kind,
|
|
}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Give the ancestor watch time to install.
|
|
time.Sleep(200 * time.Millisecond)
|
|
|
|
// Create the target directory and a file inside it. The real backend's
|
|
// ancestor watch should fire, promote to the target, and
|
|
// the file should ultimately surface.
|
|
if err := os.MkdirAll(filepath.Join(base, "pkg"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(base, "pkg", "index.ts"), []byte("export {}"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
collected := func() []*lsproto.FileEvent {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
var all []*lsproto.FileEvent
|
|
for _, b := range batches {
|
|
all = append(all, b...)
|
|
}
|
|
return all
|
|
}
|
|
|
|
waitFor(t, func() bool {
|
|
for _, e := range collected() {
|
|
if strings.HasSuffix(string(e.Uri), "/pkg/index.ts") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}, "event for file created in a previously-missing directory")
|
|
}
|
|
|
|
func TestWatcher_MissingDirectoryTracksAncestor(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := bundled.WrapFS(osvfs.FS())
|
|
backend := newFakeBackend()
|
|
w := newWithBackend(fs, backend, func([]*lsproto.FileEvent) {}, logging.NewLogger(os.Stderr))
|
|
t.Cleanup(w.Close)
|
|
|
|
base := t.TempDir()
|
|
baseNorm := tspath.NormalizeSlashes(base)
|
|
target := tspath.NormalizeSlashes(filepath.Join(base, "pkg"))
|
|
pattern := target + "/*"
|
|
|
|
if err := w.WatchFiles("id", []*lsproto.FileSystemWatcher{{
|
|
GlobPattern: lsproto.PatternOrRelativePattern{Pattern: &pattern},
|
|
}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// A missing target directory installs an ancestor watch on the nearest
|
|
// existing ancestor (the base dir), not on the target.
|
|
if !backend.isWatching(baseNorm) {
|
|
t.Fatalf("expected ancestor watch on ancestor %q, watched: %v", baseNorm, backend.watchedDirs())
|
|
}
|
|
if dirs := backend.watchedDirs(); len(dirs) != 1 {
|
|
t.Fatalf("expected exactly one (ancestor) watch, got %v", dirs)
|
|
}
|
|
|
|
if err := w.UnwatchFiles("id"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if dirs := backend.watchedDirs(); len(dirs) != 0 {
|
|
t.Fatalf("expected all watches closed after unwatch, got %v", dirs)
|
|
}
|
|
}
|
|
|
|
func TestWatcher_MissingDirectoryPromotesOnCreate(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := bundled.WrapFS(osvfs.FS())
|
|
backend := newFakeBackend()
|
|
var (
|
|
mu sync.Mutex
|
|
got []*lsproto.FileEvent
|
|
)
|
|
w := newWithBackend(fs, backend, func(changes []*lsproto.FileEvent) {
|
|
mu.Lock()
|
|
got = append(got, changes...)
|
|
mu.Unlock()
|
|
}, logging.NewLogger(os.Stderr))
|
|
t.Cleanup(w.Close)
|
|
|
|
base := t.TempDir()
|
|
baseNorm := tspath.NormalizeSlashes(base)
|
|
target := tspath.NormalizeSlashes(filepath.Join(base, "pkg"))
|
|
pattern := target + "/*"
|
|
kind := lsproto.WatchKindCreate | lsproto.WatchKindChange | lsproto.WatchKindDelete
|
|
|
|
if err := w.WatchFiles("id", []*lsproto.FileSystemWatcher{{
|
|
GlobPattern: lsproto.PatternOrRelativePattern{Pattern: &pattern},
|
|
Kind: &kind,
|
|
}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Create the target directory with a file, then notify the ancestor watch.
|
|
if err := os.MkdirAll(filepath.Join(base, "pkg"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(base, "pkg", "index.ts"), []byte("export {}"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
backend.emit(baseNorm, []fswatch.Event{
|
|
{Kind: fswatch.EventUpdate, Path: filepath.Join(base, "pkg")},
|
|
}, nil)
|
|
|
|
waitFor(t, func() bool { return backend.isWatching(target) }, "promotion to target watch")
|
|
|
|
// Synthetic creates must cover the target dir and its immediate child so
|
|
// the session re-resolves files created before the watch was installed.
|
|
waitFor(t, func() bool {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
var sawDir, sawChild bool
|
|
for _, e := range got {
|
|
if e.Type != lsproto.FileChangeTypeCreated {
|
|
continue
|
|
}
|
|
s := string(e.Uri)
|
|
if strings.HasSuffix(s, "/pkg") {
|
|
sawDir = true
|
|
}
|
|
if strings.HasSuffix(s, "/pkg/index.ts") {
|
|
sawChild = true
|
|
}
|
|
}
|
|
return sawDir && sawChild
|
|
}, "synthetic create events for target and child")
|
|
}
|
|
|
|
func TestWatcher_MultiLevelDescend(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := bundled.WrapFS(osvfs.FS())
|
|
backend := newFakeBackend()
|
|
w := newWithBackend(fs, backend, func([]*lsproto.FileEvent) {}, logging.NewLogger(os.Stderr))
|
|
t.Cleanup(w.Close)
|
|
|
|
base := t.TempDir()
|
|
baseNorm := tspath.NormalizeSlashes(base)
|
|
target := tspath.NormalizeSlashes(filepath.Join(base, "a", "b", "c"))
|
|
pattern := target + "/*"
|
|
|
|
if err := w.WatchFiles("id", []*lsproto.FileSystemWatcher{{
|
|
GlobPattern: lsproto.PatternOrRelativePattern{Pattern: &pattern},
|
|
}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !backend.isWatching(baseNorm) {
|
|
t.Fatalf("expected initial ancestor watch on %q, got %v", baseNorm, backend.watchedDirs())
|
|
}
|
|
|
|
// Reveal one path component at a time; the ancestor watch should descend.
|
|
mkdirAndPath := func(rel string) string {
|
|
p := filepath.Join(base, rel)
|
|
if err := os.MkdirAll(p, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return tspath.NormalizeSlashes(p)
|
|
}
|
|
|
|
aDir := mkdirAndPath("a")
|
|
backend.emit(baseNorm, []fswatch.Event{{Kind: fswatch.EventUpdate, Path: filepath.Join(base, "a")}}, nil)
|
|
waitFor(t, func() bool { return backend.isWatching(aDir) }, "descend to a")
|
|
|
|
abDir := mkdirAndPath(filepath.Join("a", "b"))
|
|
backend.emit(aDir, []fswatch.Event{{Kind: fswatch.EventUpdate, Path: filepath.Join(base, "a", "b")}}, nil)
|
|
waitFor(t, func() bool { return backend.isWatching(abDir) }, "descend to a/b")
|
|
|
|
abcDir := mkdirAndPath(filepath.Join("a", "b", "c"))
|
|
backend.emit(abDir, []fswatch.Event{{Kind: fswatch.EventUpdate, Path: filepath.Join(base, "a", "b", "c")}}, nil)
|
|
waitFor(t, func() bool { return backend.isWatching(abcDir) }, "promote to target a/b/c")
|
|
}
|
|
|
|
func TestWatcher_AtomicTreeCreateRace(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := bundled.WrapFS(osvfs.FS())
|
|
backend := newFakeBackend()
|
|
w := newWithBackend(fs, backend, func([]*lsproto.FileEvent) {}, logging.NewLogger(os.Stderr))
|
|
t.Cleanup(w.Close)
|
|
|
|
base := t.TempDir()
|
|
baseNorm := tspath.NormalizeSlashes(base)
|
|
target := tspath.NormalizeSlashes(filepath.Join(base, "a", "b", "c"))
|
|
pattern := target + "/*"
|
|
|
|
if err := w.WatchFiles("id", []*lsproto.FileSystemWatcher{{
|
|
GlobPattern: lsproto.PatternOrRelativePattern{Pattern: &pattern},
|
|
}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// The whole tree appears at once (e.g. an extraction/symlink). A single
|
|
// notification on the base ancestor watch must descend all the way and
|
|
// promote to the target in one reconcile pass.
|
|
if err := os.MkdirAll(filepath.Join(base, "a", "b", "c"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
backend.emit(baseNorm, []fswatch.Event{{Kind: fswatch.EventUpdate, Path: filepath.Join(base, "a")}}, nil)
|
|
|
|
waitFor(t, func() bool { return backend.isWatching(target) }, "promote to target in one pass")
|
|
}
|
|
|
|
func TestWatcher_SyntheticCreateDepth(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, recursive := range []bool{false, true} {
|
|
t.Run(map[bool]string{false: "non-recursive", true: "recursive"}[recursive], func(t *testing.T) {
|
|
t.Parallel()
|
|
fs := bundled.WrapFS(osvfs.FS())
|
|
backend := newFakeBackend()
|
|
var (
|
|
mu sync.Mutex
|
|
got []*lsproto.FileEvent
|
|
)
|
|
w := newWithBackend(fs, backend, func(changes []*lsproto.FileEvent) {
|
|
mu.Lock()
|
|
got = append(got, changes...)
|
|
mu.Unlock()
|
|
}, logging.NewLogger(os.Stderr))
|
|
t.Cleanup(w.Close)
|
|
|
|
base := t.TempDir()
|
|
baseNorm := tspath.NormalizeSlashes(base)
|
|
target := tspath.NormalizeSlashes(filepath.Join(base, "pkg"))
|
|
kind := lsproto.WatchKindCreate | lsproto.WatchKindChange | lsproto.WatchKindDelete
|
|
var pattern string
|
|
if recursive {
|
|
pattern = target + "/**/*"
|
|
} else {
|
|
pattern = target + "/*"
|
|
}
|
|
|
|
if err := w.WatchFiles("id", []*lsproto.FileSystemWatcher{{
|
|
GlobPattern: lsproto.PatternOrRelativePattern{Pattern: &pattern},
|
|
Kind: &kind,
|
|
}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Materialize the target with a nested file under a subdirectory.
|
|
if err := os.MkdirAll(filepath.Join(base, "pkg", "sub"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(base, "pkg", "top.ts"), []byte("export {}"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(base, "pkg", "sub", "deep.ts"), []byte("export {}"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
backend.emit(baseNorm, []fswatch.Event{{Kind: fswatch.EventUpdate, Path: filepath.Join(base, "pkg")}}, nil)
|
|
|
|
waitFor(t, func() bool { return backend.isWatching(target) }, "promotion")
|
|
|
|
created := func() map[string]bool {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
m := map[string]bool{}
|
|
for _, e := range got {
|
|
if e.Type == lsproto.FileChangeTypeCreated {
|
|
m[string(e.Uri)] = true
|
|
}
|
|
}
|
|
return m
|
|
}
|
|
|
|
// Both modes must synthesize the immediate child.
|
|
waitFor(t, func() bool {
|
|
for s := range created() {
|
|
if strings.HasSuffix(s, "/pkg/top.ts") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}, "synthetic create for immediate child")
|
|
|
|
// Only the recursive watch should synthesize the deep descendant.
|
|
deadline := time.Now().Add(1 * time.Second)
|
|
sawDeep := func() bool {
|
|
for s := range created() {
|
|
if strings.HasSuffix(s, "/pkg/sub/deep.ts") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
if recursive {
|
|
for time.Now().Before(deadline) && !sawDeep() {
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
if !sawDeep() {
|
|
t.Errorf("recursive watch should synthesize deep descendant; got %v", created())
|
|
}
|
|
} else {
|
|
time.Sleep(300 * time.Millisecond) // allow any erroneous deep event to arrive
|
|
if sawDeep() {
|
|
t.Errorf("non-recursive watch must not synthesize deep descendant; got %v", created())
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWatcher_TerminatedFallsBackAndRecovers(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := bundled.WrapFS(osvfs.FS())
|
|
backend := newFakeBackend()
|
|
var (
|
|
mu sync.Mutex
|
|
got []*lsproto.FileEvent
|
|
)
|
|
w := newWithBackend(fs, backend, func(changes []*lsproto.FileEvent) {
|
|
mu.Lock()
|
|
got = append(got, changes...)
|
|
mu.Unlock()
|
|
}, logging.NewLogger(os.Stderr))
|
|
t.Cleanup(w.Close)
|
|
|
|
base := t.TempDir()
|
|
baseNorm := tspath.NormalizeSlashes(base)
|
|
target := tspath.NormalizeSlashes(filepath.Join(base, "pkg"))
|
|
if err := os.MkdirAll(filepath.Join(base, "pkg"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pattern := target + "/*"
|
|
kind := lsproto.WatchKindCreate | lsproto.WatchKindChange | lsproto.WatchKindDelete
|
|
|
|
if err := w.WatchFiles("id", []*lsproto.FileSystemWatcher{{
|
|
GlobPattern: lsproto.PatternOrRelativePattern{Pattern: &pattern},
|
|
Kind: &kind,
|
|
}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !backend.isWatching(target) {
|
|
t.Fatalf("expected target watch on %q, got %v", target, backend.watchedDirs())
|
|
}
|
|
|
|
// Delete the directory and deliver ErrWatchTerminated together with the
|
|
// directory's own delete event (as the real backends do).
|
|
if err := os.RemoveAll(filepath.Join(base, "pkg")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
backend.emit(target, []fswatch.Event{
|
|
{Kind: fswatch.EventDelete, Path: filepath.Join(base, "pkg")},
|
|
}, errors.Join(fswatch.ErrWatchTerminated, errors.New("removed")))
|
|
|
|
// The delete must be forwarded, and the watch must fall back to watching
|
|
// the ancestor.
|
|
waitFor(t, func() bool {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
for _, e := range got {
|
|
if e.Type == lsproto.FileChangeTypeDeleted && strings.HasSuffix(string(e.Uri), "/pkg") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}, "forwarded delete of terminated dir")
|
|
waitFor(t, func() bool { return backend.isWatching(baseNorm) && !backend.isWatching(target) }, "fallback to ancestor watch")
|
|
|
|
// Recreate the directory; the ancestor watch must promote back to target.
|
|
if err := os.MkdirAll(filepath.Join(base, "pkg"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
backend.emit(baseNorm, []fswatch.Event{{Kind: fswatch.EventUpdate, Path: filepath.Join(base, "pkg")}}, nil)
|
|
waitFor(t, func() bool { return backend.isWatching(target) }, "recovery to target watch after recreation")
|
|
}
|
|
|
|
func TestWatcher_GenuineFailureRollsBackForRetry(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := bundled.WrapFS(osvfs.FS())
|
|
backend := newFakeBackend()
|
|
w := newWithBackend(fs, backend, func([]*lsproto.FileEvent) {}, logging.NewLogger(os.Stderr))
|
|
t.Cleanup(w.Close)
|
|
|
|
dir := t.TempDir()
|
|
dirNorm := tspath.NormalizeSlashes(dir)
|
|
pattern := dirNorm + "/*"
|
|
|
|
// Inject a genuine backend failure for the existing directory.
|
|
backend.mu.Lock()
|
|
backend.failDirs[dirNorm] = errors.New("too many open files")
|
|
backend.mu.Unlock()
|
|
|
|
err := w.WatchFiles("id", []*lsproto.FileSystemWatcher{{
|
|
GlobPattern: lsproto.PatternOrRelativePattern{Pattern: &pattern},
|
|
}})
|
|
if err == nil {
|
|
t.Fatal("expected error from genuine backend failure")
|
|
}
|
|
|
|
// The id must have been rolled back so a retry can re-register cleanly
|
|
// (rather than hitting the duplicate-id error). Clear the injected failure
|
|
// to simulate the resource pressure easing on retry.
|
|
backend.mu.Lock()
|
|
delete(backend.failDirs, dirNorm)
|
|
backend.mu.Unlock()
|
|
|
|
if err := w.WatchFiles("id", []*lsproto.FileSystemWatcher{{
|
|
GlobPattern: lsproto.PatternOrRelativePattern{Pattern: &pattern},
|
|
}}); err != nil {
|
|
t.Fatalf("retry after rollback should succeed, got %v", err)
|
|
}
|
|
if !backend.isWatching(dirNorm) {
|
|
t.Fatalf("expected watch on %q after successful retry, got %v", dirNorm, backend.watchedDirs())
|
|
}
|
|
}
|
|
|
|
func TestWatcher_WatchTerminatedDoesNotDropEvents(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
dirNorm := tspath.NormalizeSlashes(dir)
|
|
fs := bundled.WrapFS(osvfs.FS())
|
|
backend := newFakeBackend()
|
|
var got []*lsproto.FileEvent
|
|
var mu sync.Mutex
|
|
w := newWithBackend(fs, backend, func(changes []*lsproto.FileEvent) {
|
|
mu.Lock()
|
|
got = append(got, changes...)
|
|
mu.Unlock()
|
|
}, logging.NewLogger(os.Stderr))
|
|
|
|
pattern := dirNorm + "/**/*"
|
|
if err := w.WatchFiles("id", []*lsproto.FileSystemWatcher{{
|
|
GlobPattern: lsproto.PatternOrRelativePattern{Pattern: &pattern},
|
|
}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
backend.emitAll([]fswatch.Event{
|
|
{Kind: fswatch.EventUpdate, Path: filepath.FromSlash(filepath.Join(dirNorm, "b.ts"))},
|
|
}, errors.Join(fswatch.ErrWatchTerminated, errors.New("simulated")))
|
|
|
|
waitFor(t, func() bool {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
return len(got) > 0
|
|
}, "events with watch-terminated error")
|
|
}
|