blob: 8ad80455829e2cce2a712e0fb092191e36b513f6 [file] [log] [blame]
package fsnotify
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/fsnotify/fsnotify/internal"
"github.com/fsnotify/fsnotify/internal/ztest"
)
// We wait a little bit after most commands; gives the system some time to sync
// things and makes things more consistent across platforms.
func eventSeparator() { time.Sleep(50 * time.Millisecond) }
func waitForEvents() { time.Sleep(500 * time.Millisecond) }
// To test the buffered watcher we run the tests twice in the CI: once as "go
// test" and once with FSNOTIFY_BUFFER set. This is a bit hacky, but saves
// having to refactor a lot of this code. Besides, running the tests in the CI
// more than once isn't a bad thing, since it helps catch flaky tests (should
// probably run it even more).
var testBuffered = func() uint {
s, ok := os.LookupEnv("FSNOTIFY_BUFFER")
if ok {
i, err := strconv.ParseUint(s, 0, 0)
if err != nil {
panic(fmt.Sprintf("FSNOTIFY_BUFFER: %s", err))
}
return uint(i)
}
return 0
}()
// newWatcher initializes an fsnotify Watcher instance.
func newWatcher(t *testing.T, add ...string) *Watcher {
t.Helper()
var (
w *Watcher
err error
)
if testBuffered > 0 {
w, err = NewBufferedWatcher(testBuffered)
} else {
w, err = NewWatcher()
}
if err != nil {
t.Fatalf("newWatcher: %s", err)
}
for _, a := range add {
err := w.Add(a)
if err != nil {
t.Fatalf("newWatcher: add %q: %s", a, err)
}
}
return w
}
// addWatch adds a watch for a directory
func addWatch(t *testing.T, w *Watcher, path ...string) {
t.Helper()
if len(path) < 1 {
t.Fatalf("addWatch: path must have at least one element: %s", path)
}
err := w.Add(join(path...))
if err != nil {
t.Fatalf("addWatch(%q): %s", join(path...), err)
}
}
// rmWatch removes a watch.
func rmWatch(t *testing.T, watcher *Watcher, path ...string) {
t.Helper()
if len(path) < 1 {
t.Fatalf("rmWatch: path must have at least one element: %s", path)
}
err := watcher.Remove(join(path...))
if err != nil {
t.Fatalf("rmWatch(%q): %s", join(path...), err)
}
}
const noWait = ""
func shouldWait(path ...string) bool {
// Take advantage of the fact that join skips empty parameters.
for _, p := range path {
if p == "" {
return false
}
}
return true
}
// Create n empty files with the prefix in the directory dir.
func createFiles(t *testing.T, dir, prefix string, n int, d time.Duration) int {
t.Helper()
if d == 0 {
d = 9 * time.Minute
}
fmtNum := func(n int) string {
s := fmt.Sprintf("%09d", n)
return s[:3] + "_" + s[3:6] + "_" + s[6:]
}
var (
max = time.After(d)
created int
)
for i := 0; i < n; i++ {
select {
case <-max:
t.Logf("createFiles: stopped at %s files because it took longer than %s", fmtNum(created), d)
return created
default:
path := join(dir, prefix+fmtNum(i))
fp, err := os.Create(path)
if err != nil {
t.Errorf("create failed for %s: %s", fmtNum(i), err)
continue
}
if err := fp.Close(); err != nil {
t.Errorf("close failed for %s: %s", fmtNum(i), err)
}
if err := os.Remove(path); err != nil {
t.Errorf("remove failed for %s: %s", fmtNum(i), err)
}
if i%10_000 == 0 {
t.Logf("createFiles: %s", fmtNum(i))
}
created++
}
}
return created
}
// mkdir
func mkdir(t *testing.T, path ...string) {
t.Helper()
if len(path) < 1 {
t.Fatalf("mkdir: path must have at least one element: %s", path)
}
err := os.Mkdir(join(path...), 0o0755)
if err != nil {
t.Fatalf("mkdir(%q): %s", join(path...), err)
}
if shouldWait(path...) {
eventSeparator()
}
}
// mkdir -p
func mkdirAll(t *testing.T, path ...string) {
t.Helper()
if len(path) < 1 {
t.Fatalf("mkdirAll: path must have at least one element: %s", path)
}
err := os.MkdirAll(join(path...), 0o0755)
if err != nil {
t.Fatalf("mkdirAll(%q): %s", join(path...), err)
}
if shouldWait(path...) {
eventSeparator()
}
}
// ln -s
func symlink(t *testing.T, target string, link ...string) {
t.Helper()
if len(link) < 1 {
t.Fatalf("symlink: link must have at least one element: %s", link)
}
err := os.Symlink(target, join(link...))
if err != nil {
t.Fatalf("symlink(%q, %q): %s", target, join(link...), err)
}
if shouldWait(link...) {
eventSeparator()
}
}
// mkfifo
func mkfifo(t *testing.T, path ...string) {
t.Helper()
if len(path) < 1 {
t.Fatalf("mkfifo: path must have at least one element: %s", path)
}
err := internal.Mkfifo(join(path...), 0o644)
if err != nil {
t.Fatalf("mkfifo(%q): %s", join(path...), err)
}
if shouldWait(path...) {
eventSeparator()
}
}
// mknod
func mknod(t *testing.T, dev int, path ...string) {
t.Helper()
if len(path) < 1 {
t.Fatalf("mknod: path must have at least one element: %s", path)
}
err := internal.Mknod(join(path...), 0o644, dev)
if err != nil {
t.Fatalf("mknod(%d, %q): %s", dev, join(path...), err)
}
if shouldWait(path...) {
eventSeparator()
}
}
// echo > and echo >>
func echoAppend(t *testing.T, data string, path ...string) { t.Helper(); echo(t, false, data, path...) }
func echoTrunc(t *testing.T, data string, path ...string) { t.Helper(); echo(t, true, data, path...) }
func echo(t *testing.T, trunc bool, data string, path ...string) {
n := "echoAppend"
if trunc {
n = "echoTrunc"
}
t.Helper()
if len(path) < 1 {
t.Fatalf("%s: path must have at least one element: %s", n, path)
}
err := func() error {
var (
fp *os.File
err error
)
if trunc {
fp, err = os.Create(join(path...))
} else {
fp, err = os.OpenFile(join(path...), os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
}
if err != nil {
return err
}
if err := fp.Sync(); err != nil {
return err
}
if shouldWait(path...) {
eventSeparator()
}
if _, err := fp.WriteString(data); err != nil {
return err
}
if err := fp.Sync(); err != nil {
return err
}
if shouldWait(path...) {
eventSeparator()
}
return fp.Close()
}()
if err != nil {
t.Fatalf("%s(%q): %s", n, join(path...), err)
}
}
// touch
func touch(t *testing.T, path ...string) {
t.Helper()
if len(path) < 1 {
t.Fatalf("touch: path must have at least one element: %s", path)
}
fp, err := os.Create(join(path...))
if err != nil {
t.Fatalf("touch(%q): %s", join(path...), err)
}
err = fp.Close()
if err != nil {
t.Fatalf("touch(%q): %s", join(path...), err)
}
if shouldWait(path...) {
eventSeparator()
}
}
// mv
func mv(t *testing.T, src string, dst ...string) {
t.Helper()
if len(dst) < 1 {
t.Fatalf("mv: dst must have at least one element: %s", dst)
}
err := os.Rename(src, join(dst...))
if err != nil {
t.Fatalf("mv(%q, %q): %s", src, join(dst...), err)
}
if shouldWait(dst...) {
eventSeparator()
}
}
// rm
func rm(t *testing.T, path ...string) {
t.Helper()
if len(path) < 1 {
t.Fatalf("rm: path must have at least one element: %s", path)
}
err := os.Remove(join(path...))
if err != nil {
t.Fatalf("rm(%q): %s", join(path...), err)
}
if shouldWait(path...) {
eventSeparator()
}
}
// rm -r
func rmAll(t *testing.T, path ...string) {
t.Helper()
if len(path) < 1 {
t.Fatalf("rmAll: path must have at least one element: %s", path)
}
err := os.RemoveAll(join(path...))
if err != nil {
t.Fatalf("rmAll(%q): %s", join(path...), err)
}
if shouldWait(path...) {
eventSeparator()
}
}
// cat
func cat(t *testing.T, path ...string) {
t.Helper()
if len(path) < 1 {
t.Fatalf("cat: path must have at least one element: %s", path)
}
_, err := os.ReadFile(join(path...))
if err != nil {
t.Fatalf("cat(%q): %s", join(path...), err)
}
if shouldWait(path...) {
eventSeparator()
}
}
// chmod
func chmod(t *testing.T, mode fs.FileMode, path ...string) {
t.Helper()
if len(path) < 1 {
t.Fatalf("chmod: path must have at least one element: %s", path)
}
err := os.Chmod(join(path...), mode)
if err != nil {
t.Fatalf("chmod(%q): %s", join(path...), err)
}
if shouldWait(path...) {
eventSeparator()
}
}
// Collect all events in an array.
//
// w := newCollector(t)
// w.collect(r)
//
// .. do stuff ..
//
// events := w.stop(t)
type eventCollector struct {
w *Watcher
e Events
mu sync.Mutex
done chan struct{}
}
func newCollector(t *testing.T, add ...string) *eventCollector {
return &eventCollector{
w: newWatcher(t, add...),
done: make(chan struct{}),
e: make(Events, 0, 8),
}
}
// stop collecting events and return what we've got.
func (w *eventCollector) stop(t *testing.T) Events {
return w.stopWait(t, time.Second)
}
func (w *eventCollector) stopWait(t *testing.T, waitFor time.Duration) Events {
waitForEvents()
go func() {
err := w.w.Close()
if err != nil {
t.Error(err)
}
}()
select {
case <-time.After(waitFor):
t.Fatalf("event stream was not closed after %s", waitFor)
case <-w.done:
}
w.mu.Lock()
defer w.mu.Unlock()
return w.e
}
// Get all events we've found up to now and clear the event buffer.
func (w *eventCollector) events(t *testing.T) Events {
w.mu.Lock()
defer w.mu.Unlock()
e := make(Events, len(w.e))
copy(e, w.e)
w.e = make(Events, 0, 16)
return e
}
// Start collecting events.
func (w *eventCollector) collect(t *testing.T) {
go func() {
for {
select {
case e, ok := <-w.w.Errors:
if !ok {
w.done <- struct{}{}
return
}
t.Error(e)
w.done <- struct{}{}
return
case e, ok := <-w.w.Events:
if !ok {
w.done <- struct{}{}
return
}
w.mu.Lock()
w.e = append(w.e, e)
w.mu.Unlock()
}
}
}()
}
type Events []Event
func (e Events) String() string {
b := new(strings.Builder)
for i, ee := range e {
if i > 0 {
b.WriteString("\n")
}
if ee.renamedFrom != "" {
fmt.Fprintf(b, "%-8s %s ← %s", ee.Op.String(), filepath.ToSlash(ee.Name), filepath.ToSlash(ee.renamedFrom))
} else {
fmt.Fprintf(b, "%-8s %s", ee.Op.String(), filepath.ToSlash(ee.Name))
}
}
return b.String()
}
func (e Events) TrimPrefix(prefix string) Events {
for i := range e {
if e[i].Name == prefix {
e[i].Name = "/"
} else {
e[i].Name = strings.TrimPrefix(e[i].Name, prefix)
}
if e[i].renamedFrom == prefix {
e[i].renamedFrom = "/"
} else {
e[i].renamedFrom = strings.TrimPrefix(e[i].renamedFrom, prefix)
}
}
return e
}
func (e Events) copy() Events {
cp := make(Events, len(e))
copy(cp, e)
return cp
}
// Create a new Events list from a string; for example:
//
// CREATE path
// CREATE|WRITE path
//
// Every event is one line, and any whitespace between the event and path are
// ignored. The path can optionally be surrounded in ". Anything after a "#" is
// ignored.
//
// Platform-specific tests can be added after GOOS:
//
// # Tested if nothing else matches
// CREATE path
//
// # Windows-specific test.
// windows:
// WRITE path
//
// You can specify multiple platforms with a comma (e.g. "windows, linux:").
// "kqueue" is a shortcut for all kqueue systems (BSD, macOS).
func newEvents(t *testing.T, s string) Events {
t.Helper()
var (
lines = strings.Split(s, "\n")
groups = []string{""}
events = make(map[string]Events)
)
for no, line := range lines {
if i := strings.IndexByte(line, '#'); i > -1 {
line = line[:i]
}
line = strings.TrimSpace(line)
if line == "" {
continue
}
if strings.HasSuffix(line, ":") {
groups = strings.Split(strings.TrimRight(line, ":"), ",")
for i := range groups {
groups[i] = strings.TrimSpace(groups[i])
}
continue
}
fields := strings.Fields(line)
if len(fields) != 2 && len(fields) != 4 {
if strings.ToLower(fields[0]) == "empty" || strings.ToLower(fields[0]) == "no-events" {
for _, g := range groups {
events[g] = Events{}
}
continue
}
t.Fatalf("newEvents: line %d: needs 2 or 4 fields: %s", no+1, line)
}
var op Op
for _, ee := range strings.Split(fields[0], "|") {
switch strings.ToUpper(ee) {
case "CREATE":
op |= Create
case "WRITE":
op |= Write
case "REMOVE":
op |= Remove
case "RENAME":
op |= Rename
case "CHMOD":
op |= Chmod
case "OPEN":
op |= xUnportableOpen
case "READ":
op |= xUnportableRead
case "CLOSE_WRITE":
op |= xUnportableCloseWrite
case "CLOSE_READ":
op |= xUnportableCloseRead
default:
t.Fatalf("newEvents: line %d has unknown event %q: %s", no+1, ee, line)
}
}
var from string
if len(fields) > 2 {
if fields[2] != "←" {
t.Fatalf("newEvents: line %d: invalid format: %s", no+1, line)
}
from = strings.Trim(fields[3], `"`)
}
if !supportsRename() {
from = ""
}
for _, g := range groups {
events[g] = append(events[g], Event{Name: strings.Trim(fields[1], `"`), renamedFrom: from, Op: op})
}
}
if e, ok := events[runtime.GOOS]; ok {
return e
}
switch runtime.GOOS {
// kqueue shortcut
case "freebsd", "netbsd", "openbsd", "dragonfly", "darwin":
if e, ok := events["kqueue"]; ok {
return e
}
// fen shortcut
case "solaris", "illumos":
if e, ok := events["fen"]; ok {
return e
}
}
return events[""]
}
func cmpEvents(t *testing.T, tmp string, have, want Events) {
t.Helper()
have = have.TrimPrefix(tmp)
haveSort, wantSort := have.copy(), want.copy()
sort.Slice(haveSort, func(i, j int) bool {
return haveSort[i].String() > haveSort[j].String()
})
sort.Slice(wantSort, func(i, j int) bool {
return wantSort[i].String() > wantSort[j].String()
})
if haveSort.String() != wantSort.String() {
b := new(strings.Builder)
b.WriteString(strings.TrimSpace(ztest.Diff(indent(haveSort), indent(wantSort))))
t.Errorf("\nhave:\n%s\nwant:\n%s\ndiff:\n%s", indent(have), indent(want), indent(b))
}
}
func indent(s fmt.Stringer) string {
return "\t" + strings.ReplaceAll(s.String(), "\n", "\n\t")
}
var join = filepath.Join
func isKqueue() bool {
switch runtime.GOOS {
case "darwin", "freebsd", "openbsd", "netbsd", "dragonfly":
return true
}
return false
}
func isSolaris() bool {
switch runtime.GOOS {
case "illumos", "solaris":
return true
}
return false
}
func supportsRecurse(t *testing.T) {
switch runtime.GOOS {
case "windows", "linux":
// Run test.
default:
t.Skip("recursion not yet supported on " + runtime.GOOS)
}
}
func supportsFilter(t *testing.T) {
switch runtime.GOOS {
case "linux":
// Run test.
default:
t.Skip("withOps() not yet supported on " + runtime.GOOS)
}
}
func supportsRename() bool {
switch runtime.GOOS {
case "linux", "windows":
return true
default:
return false
}
}
func supportsNofollow(t *testing.T) {
switch runtime.GOOS {
case "linux":
// Run test.
default:
t.Skip("withNoFollow() not yet supported on " + runtime.GOOS)
}
}
func tmppath(tmp, s string) string {
if len(s) == 0 {
return ""
}
if !strings.HasPrefix(s, "./") {
return filepath.Join(tmp, s)
}
// Needed for creating relative links. Support that only with explicit "./"
// – otherwise too easy to forget leading "/" and create files outside of
// the tmp dir.
return s
}
type command struct {
line int
cmd string
args []string
}
func parseScript(t *testing.T, in string) {
var (
lines = strings.Split(in, "\n")
cmds = make([]command, 0, 8)
readW bool
want string
tmp = t.TempDir()
)
for i, line := range lines {
line = strings.TrimSpace(line)
if line == "" || line[0] == '#' {
continue
}
if i := strings.IndexByte(line, '#'); i > -1 {
line = strings.TrimSpace(line[:i])
}
if line == "Output:" {
readW = true
continue
}
if readW {
want += line + "\n"
continue
}
cmd := command{line: i + 1, args: make([]string, 0, 4)}
var (
q bool
cur = make([]rune, 0, 16)
app = func() {
if len(cur) == 0 {
return
}
if cmd.cmd == "" {
cmd.cmd = string(cur)
} else {
cmd.args = append(cmd.args, string(cur))
}
cur = cur[:0]
}
)
for _, c := range line {
switch c {
case ' ', '\t':
if q {
cur = append(cur, c)
} else {
app()
}
case '"', '\'': // '
q = !q
default:
cur = append(cur, c)
}
}
app()
cmds = append(cmds, cmd)
}
var (
do = make([]func(), 0, len(cmds))
w = newCollector(t)
mustArg = func(c command, n int) {
if len(c.args) != n {
t.Fatalf("line %d: %q requires exactly %d argument (have %d: %q)",
c.line, c.cmd, n, len(c.args), c.args)
}
}
)
loop:
for _, c := range cmds {
c := c
//fmt.Printf("line %d: %q %q\n", c.line, c.cmd, c.args)
switch c.cmd {
case "skip", "require":
mustArg(c, 1)
switch c.args[0] {
case "op_all":
if runtime.GOOS != "linux" {
t.Skip("No op_all on this platform")
}
case "op_open":
if runtime.GOOS != "linux" {
t.Skip("No Open on this platform")
}
case "op_read":
if runtime.GOOS != "linux" {
t.Skip("No Read on this platform")
}
case "op_close_write":
if runtime.GOOS != "linux" {
t.Skip("No CloseWrite on this platform")
}
case "op_close_read":
if runtime.GOOS != "linux" {
t.Skip("No CloseRead on this platform")
}
case "always":
t.Skip()
case "symlink":
if !internal.HasPrivilegesForSymlink() {
t.Skipf("%s symlink: admin permissions required on Windows", c.cmd)
}
case "mkfifo":
if runtime.GOOS == "windows" {
t.Skip("No named pipes on Windows")
}
case "mknod":
if runtime.GOOS == "windows" {
t.Skip("No device nodes on Windows")
}
if isKqueue() {
// Don't want to use os/user to check uid, since that pulls
// in cgo by default and stuff that uses fsnotify won't be
// statically linked by default.
t.Skip("needs root on BSD")
}
if isSolaris() {
t.Skip(`"mknod fails with "not owner"`)
}
case "recurse":
supportsRecurse(t)
case "filter":
supportsFilter(t)
case "nofollow":
supportsNofollow(t)
case "windows":
if runtime.GOOS == "windows" {
t.Skip("Skipping on Windows")
}
case "netbsd":
if runtime.GOOS == "netbsd" {
t.Skip("Skipping on NetBSD")
}
case "openbsd":
if runtime.GOOS == "openbsd" {
t.Skip("Skipping on OpenBSD")
}
default:
t.Fatalf("line %d: unknown %s reason: %q", c.line, c.cmd, c.args[0])
}
//case "state":
// mustArg(c, 0)
// do = append(do, func() { eventSeparator(); fmt.Fprintln(os.Stderr); w.w.state(); fmt.Fprintln(os.Stderr) })
case "debug":
mustArg(c, 1)
switch c.args[0] {
case "1", "on", "true", "yes":
do = append(do, func() { debug = true })
case "0", "off", "false", "no":
do = append(do, func() { debug = false })
default:
t.Fatalf("line %d: unknown debug: %q", c.line, c.args[0])
}
case "stop":
mustArg(c, 0)
break loop
case "watch":
if len(c.args) < 1 {
t.Fatalf("line %d: %q requires at least %d arguments (have %d: %q)",
c.line, c.cmd, 1, len(c.args), c.args)
}
if len(c.args) == 1 {
do = append(do, func() { addWatch(t, w.w, tmppath(tmp, c.args[0])) })
continue
}
var follow addOpt
for i := range c.args {
if c.args[i] == "nofollow" || c.args[i] == "no-follow" {
c.args = append(c.args[:i], c.args[i+1:]...)
follow = withNoFollow()
break
}
}
var op Op
for _, o := range c.args[1:] {
switch strings.ToLower(o) {
default:
t.Fatalf("line %d: unknown: %q", c.line+1, o)
case "default":
op |= Create | Write | Remove | Rename | Chmod
case "create":
op |= Create
case "write":
op |= Write
case "remove":
op |= Remove
case "rename":
op |= Rename
case "chmod":
op |= Chmod
case "open":
op |= xUnportableOpen
case "read":
op |= xUnportableRead
case "close_write":
op |= xUnportableCloseWrite
case "close_read":
op |= xUnportableCloseRead
}
}
do = append(do, func() {
p := tmppath(tmp, c.args[0])
err := w.w.AddWith(p, withOps(op), follow)
if err != nil {
t.Fatalf("line %d: addWatch(%q): %s", c.line+1, p, err)
}
})
case "unwatch":
mustArg(c, 1)
do = append(do, func() { rmWatch(t, w.w, tmppath(tmp, c.args[0])) })
case "watchlist":
mustArg(c, 1)
n, err := strconv.ParseInt(c.args[0], 10, 0)
if err != nil {
t.Fatalf("line %d: %s", c.line, err)
}
do = append(do, func() {
wl := w.w.WatchList()
if l := int64(len(wl)); l != n {
t.Errorf("line %d: watchlist has %d entries, not %d\n%q", c.line, l, n, wl)
}
})
case "touch":
mustArg(c, 1)
do = append(do, func() { touch(t, tmppath(tmp, c.args[0])) })
case "mkdir":
recur := false
if len(c.args) == 2 && c.args[0] == "-p" {
recur, c.args = true, c.args[1:]
}
mustArg(c, 1)
if recur {
do = append(do, func() { mkdirAll(t, tmppath(tmp, c.args[0])) })
} else {
do = append(do, func() { mkdir(t, tmppath(tmp, c.args[0])) })
}
case "ln":
mustArg(c, 3)
if c.args[0] != "-s" {
t.Fatalf("line %d: only ln -s is supported", c.line)
}
do = append(do, func() { symlink(t, tmppath(tmp, c.args[1]), tmppath(tmp, c.args[2])) })
case "mkfifo":
mustArg(c, 1)
do = append(do, func() { mkfifo(t, tmppath(tmp, c.args[0])) })
case "mknod":
mustArg(c, 2)
n, err := strconv.ParseInt(c.args[0], 10, 0)
if err != nil {
t.Fatalf("line %d: %s", c.line, err)
}
do = append(do, func() { mknod(t, int(n), tmppath(tmp, c.args[1])) })
case "mv":
mustArg(c, 2)
do = append(do, func() { mv(t, tmppath(tmp, c.args[0]), tmppath(tmp, c.args[1])) })
case "rm":
recur := false
if len(c.args) == 2 && c.args[0] == "-r" {
recur, c.args = true, c.args[1:]
}
mustArg(c, 1)
if recur {
do = append(do, func() { rmAll(t, tmppath(tmp, c.args[0])) })
} else {
do = append(do, func() { rm(t, tmppath(tmp, c.args[0])) })
}
case "chmod":
mustArg(c, 2)
n, err := strconv.ParseUint(c.args[0], 8, 32)
if err != nil {
t.Fatalf("line %d: %s", c.line, err)
}
do = append(do, func() { chmod(t, fs.FileMode(n), tmppath(tmp, c.args[1])) })
case "cat":
mustArg(c, 1)
do = append(do, func() { cat(t, tmppath(tmp, c.args[0])) })
case "echo":
if len(c.args) < 2 || len(c.args) > 3 {
t.Fatalf("line %d: %q requires 2 or 3 arguments (have %d: %q)",
c.line, c.cmd, len(c.args), c.args)
}
var data, op, dst string
if len(c.args) == 2 { // echo foo >dst
data, op, dst = c.args[0], c.args[1][:1], c.args[1][1:]
if strings.HasPrefix(dst, ">") {
op, dst = op+dst[:1], dst[1:]
}
} else { // echo foo > dst
data, op, dst = c.args[0], c.args[1], c.args[2]
}
switch op {
case ">":
do = append(do, func() { echoTrunc(t, data, tmppath(tmp, dst)) })
case ">>":
do = append(do, func() { echoAppend(t, data, tmppath(tmp, dst)) })
default:
t.Fatalf("line %d: echo requires > (truncate) or >> (append): echo data >file", c.line)
}
case "sleep":
mustArg(c, 1)
n, err := strconv.ParseInt(strings.TrimRight(c.args[0], "ms"), 10, 0)
if err != nil {
t.Fatalf("line %d: %s", c.line, err)
}
do = append(do, func() { time.Sleep(time.Duration(n) * time.Millisecond) })
default:
t.Errorf("line %d: unknown command %q", c.line, c.cmd)
}
}
w.collect(t)
for _, d := range do {
d()
}
ev := w.stop(t)
cmpEvents(t, tmp, ev, newEvents(t, want))
}