From 6657a871a74131ed1a2f707a73f5a360e82934b3 Mon Sep 17 00:00:00 2001 From: Tiago Queiroz Date: Thu, 20 Nov 2025 16:09:36 -0500 Subject: [PATCH 1/6] Add TempDir and NewLogFile test utilities --- testing/fs.go | 103 ++++++++++++++++++++++++++++++ testing/logfile.go | 152 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 255 insertions(+) create mode 100644 testing/fs.go create mode 100644 testing/logfile.go diff --git a/testing/fs.go b/testing/fs.go new file mode 100644 index 0000000..52d270d --- /dev/null +++ b/testing/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 testing + +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/logfile.go b/testing/logfile.go new file mode 100644 index 0000000..792d356 --- /dev/null +++ b/testing/logfile.go @@ -0,0 +1,152 @@ +// 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 testing + +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. +// Key feature: +// - 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 to appear are provided. +// dir and pattern are passed directly to os.CreateTemp. +// It is the callers responsibility to remove the file. To keep the file in +// 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 +} From 947d5d0d933186ed0169c0054a7caf2a34c04820 Mon Sep 17 00:00:00 2001 From: Tiago Queiroz Date: Fri, 21 Nov 2025 09:23:02 -0500 Subject: [PATCH 2/6] Improve docs --- testing/logfile.go | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/testing/logfile.go b/testing/logfile.go index 792d356..83f7f02 100644 --- a/testing/logfile.go +++ b/testing/logfile.go @@ -32,19 +32,18 @@ import ( ) // LogFile wraps a *os.File and makes it more suitable for tests. -// Key feature: -// - Methods to search and wait for substrings in lines are provided, -// they keep track of the offset, ensuring ordering when -// when searching. +// 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 to appear are provided. -// dir and pattern are passed directly to os.CreateTemp. -// It is the callers responsibility to remove the file. To keep the file in +// 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) From edcca02f7ff98603abe49368c8336a73a89505bd Mon Sep 17 00:00:00 2001 From: Tiago Queiroz Date: Fri, 21 Nov 2025 09:58:41 -0500 Subject: [PATCH 3/6] Move file and add tests for TempDir --- testing/{ => fs}/fs.go | 2 +- testing/fs/fs_test.go | 124 ++++++++++++++++++++++++++++++++++++ testing/{ => fs}/logfile.go | 2 +- 3 files changed, 126 insertions(+), 2 deletions(-) rename testing/{ => fs}/fs.go (99%) create mode 100644 testing/fs/fs_test.go rename testing/{ => fs}/logfile.go (99%) diff --git a/testing/fs.go b/testing/fs/fs.go similarity index 99% rename from testing/fs.go rename to testing/fs/fs.go index 52d270d..bbd3146 100644 --- a/testing/fs.go +++ b/testing/fs/fs.go @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -package testing +package fs import ( "os" diff --git a/testing/fs/fs_test.go b/testing/fs/fs_test.go new file mode 100644 index 0000000..b0504c5 --- /dev/null +++ b/testing/fs/fs_test.go @@ -0,0 +1,124 @@ +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", + rootDir+"="+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 in 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/logfile.go b/testing/fs/logfile.go similarity index 99% rename from testing/logfile.go rename to testing/fs/logfile.go index 83f7f02..032fc8f 100644 --- a/testing/logfile.go +++ b/testing/fs/logfile.go @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -package testing +package fs import ( "bufio" From f7b2d26115176a0160b0b78cc17368dfcf05a5a8 Mon Sep 17 00:00:00 2001 From: Tiago Queiroz Date: Fri, 21 Nov 2025 10:13:48 -0500 Subject: [PATCH 4/6] Add tests to LogFile --- testing/fs/logfile_test.go | 64 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 testing/fs/logfile_test.go diff --git a/testing/fs/logfile_test.go b/testing/fs/logfile_test.go new file mode 100644 index 0000000..62b3063 --- /dev/null +++ b/testing/fs/logfile_test.go @@ -0,0 +1,64 @@ +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") + } +} From 92b0da2ca4a39f3cd47c2fc76b842876fc50ee6c Mon Sep 17 00:00:00 2001 From: Tiago Queiroz Date: Fri, 21 Nov 2025 10:19:54 -0500 Subject: [PATCH 5/6] Add license headers --- testing/fs/fs_test.go | 17 +++++++++++++++++ testing/fs/logfile_test.go | 17 +++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/testing/fs/fs_test.go b/testing/fs/fs_test.go index b0504c5..1f3fafc 100644 --- a/testing/fs/fs_test.go +++ b/testing/fs/fs_test.go @@ -1,3 +1,20 @@ +// 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 ( diff --git a/testing/fs/logfile_test.go b/testing/fs/logfile_test.go index 62b3063..f621408 100644 --- a/testing/fs/logfile_test.go +++ b/testing/fs/logfile_test.go @@ -1,3 +1,20 @@ +// 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 ( From 62c526c7f0729f6ba01f6378e4b57773c35a6270 Mon Sep 17 00:00:00 2001 From: Tiago Queiroz Date: Wed, 3 Dec 2025 12:33:49 -0500 Subject: [PATCH 6/6] Apply suggestions from code review Co-authored-by: Anderson Queiroz --- testing/fs/fs_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/testing/fs/fs_test.go b/testing/fs/fs_test.go index 1f3fafc..95d09d8 100644 --- a/testing/fs/fs_test.go +++ b/testing/fs/fs_test.go @@ -82,7 +82,7 @@ func TestTempDirIsKeptOnTestFailure(t *testing.T) { cmd.Env = append( cmd.Env, "INNER_TEST=1", - rootDir+"="+rootDir, + rootDirEnv+"="+rootDir, ) out, cmdErr := cmd.CombinedOutput() @@ -92,7 +92,7 @@ func TestTempDirIsKeptOnTestFailure(t *testing.T) { defer func() { if t.Failed() { t.Errorf( - "the test process returned an error (this is expected in on a normal test execution): %s", + "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)) }