blob: 8ed97295e9847184dd3bf3cf9bd7ee6e7e0277be [file] [log] [blame]
// Copyright 2025 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package filewatcher_test
import (
"os"
"path/filepath"
"runtime"
"slices"
"testing"
"time"
"golang.org/x/tools/gopls/internal/filewatcher"
"golang.org/x/tools/gopls/internal/protocol"
"golang.org/x/tools/txtar"
)
func TestFileWatcher(t *testing.T) {
switch runtime.GOOS {
case "darwin", "linux", "windows":
default:
t.Skip("unsupported OS")
}
testCases := []struct {
name string
goos []string // if not empty, only run in these OS.
initWorkspace string
changes func(t *testing.T, root string)
expectedEvents []protocol.FileEvent
}{
{
name: "create file in darwin",
goos: []string{"darwin"},
initWorkspace: `
-- foo.go --
package foo
`,
changes: func(t *testing.T, root string) {
if err := os.WriteFile(filepath.Join(root, "bar.go"), []byte("package main"), 0644); err != nil {
t.Fatal(err)
}
},
expectedEvents: []protocol.FileEvent{
{URI: "bar.go", Type: protocol.Created},
},
},
{
name: "create file in linux & windows",
goos: []string{"linux", "windows"},
initWorkspace: `
-- foo.go --
package foo
`,
changes: func(t *testing.T, root string) {
if err := os.WriteFile(filepath.Join(root, "bar.go"), []byte("package main"), 0644); err != nil {
t.Fatal(err)
}
},
expectedEvents: []protocol.FileEvent{
{URI: "bar.go", Type: protocol.Created},
{URI: "bar.go", Type: protocol.Changed},
},
},
{
name: "modify file",
initWorkspace: `
-- foo.go --
package foo
`,
changes: func(t *testing.T, root string) {
if err := os.WriteFile(filepath.Join(root, "foo.go"), []byte("package main // modified"), 0644); err != nil {
t.Fatal(err)
}
},
expectedEvents: []protocol.FileEvent{
{URI: "foo.go", Type: protocol.Changed},
},
},
{
name: "delete file",
initWorkspace: `
-- foo.go --
package foo
-- bar.go --
package bar
`,
changes: func(t *testing.T, root string) {
if err := os.Remove(filepath.Join(root, "foo.go")); err != nil {
t.Fatal(err)
}
},
expectedEvents: []protocol.FileEvent{
{URI: "foo.go", Type: protocol.Deleted},
},
},
{
name: "rename file in linux & windows",
goos: []string{"linux", "windows"},
initWorkspace: `
-- foo.go --
package foo
`,
changes: func(t *testing.T, root string) {
if err := os.Rename(filepath.Join(root, "foo.go"), filepath.Join(root, "bar.go")); err != nil {
t.Fatal(err)
}
},
expectedEvents: []protocol.FileEvent{
{URI: "foo.go", Type: protocol.Deleted},
{URI: "bar.go", Type: protocol.Created},
},
},
{
name: "rename file in darwin",
goos: []string{"darwin"},
initWorkspace: `
-- foo.go --
package foo
`,
changes: func(t *testing.T, root string) {
if err := os.Rename(filepath.Join(root, "foo.go"), filepath.Join(root, "bar.go")); err != nil {
t.Fatal(err)
}
},
expectedEvents: []protocol.FileEvent{
{URI: "bar.go", Type: protocol.Created},
{URI: "foo.go", Type: protocol.Deleted},
},
},
{
name: "create directory",
initWorkspace: `
-- foo.go --
package foo
`,
changes: func(t *testing.T, root string) {
if err := os.Mkdir(filepath.Join(root, "bar"), 0755); err != nil {
t.Fatal(err)
}
},
expectedEvents: []protocol.FileEvent{
{URI: "bar", Type: protocol.Created},
},
},
{
name: "delete directory",
initWorkspace: `
-- foo/bar.go --
package foo
`,
changes: func(t *testing.T, root string) {
if err := os.RemoveAll(filepath.Join(root, "foo")); err != nil {
t.Fatal(err)
}
},
expectedEvents: []protocol.FileEvent{
// We only assert that the directory deletion event exists,
// because file system event behavior is inconsistent across
// platforms when deleting a non-empty directory.
// e.g. windows-amd64 may only emit a single dir removal event,
// freebsd-amd64 report dir removal before file removal,
// linux-amd64 report the reverse order.
// Therefore, the most reliable and cross-platform compatible
// signal is the deletion event for the directory itself.
{URI: "foo", Type: protocol.Deleted},
},
},
{
name: "rename directory in linux & windows",
goos: []string{"linux", "windows"},
initWorkspace: `
-- foo/bar.go --
package foo
`,
changes: func(t *testing.T, root string) {
if err := os.Rename(filepath.Join(root, "foo"), filepath.Join(root, "baz")); err != nil {
t.Fatal(err)
}
},
expectedEvents: []protocol.FileEvent{
{URI: "foo", Type: protocol.Deleted},
{URI: "baz", Type: protocol.Created},
},
},
{
name: "rename directory in darwin",
goos: []string{"darwin"},
initWorkspace: `
-- foo/bar.go --
package foo
`,
changes: func(t *testing.T, root string) {
if err := os.Rename(filepath.Join(root, "foo"), filepath.Join(root, "baz")); err != nil {
t.Fatal(err)
}
},
expectedEvents: []protocol.FileEvent{
{URI: "baz", Type: protocol.Created},
{URI: "foo", Type: protocol.Deleted},
},
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
if len(tt.goos) > 0 && !slices.Contains(tt.goos, runtime.GOOS) {
t.Skipf("skipping on %s", runtime.GOOS)
}
t.Parallel()
root := t.TempDir()
archive := txtar.Parse([]byte(tt.initWorkspace))
for _, f := range archive.Files {
path := filepath.Join(root, f.Name)
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, f.Data, 0644); err != nil {
t.Fatal(err)
}
}
matched := 0
foundAll := make(chan struct{})
var gots []protocol.FileEvent
handler := func(events []protocol.FileEvent, err error) {
if err != nil {
t.Errorf("error from watcher: %v", err)
}
gots = append(gots, events...)
// This verifies that the list of wanted events is a subsequence of
// the received events. It confirms not only that all wanted events
// are present, but also that their relative order is preserved.
for _, got := range events {
if matched == len(tt.expectedEvents) {
break
}
want := protocol.FileEvent{
URI: protocol.URIFromPath(filepath.Join(root, string(tt.expectedEvents[matched].URI))),
Type: tt.expectedEvents[matched].Type,
}
if want == got {
matched++
}
}
if matched == len(tt.expectedEvents) {
close(foundAll)
}
}
w, err := filewatcher.New(50*time.Millisecond, nil, handler)
if err != nil {
t.Fatal(err)
}
if err := w.WatchDir(root); err != nil {
t.Fatal(err)
}
if tt.changes != nil {
tt.changes(t, root)
}
select {
case <-foundAll:
case <-time.After(30 * time.Second):
if matched < len(tt.expectedEvents) {
t.Errorf("found %v matching events\nall want: %#v\nall got: %#v", matched, tt.expectedEvents, gots)
}
}
if err := w.Close(); err != nil {
t.Errorf("failed to close the file watcher: %v", err)
}
})
}
}