Skip to content

Commit 88e330e

Browse files
belimawrAndersonQ
andauthored
Add TempDir and NewLogFile test utilities (#369)
--------- Co-authored-by: Anderson Queiroz <[email protected]>
1 parent d338d2d commit 88e330e

File tree

4 files changed

+476
-0
lines changed

4 files changed

+476
-0
lines changed

testing/fs/fs.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// Licensed to Elasticsearch B.V. under one or more contributor
2+
// license agreements. See the NOTICE file distributed with
3+
// this work for additional information regarding copyright
4+
// ownership. Elasticsearch B.V. licenses this file to you under
5+
// the Apache License, Version 2.0 (the "License"); you may
6+
// not use this file except in compliance with the License.
7+
// You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
package fs
19+
20+
import (
21+
"os"
22+
"path/filepath"
23+
"runtime"
24+
"strings"
25+
"testing"
26+
"unicode"
27+
"unicode/utf8"
28+
)
29+
30+
// TempDir creates a temporary directory that will be
31+
// removed if the tests passes. The temporary directory is
32+
// created by joining all elements from path, with the sanitised
33+
// test name.
34+
//
35+
// If path is empty, the temporary directory is created in os.TempDir.
36+
//
37+
// When tests are run with -v, the temporary directory absolute
38+
// path will be logged.
39+
func TempDir(t *testing.T, path ...string) string {
40+
rootDir := filepath.Join(path...)
41+
42+
if rootDir == "" {
43+
rootDir = os.TempDir()
44+
}
45+
46+
rootDir, err := filepath.Abs(rootDir)
47+
if err != nil {
48+
t.Fatalf("cannot get absolute path: %s", err)
49+
}
50+
51+
// Logic copied with small modifications from
52+
// the Go source code: testing/testing.go
53+
folderName := t.Name()
54+
mapper := func(r rune) rune {
55+
if r < utf8.RuneSelf {
56+
const allowed = "_-"
57+
if '0' <= r && r <= '9' ||
58+
'a' <= r && r <= 'z' ||
59+
'A' <= r && r <= 'Z' {
60+
return r
61+
}
62+
if strings.ContainsRune(allowed, r) {
63+
return r
64+
}
65+
} else if unicode.IsLetter(r) || unicode.IsNumber(r) {
66+
return r
67+
}
68+
return -1
69+
}
70+
folderName = strings.Map(mapper, folderName)
71+
72+
if err := os.MkdirAll(rootDir, 0o750); err != nil {
73+
t.Fatalf("error making test dir: %s: %s", rootDir, err)
74+
}
75+
76+
tempDir, err := os.MkdirTemp(rootDir, folderName)
77+
if err != nil {
78+
t.Fatalf("failed to make temp directory: %s", err)
79+
}
80+
81+
cleanup := func() {
82+
if !t.Failed() {
83+
if err := os.RemoveAll(tempDir); err != nil {
84+
// Ungly workaround Windows limitations
85+
// Windows does not support the Interrup signal, so it might
86+
// happen that Filebeat is still running, keeping it's registry
87+
// file open, thus preventing the temporary folder from being
88+
// removed. So we log the error and move on without failing the
89+
// test
90+
if runtime.GOOS == "windows" {
91+
t.Logf("[WARN] Could not remove temporatry directory '%s': %s", tempDir, err)
92+
} else {
93+
t.Errorf("could not remove temp dir '%s': %s", tempDir, err)
94+
}
95+
}
96+
} else {
97+
t.Logf("Temporary directory saved: %s", tempDir)
98+
}
99+
}
100+
t.Cleanup(cleanup)
101+
102+
return tempDir
103+
}

testing/fs/fs_test.go

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
// Licensed to Elasticsearch B.V. under one or more contributor
2+
// license agreements. See the NOTICE file distributed with
3+
// this work for additional information regarding copyright
4+
// ownership. Elasticsearch B.V. licenses this file to you under
5+
// the Apache License, Version 2.0 (the "License"); you may
6+
// not use this file except in compliance with the License.
7+
// You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
package fs
19+
20+
import (
21+
"bufio"
22+
"bytes"
23+
"fmt"
24+
"os"
25+
"os/exec"
26+
"path/filepath"
27+
"strings"
28+
"testing"
29+
)
30+
31+
func TestTempDir(t *testing.T) {
32+
t.Run("temp dir is created using os.TempDir", func(t *testing.T) {
33+
tempDir := TempDir(t)
34+
osTempDir := os.TempDir()
35+
36+
baseDir := filepath.Dir(tempDir)
37+
if baseDir != osTempDir {
38+
t.Fatalf("expecting %q to be created on %q", tempDir, osTempDir)
39+
}
40+
})
41+
42+
t.Run("temp dir is created in the specified path", func(t *testing.T) {
43+
path := []string{os.TempDir(), "foo", "bar"}
44+
rootDir := filepath.Join(path...)
45+
46+
// Pass each element here so we ensure they're correctly joined
47+
tempDir := TempDir(t, path...)
48+
49+
baseDir := filepath.Dir(tempDir)
50+
if baseDir != rootDir {
51+
t.Fatalf("expecting %q to be created on %q", tempDir, rootDir)
52+
}
53+
})
54+
}
55+
56+
func TestTempDirIsKeptOnTestFailure(t *testing.T) {
57+
rootDirEnv := "TEMPDIR_ROOTDIR"
58+
tempFilename := "it-works"
59+
if os.Getenv("INNER_TEST") == "1" {
60+
rootDir := os.Getenv(rootDirEnv)
61+
// We're inside the subprocess:
62+
// 1. Read the root dir set as env var by the 'main' process
63+
// 2. Use it when calling TempDir
64+
// 3. Create a file just to ensure this actually run
65+
tmpDir := TempDir(t, rootDir)
66+
if err := os.WriteFile(filepath.Join(tmpDir, tempFilename), []byte("it works\n"), 0x666); err != nil {
67+
t.Fatalf("cannot write temp file: %s", err)
68+
}
69+
70+
t.Fatal("keep the folder")
71+
return
72+
}
73+
74+
rootDir := os.TempDir()
75+
//nolint:gosec // This is intentionally running a subprocess
76+
cmd := exec.CommandContext(
77+
t.Context(),
78+
os.Args[0],
79+
fmt.Sprintf("-test.run=^%s$",
80+
t.Name()),
81+
"-test.v")
82+
cmd.Env = append(
83+
cmd.Env,
84+
"INNER_TEST=1",
85+
rootDirEnv+"="+rootDir,
86+
)
87+
88+
out, cmdErr := cmd.CombinedOutput()
89+
if cmdErr != nil {
90+
// The test ran by cmd will fail and retrun 1 as the exit code. So we only
91+
// print the error if the main test fails.
92+
defer func() {
93+
if t.Failed() {
94+
t.Errorf(
95+
"the test process returned an error (this is expected on a normal test execution): %s",
96+
cmdErr)
97+
t.Logf("Output of the subprocess:\n%s\n", string(out))
98+
}
99+
}()
100+
}
101+
102+
var tempFolder string
103+
sc := bufio.NewScanner(bytes.NewReader(out))
104+
for sc.Scan() {
105+
txt := sc.Text()
106+
// To extract the temp folder path we split txt in a way that the path
107+
// is the 2nd element.
108+
// The string we're using as reference:
109+
// fs.go:97: Temporary directory saved: /tmp/TestTempDirIsKeptOnTestFailure2385221663
110+
if strings.Contains(txt, "Temporary directory saved:") {
111+
split := strings.Split(txt, "Temporary directory saved: ")
112+
if len(split) != 2 {
113+
t.Fatalf("could not parse log file form test output, invalid format %q", txt)
114+
}
115+
tempFolder = split[1]
116+
t.Cleanup(func() {
117+
if t.Failed() {
118+
t.Logf("Temp folder: %q", tempFolder)
119+
}
120+
})
121+
}
122+
}
123+
124+
stat, err := os.Stat(tempFolder)
125+
if err != nil {
126+
t.Fatalf("cannot stat created temp folder: %s", err)
127+
}
128+
129+
if !stat.IsDir() {
130+
t.Errorf("%s must be a directory", tempFolder)
131+
}
132+
133+
if _, err = os.Stat(filepath.Join(tempFolder, tempFilename)); err != nil {
134+
t.Fatalf("cannot stat file create by subprocess: %s", err)
135+
}
136+
137+
// Be nice and cleanup
138+
if err := os.RemoveAll(tempFolder); err != nil {
139+
t.Fatalf("cannot remove created folders: %s", err)
140+
}
141+
}

testing/fs/logfile.go

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
// Licensed to Elasticsearch B.V. under one or more contributor
2+
// license agreements. See the NOTICE file distributed with
3+
// this work for additional information regarding copyright
4+
// ownership. Elasticsearch B.V. licenses this file to you under
5+
// the Apache License, Version 2.0 (the "License"); you may
6+
// not use this file except in compliance with the License.
7+
// You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
package fs
19+
20+
import (
21+
"bufio"
22+
"errors"
23+
"fmt"
24+
"io"
25+
"os"
26+
"strings"
27+
"testing"
28+
"time"
29+
30+
"github.com/stretchr/testify/assert"
31+
"github.com/stretchr/testify/require"
32+
)
33+
34+
// LogFile wraps a *os.File and makes it more suitable for tests.
35+
// Methods to search and wait for substrings in lines are provided,
36+
// they keep track of the offset, ensuring ordering when
37+
// when searching.
38+
type LogFile struct {
39+
*os.File
40+
offset int64
41+
}
42+
43+
// NewLogFile returns a new LogFile which wraps a os.File meant to be used
44+
// for testing. Methods to search and wait for strings in the file are
45+
// provided. 'dir' and 'pattern' are passed directly to os.CreateTemp.
46+
// It is the callers responsibility to remove the file. To keep the file
47+
// when the test fails, use [TempDir] to create a folder.
48+
func NewLogFile(t testing.TB, dir, pattern string) *LogFile {
49+
f, err := os.CreateTemp(dir, pattern)
50+
if err != nil {
51+
t.Fatalf("cannot create log file: %s", err)
52+
}
53+
54+
lf := &LogFile{
55+
File: f,
56+
}
57+
58+
t.Cleanup(func() {
59+
if err := f.Sync(); err != nil {
60+
t.Logf("cannot sync log file: %s", err)
61+
}
62+
63+
if err := f.Close(); err != nil {
64+
t.Logf("cannot close log file: %s", err)
65+
}
66+
})
67+
68+
return lf
69+
}
70+
71+
// WaitLogsContains waits for the specified string s to be present in the logs within
72+
// the given timeout duration and fails the test if s is not found.
73+
// It keeps track of the log file offset, reading only new lines. Each
74+
// subsequent call to WaitLogsContains will only check logs not yet evaluated.
75+
// msgAndArgs should be a format string and arguments that will be printed
76+
// if the logs are not found, providing additional context for debugging.
77+
func (l *LogFile) WaitLogsContains(t testing.TB, s string, timeout time.Duration, msgAndArgs ...any) {
78+
t.Helper()
79+
require.EventuallyWithT(
80+
t,
81+
func(c *assert.CollectT) {
82+
found, err := l.FindInLogs(s)
83+
if err != nil {
84+
c.Errorf("cannot check the log file: %s", err)
85+
return
86+
}
87+
88+
if !found {
89+
c.Errorf("did not find '%s' in the logs", s)
90+
}
91+
},
92+
timeout,
93+
100*time.Millisecond,
94+
msgAndArgs...)
95+
}
96+
97+
// LogContains searches for str in the log file keeping track of the
98+
// offset. If there is any issue reading the log file, then t.Fatalf is called,
99+
// if str is not present in the logs, t.Errorf is called.
100+
func (l *LogFile) LogContains(t testing.TB, str string) {
101+
t.Helper()
102+
found, err := l.FindInLogs(str)
103+
if err != nil {
104+
t.Fatalf("cannot read log file: %s", err)
105+
}
106+
107+
if !found {
108+
t.Errorf("'%s' not found in logs", str)
109+
}
110+
}
111+
112+
// FindInLogs searches for str in the log file keeping track of the offset.
113+
// It returns true if str is found in the logs. If there are any errors,
114+
// it returns false and the error
115+
func (l *LogFile) FindInLogs(str string) (bool, error) {
116+
// Open the file again so we can seek and not interfere with
117+
// the logger writing to it.
118+
f, err := os.Open(l.Name())
119+
if err != nil {
120+
return false, fmt.Errorf("cannot open log file for reading: %w", err)
121+
}
122+
123+
if _, err := f.Seek(l.offset, io.SeekStart); err != nil {
124+
return false, fmt.Errorf("cannot seek log file: %w", err)
125+
}
126+
127+
r := bufio.NewReader(f)
128+
for {
129+
data, err := r.ReadBytes('\n')
130+
line := string(data)
131+
l.offset += int64(len(data))
132+
133+
if err != nil {
134+
if !errors.Is(err, io.EOF) {
135+
return false, fmt.Errorf("error reading log file '%s': %w", l.Name(), err)
136+
}
137+
break
138+
}
139+
140+
if strings.Contains(line, str) {
141+
return true, nil
142+
}
143+
}
144+
145+
return false, nil
146+
}
147+
148+
// ResetOffset resets the log file offset
149+
func (l *LogFile) ResetOffset() {
150+
l.offset = 0
151+
}

0 commit comments

Comments
 (0)