Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions testing/fs/fs.go
Original file line number Diff line number Diff line change
@@ -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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q: what's the difference with the stdlib t tmpdir ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TempDir creates a folder that when the test fails is kept and the path logged. This allows us to debug the state the test was running both locally and on CI.

We have number of tests in Beats and Elastic Agent that create configuration files, logs to be ingested, etc. When those tests fail and all state is gone, debugging becomes extremely hard.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have similar functionality spread across many tests/test frameworks in Beats and Elastic Agent, I'm trying to consolidate them.

I've heavily needed and relayed on this functionality in the past couple of weeks to debug a number of tests, after duplicating the code in a few PRs I realised I needed it in a central place, with a single implementation.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not a fan of copying so much of the code that exists in t.TempDir(). Can we use t.TempDir and then just have the cleanup function copy or archive the contents in the case of a failure?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally I like that the temp dir is already created on its final destination, so no matter how wrong the test goes, the files are kept.

Copying/archiving the temp dir after a test failure adds another point of failure and complexity that I'd like to avoid.

I'm not a fan of copying so much of the code that exists in t.TempDir()

I haven't copied much of it, well, the bit I copied was only the part sanitising the test name to create the folder.

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
}
141 changes: 141 additions & 0 deletions testing/fs/fs_test.go
Original file line number Diff line number Diff line change
@@ -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",
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)
}
}
151 changes: 151 additions & 0 deletions testing/fs/logfile.go
Original file line number Diff line number Diff line change
@@ -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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I've needed it in different places, like Elastic Agent. So this PR is a first attempt to centralise some helpers in a single place.

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
}
Loading
Loading