diff --git a/testing/fs/fs.go b/testing/fs/fs.go new file mode 100644 index 0000000..bbd3146 --- /dev/null +++ b/testing/fs/fs.go @@ -0,0 +1,103 @@ +// Licensed to Elasticsearch B.V. under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Elasticsearch B.V. licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package fs + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "unicode" + "unicode/utf8" +) + +// TempDir creates a temporary directory that will be +// removed if the tests passes. The temporary directory is +// created by joining all elements from path, with the sanitised +// test name. +// +// If path is empty, the temporary directory is created in os.TempDir. +// +// When tests are run with -v, the temporary directory absolute +// path will be logged. +func TempDir(t *testing.T, path ...string) string { + rootDir := filepath.Join(path...) + + if rootDir == "" { + rootDir = os.TempDir() + } + + rootDir, err := filepath.Abs(rootDir) + if err != nil { + t.Fatalf("cannot get absolute path: %s", err) + } + + // Logic copied with small modifications from + // the Go source code: testing/testing.go + folderName := t.Name() + mapper := func(r rune) rune { + if r < utf8.RuneSelf { + const allowed = "_-" + if '0' <= r && r <= '9' || + 'a' <= r && r <= 'z' || + 'A' <= r && r <= 'Z' { + return r + } + if strings.ContainsRune(allowed, r) { + return r + } + } else if unicode.IsLetter(r) || unicode.IsNumber(r) { + return r + } + return -1 + } + folderName = strings.Map(mapper, folderName) + + if err := os.MkdirAll(rootDir, 0o750); err != nil { + t.Fatalf("error making test dir: %s: %s", rootDir, err) + } + + tempDir, err := os.MkdirTemp(rootDir, folderName) + if err != nil { + t.Fatalf("failed to make temp directory: %s", err) + } + + cleanup := func() { + if !t.Failed() { + if err := os.RemoveAll(tempDir); err != nil { + // Ungly workaround Windows limitations + // Windows does not support the Interrup signal, so it might + // happen that Filebeat is still running, keeping it's registry + // file open, thus preventing the temporary folder from being + // removed. So we log the error and move on without failing the + // test + if runtime.GOOS == "windows" { + t.Logf("[WARN] Could not remove temporatry directory '%s': %s", tempDir, err) + } else { + t.Errorf("could not remove temp dir '%s': %s", tempDir, err) + } + } + } else { + t.Logf("Temporary directory saved: %s", tempDir) + } + } + t.Cleanup(cleanup) + + return tempDir +} diff --git a/testing/fs/fs_test.go b/testing/fs/fs_test.go new file mode 100644 index 0000000..95d09d8 --- /dev/null +++ b/testing/fs/fs_test.go @@ -0,0 +1,141 @@ +// Licensed to Elasticsearch B.V. under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Elasticsearch B.V. licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package fs + +import ( + "bufio" + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestTempDir(t *testing.T) { + t.Run("temp dir is created using os.TempDir", func(t *testing.T) { + tempDir := TempDir(t) + osTempDir := os.TempDir() + + baseDir := filepath.Dir(tempDir) + if baseDir != osTempDir { + t.Fatalf("expecting %q to be created on %q", tempDir, osTempDir) + } + }) + + t.Run("temp dir is created in the specified path", func(t *testing.T) { + path := []string{os.TempDir(), "foo", "bar"} + rootDir := filepath.Join(path...) + + // Pass each element here so we ensure they're correctly joined + tempDir := TempDir(t, path...) + + baseDir := filepath.Dir(tempDir) + if baseDir != rootDir { + t.Fatalf("expecting %q to be created on %q", tempDir, rootDir) + } + }) +} + +func TestTempDirIsKeptOnTestFailure(t *testing.T) { + rootDirEnv := "TEMPDIR_ROOTDIR" + tempFilename := "it-works" + if os.Getenv("INNER_TEST") == "1" { + rootDir := os.Getenv(rootDirEnv) + // We're inside the subprocess: + // 1. Read the root dir set as env var by the 'main' process + // 2. Use it when calling TempDir + // 3. Create a file just to ensure this actually run + tmpDir := TempDir(t, rootDir) + if err := os.WriteFile(filepath.Join(tmpDir, tempFilename), []byte("it works\n"), 0x666); err != nil { + t.Fatalf("cannot write temp file: %s", err) + } + + t.Fatal("keep the folder") + return + } + + rootDir := os.TempDir() + //nolint:gosec // This is intentionally running a subprocess + cmd := exec.CommandContext( + t.Context(), + os.Args[0], + fmt.Sprintf("-test.run=^%s$", + t.Name()), + "-test.v") + cmd.Env = append( + cmd.Env, + "INNER_TEST=1", + rootDirEnv+"="+rootDir, + ) + + out, cmdErr := cmd.CombinedOutput() + if cmdErr != nil { + // The test ran by cmd will fail and retrun 1 as the exit code. So we only + // print the error if the main test fails. + defer func() { + if t.Failed() { + t.Errorf( + "the test process returned an error (this is expected on a normal test execution): %s", + cmdErr) + t.Logf("Output of the subprocess:\n%s\n", string(out)) + } + }() + } + + var tempFolder string + sc := bufio.NewScanner(bytes.NewReader(out)) + for sc.Scan() { + txt := sc.Text() + // To extract the temp folder path we split txt in a way that the path + // is the 2nd element. + // The string we're using as reference: + // fs.go:97: Temporary directory saved: /tmp/TestTempDirIsKeptOnTestFailure2385221663 + if strings.Contains(txt, "Temporary directory saved:") { + split := strings.Split(txt, "Temporary directory saved: ") + if len(split) != 2 { + t.Fatalf("could not parse log file form test output, invalid format %q", txt) + } + tempFolder = split[1] + t.Cleanup(func() { + if t.Failed() { + t.Logf("Temp folder: %q", tempFolder) + } + }) + } + } + + stat, err := os.Stat(tempFolder) + if err != nil { + t.Fatalf("cannot stat created temp folder: %s", err) + } + + if !stat.IsDir() { + t.Errorf("%s must be a directory", tempFolder) + } + + if _, err = os.Stat(filepath.Join(tempFolder, tempFilename)); err != nil { + t.Fatalf("cannot stat file create by subprocess: %s", err) + } + + // Be nice and cleanup + if err := os.RemoveAll(tempFolder); err != nil { + t.Fatalf("cannot remove created folders: %s", err) + } +} diff --git a/testing/fs/logfile.go b/testing/fs/logfile.go new file mode 100644 index 0000000..032fc8f --- /dev/null +++ b/testing/fs/logfile.go @@ -0,0 +1,151 @@ +// Licensed to Elasticsearch B.V. under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Elasticsearch B.V. licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package fs + +import ( + "bufio" + "errors" + "fmt" + "io" + "os" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// LogFile wraps a *os.File and makes it more suitable for tests. +// Methods to search and wait for substrings in lines are provided, +// they keep track of the offset, ensuring ordering when +// when searching. +type LogFile struct { + *os.File + offset int64 +} + +// NewLogFile returns a new LogFile which wraps a os.File meant to be used +// for testing. Methods to search and wait for strings in the file are +// provided. 'dir' and 'pattern' are passed directly to os.CreateTemp. +// It is the callers responsibility to remove the file. To keep the file +// when the test fails, use [TempDir] to create a folder. +func NewLogFile(t testing.TB, dir, pattern string) *LogFile { + f, err := os.CreateTemp(dir, pattern) + if err != nil { + t.Fatalf("cannot create log file: %s", err) + } + + lf := &LogFile{ + File: f, + } + + t.Cleanup(func() { + if err := f.Sync(); err != nil { + t.Logf("cannot sync log file: %s", err) + } + + if err := f.Close(); err != nil { + t.Logf("cannot close log file: %s", err) + } + }) + + return lf +} + +// WaitLogsContains waits for the specified string s to be present in the logs within +// the given timeout duration and fails the test if s is not found. +// It keeps track of the log file offset, reading only new lines. Each +// subsequent call to WaitLogsContains will only check logs not yet evaluated. +// msgAndArgs should be a format string and arguments that will be printed +// if the logs are not found, providing additional context for debugging. +func (l *LogFile) WaitLogsContains(t testing.TB, s string, timeout time.Duration, msgAndArgs ...any) { + t.Helper() + require.EventuallyWithT( + t, + func(c *assert.CollectT) { + found, err := l.FindInLogs(s) + if err != nil { + c.Errorf("cannot check the log file: %s", err) + return + } + + if !found { + c.Errorf("did not find '%s' in the logs", s) + } + }, + timeout, + 100*time.Millisecond, + msgAndArgs...) +} + +// LogContains searches for str in the log file keeping track of the +// offset. If there is any issue reading the log file, then t.Fatalf is called, +// if str is not present in the logs, t.Errorf is called. +func (l *LogFile) LogContains(t testing.TB, str string) { + t.Helper() + found, err := l.FindInLogs(str) + if err != nil { + t.Fatalf("cannot read log file: %s", err) + } + + if !found { + t.Errorf("'%s' not found in logs", str) + } +} + +// FindInLogs searches for str in the log file keeping track of the offset. +// It returns true if str is found in the logs. If there are any errors, +// it returns false and the error +func (l *LogFile) FindInLogs(str string) (bool, error) { + // Open the file again so we can seek and not interfere with + // the logger writing to it. + f, err := os.Open(l.Name()) + if err != nil { + return false, fmt.Errorf("cannot open log file for reading: %w", err) + } + + if _, err := f.Seek(l.offset, io.SeekStart); err != nil { + return false, fmt.Errorf("cannot seek log file: %w", err) + } + + r := bufio.NewReader(f) + for { + data, err := r.ReadBytes('\n') + line := string(data) + l.offset += int64(len(data)) + + if err != nil { + if !errors.Is(err, io.EOF) { + return false, fmt.Errorf("error reading log file '%s': %w", l.Name(), err) + } + break + } + + if strings.Contains(line, str) { + return true, nil + } + } + + return false, nil +} + +// ResetOffset resets the log file offset +func (l *LogFile) ResetOffset() { + l.offset = 0 +} diff --git a/testing/fs/logfile_test.go b/testing/fs/logfile_test.go new file mode 100644 index 0000000..f621408 --- /dev/null +++ b/testing/fs/logfile_test.go @@ -0,0 +1,81 @@ +// Licensed to Elasticsearch B.V. under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Elasticsearch B.V. licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package fs + +import ( + "sync" + "testing" + "time" +) + +func TestLogFile(t *testing.T) { + msg := "it works!" + lf := NewLogFile(t, "", "") + if _, err := lf.WriteString(msg + "\n"); err != nil { + t.Fatalf("cannot write to file: %s", err) + } + + // Ensure we can find the string we wrote + lf.LogContains(t, msg) + + // calling FindInLogs will fail because it tracks the offset + found, err := lf.FindInLogs(msg) + if err != nil { + t.Fatalf("cannot search in log file: %s", err) + } + + if found { + t.Error("'FindInLogs' must keep track of offset, it should have returned false") + } + + lf.ResetOffset() + found, err = lf.FindInLogs(msg) + if err != nil { + t.Fatalf("cannot search in log file: %s", err) + } + + if !found { + t.Error("offset was reset, 'FindInLogs' must succeed") + } + + msg2 := "second message" + wg := sync.WaitGroup{} + wg.Add(1) + + wgRunning := sync.WaitGroup{} + wgRunning.Add(1) + go func() { + wgRunning.Done() + lf.WaitLogsContains(t, msg2, 5*time.Second, "did not find msg2") + wg.Done() + }() + + // Ensure the goroutine that calls WaitLogsContains is running + wgRunning.Wait() + + // Write to the file + if _, err := lf.WriteString(msg2 + "\n"); err != nil { + t.Fatalf("cannot write to file: %s", err) + } + + // Ensure the goroutine finishes without failing the tests + wg.Wait() + if t.Failed() { + t.Error("WaitLogsContains should have succeeded") + } +}