Files
air/runner/util_test.go
T
xiantang d29a90a122 Optimize unit test performance from 60s to 35s (42% improvement) (#843)
Reduce test execution time through smart waiting and selective parallelization.

Changes:
- Replace fixed sleep delays with condition-based waiting (20ms polling)
- Add CI-aware timeout multiplier (2x in CI environments)
- Enable parallel execution for 30+ pure function tests
- Add test and test-ci Make targets
- Update GitHub Actions workflow with CI flag and timeout

Performance:
- Before: ~60 seconds
- After: ~35 seconds
- Improvement: 42% faster (25 seconds saved)

Technical details:
- New helpers: waitForCondition(), waitForEngineState() in test_util.go
- Optimized tests: TestRebuild, TestRun, TestRebuildWhenRunCmdUsingDLV, etc.
- Parallelized: config_test.go (6 tests), flag_test.go (1 test), util_test.go (13 tests)
- Avoided parallelizing tests with global state (os.Setenv, os.Chdir, signal handlers)

Limitations:
- Some tests cannot be parallelized due to Go 1.25 restrictions on t.Parallel() + t.Setenv()
- Pre-existing race conditions in engine tests remain (not addressed in this change)
2026-01-05 21:37:23 +08:00

614 lines
14 KiB
Go

package runner
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"reflect"
"runtime"
"strconv"
"strings"
"syscall"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestIsDirRootPath(t *testing.T) {
result := isDir(".")
if result != true {
t.Errorf("expected '%t' but got '%t'", true, result)
}
}
func TestIsDirMainFile(t *testing.T) {
result := isDir("main.go")
if result != false {
t.Errorf("expected '%t' but got '%t'", true, result)
}
}
func TestIsDirFileNot(t *testing.T) {
result := isDir("main.go")
if result != false {
t.Errorf("expected '%t' but got '%t'", true, result)
}
}
func TestExpandPathWithDot(t *testing.T) {
path, _ := expandPath(".")
wd, _ := os.Getwd()
if path != wd {
t.Errorf("expected '%s' but got '%s'", wd, path)
}
}
func TestExpandPathWithHomePath(t *testing.T) {
path := "~/.conf"
result, _ := expandPath(path)
home := os.Getenv("HOME")
want := home + path[1:]
if result != want {
t.Errorf("expected '%s' but got '%s'", want, result)
}
}
func TestNormalizeIncludeDirOutsideRoot(t *testing.T) {
t.Parallel()
root := t.TempDir()
parent := filepath.Dir(root)
external := filepath.Join(parent, "pkg")
cfg := &Config{
Root: root,
Build: cfgBuild{
IncludeDir: []string{"../pkg"},
},
}
cfg.Build.normalizeIncludeDirs(cfg.Root)
require.Empty(t, cfg.Build.includeDirAbs)
require.Equal(t, []string{filepath.Clean(external)}, cfg.Build.extraIncludeDirs)
engine := &Engine{config: cfg}
isIn, walk := engine.checkIncludeDir(filepath.Join(root, "runner"))
require.True(t, isIn)
require.True(t, walk)
}
func TestCheckIncludeDirRestrictsWithinRoot(t *testing.T) {
t.Parallel()
root := t.TempDir()
runnerDir := filepath.Join(root, "runner")
require.NoError(t, os.Mkdir(runnerDir, 0o755))
otherDir := filepath.Join(root, "other")
require.NoError(t, os.Mkdir(otherDir, 0o755))
cfg := &Config{
Root: root,
Build: cfgBuild{
IncludeDir: []string{"runner"},
},
}
cfg.Build.normalizeIncludeDirs(cfg.Root)
engine := &Engine{config: cfg}
isIn, walk := engine.checkIncludeDir(runnerDir)
require.True(t, isIn)
require.True(t, walk)
isIn, walk = engine.checkIncludeDir(otherDir)
require.False(t, isIn)
require.False(t, walk)
}
func TestFileChecksum(t *testing.T) {
t.Parallel()
tests := []struct {
name string
fileContents []byte
expectedChecksum string
expectedChecksumError string
}{
{
name: "empty",
fileContents: []byte(``),
expectedChecksum: "",
expectedChecksumError: "empty file, forcing rebuild without updating checksum",
},
{
name: "simple",
fileContents: []byte(`foo`),
expectedChecksum: "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
expectedChecksumError: "",
},
{
name: "binary",
fileContents: []byte{0xF}, // invalid UTF-8 codepoint
expectedChecksum: "dc0e9c3658a1a3ed1ec94274d8b19925c93e1abb7ddba294923ad9bde30f8cb8",
expectedChecksumError: "",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
f, err := os.CreateTemp("", "")
if err != nil {
t.Fatalf("couldn't create temp file for test: %v", err)
}
defer func() {
if err := f.Close(); err != nil {
t.Errorf("error closing temp file: %v", err)
}
if err := os.Remove(f.Name()); err != nil {
t.Errorf("error removing temp file: %v", err)
}
}()
_, err = f.Write(test.fileContents)
if err != nil {
t.Fatalf("couldn't write to temp file for test: %v", err)
}
checksum, err := fileChecksum(f.Name())
if err != nil && err.Error() != test.expectedChecksumError {
t.Errorf("expected '%s' but got '%s'", test.expectedChecksumError, err.Error())
}
if checksum != test.expectedChecksum {
t.Errorf("expected '%s' but got '%s'", test.expectedChecksum, checksum)
}
})
}
}
func TestChecksumMap(t *testing.T) {
t.Parallel()
m := &checksumMap{m: make(map[string]string, 3)}
if !m.updateFileChecksum("foo.txt", "abcxyz") {
t.Errorf("expected no entry for foo.txt, but had one")
}
if m.updateFileChecksum("foo.txt", "abcxyz") {
t.Errorf("expected matching entry for foo.txt")
}
if !m.updateFileChecksum("foo.txt", "123456") {
t.Errorf("expected matching entry for foo.txt")
}
if !m.updateFileChecksum("bar.txt", "123456") {
t.Errorf("expected no entry for bar.txt, but had one")
}
}
func TestAdaptToVariousPlatforms(t *testing.T) {
t.Parallel()
config := &Config{
Build: cfgBuild{
Bin: "tmp\\main.exe -dev",
},
}
adaptToVariousPlatforms(config)
if config.Build.Bin != "tmp\\main.exe -dev" {
t.Errorf("expected '%s' but got '%s'", "tmp\\main.exe -dev", config.Build.Bin)
}
}
func Test_killCmd_no_process(t *testing.T) {
e := Engine{
config: &Config{
Build: cfgBuild{
SendInterrupt: false,
},
},
}
_, err := e.killCmd(&exec.Cmd{
Process: &os.Process{
Pid: 9999,
},
})
if err == nil {
t.Errorf("expected error but got none")
}
if !errors.Is(err, syscall.ESRCH) {
t.Errorf("expected '%s' but got '%s'", syscall.ESRCH, errors.Unwrap(err))
}
}
func Test_killCmd_SendInterrupt_false(t *testing.T) {
_, b, _, _ := runtime.Caller(0)
// Root folder of this project
dir := filepath.Dir(b)
err := os.Chdir(dir)
if err != nil {
t.Fatalf("couldn't change directory: %v", err)
}
// clean file before test
os.Remove("pid")
defer os.Remove("pid")
e := Engine{
config: &Config{
Build: cfgBuild{
SendInterrupt: false,
},
},
}
startChan := make(chan struct {
pid int
cmd *exec.Cmd
})
go func() {
cmd, _, _, err := e.startCmd("sh _testdata/run-many-processes.sh")
if err != nil {
t.Errorf("failed to start command: %v", err)
return
}
pid := cmd.Process.Pid
t.Logf("process pid is %v", pid)
startChan <- struct {
pid int
cmd *exec.Cmd
}{pid: pid, cmd: cmd}
if err := cmd.Wait(); err != nil {
t.Logf("failed to wait command: %v", err)
}
t.Logf("wait finished")
}()
resp := <-startChan
t.Logf("process started. checking pid %v", resp.pid)
time.Sleep(2 * time.Second)
t.Logf("%v", resp.cmd.Process.Pid)
pid, _ := e.killCmd(resp.cmd)
t.Logf("%v was been killed", pid)
// check processes were being killed
// read pids from file
bytesRead, err := os.ReadFile("pid")
require.NoError(t, err)
lines := strings.Split(string(bytesRead), "\n")
for _, line := range lines {
_, err := strconv.Atoi(line)
if err != nil {
t.Logf("failed to convert str to int %v", err)
continue
}
_, err = exec.Command("ps", "-p", line, "-o", "comm= ").Output()
if err == nil {
t.Fatalf("process should be killed %v", line)
}
}
}
func Test_killCmd_KillsDetachedChildren(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("requires /proc")
}
_, b, _, _ := runtime.Caller(0)
dir := filepath.Dir(b)
err := os.Chdir(dir)
if err != nil {
t.Fatalf("couldn't change directory: %v", err)
}
_ = os.Remove("pid")
defer os.Remove("pid")
e := Engine{
config: &Config{
Build: cfgBuild{
SendInterrupt: false,
},
},
}
startChan := make(chan *exec.Cmd)
go func() {
cmd, _, _, err := e.startCmd("sh _testdata/run-detached-process.sh")
if err != nil {
t.Errorf("failed to start command: %v", err)
return
}
startChan <- cmd
if err := cmd.Wait(); err != nil {
t.Logf("failed to wait command: %v", err)
}
}()
cmd := <-startChan
time.Sleep(2 * time.Second)
if _, err := e.killCmd(cmd); err != nil {
t.Fatalf("failed to kill command: %v", err)
}
bytesRead, err := os.ReadFile("pid")
require.NoError(t, err)
lines := strings.Split(string(bytesRead), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
if _, err := strconv.Atoi(line); err != nil {
t.Logf("failed to convert str to int %v", err)
continue
}
_, err = exec.Command("ps", "-p", line, "-o", "comm= ").Output()
if err == nil {
t.Fatalf("process should be killed %v", line)
}
}
}
func TestGetStructureFieldTagMap(t *testing.T) {
t.Parallel()
c := Config{}
tagMap := flatConfig(c)
assert.NotEmpty(t, tagMap)
for _, i2 := range tagMap {
fmt.Printf("%v\n", i2.fieldPath)
}
}
func TestSetStructValue(t *testing.T) {
t.Parallel()
c := Config{}
v := reflect.ValueOf(&c)
setValue2Struct(v, "TmpDir", "asdasd")
assert.Equal(t, "asdasd", c.TmpDir)
}
func TestNestStructValue(t *testing.T) {
t.Parallel()
c := Config{}
v := reflect.ValueOf(&c)
setValue2Struct(v, "Build.Cmd", "asdasd")
assert.Equal(t, "asdasd", c.Build.Cmd)
}
func TestNestStructArrayValue(t *testing.T) {
t.Parallel()
c := Config{}
v := reflect.ValueOf(&c)
setValue2Struct(v, "Build.ExcludeDir", "dir1,dir2")
assert.Equal(t, []string{"dir1", "dir2"}, c.Build.ExcludeDir)
}
func TestNestStructArrayValueOverride(t *testing.T) {
t.Parallel()
c := Config{
Build: cfgBuild{
ExcludeDir: []string{"default1", "default2"},
},
}
v := reflect.ValueOf(&c)
setValue2Struct(v, "Build.ExcludeDir", "dir1,dir2")
assert.Equal(t, []string{"dir1", "dir2"}, c.Build.ExcludeDir)
}
func TestCheckIncludeFile(t *testing.T) {
t.Parallel()
e := Engine{
config: &Config{
Build: cfgBuild{
IncludeFile: []string{"main.go"},
},
},
}
assert.True(t, e.checkIncludeFile("main.go"))
assert.False(t, e.checkIncludeFile("no.go"))
assert.False(t, e.checkIncludeFile("."))
}
func TestIsIncludeExt(t *testing.T) {
e := Engine{
config: &Config{
Build: cfgBuild{
IncludeExt: []string{"go", "html"},
},
},
}
assert.True(t, e.isIncludeExt("main.go"))
assert.True(t, e.isIncludeExt("/path/to/file.html"))
assert.False(t, e.isIncludeExt("main.js"))
assert.False(t, e.isIncludeExt("file"))
}
func TestIsIncludeExtWildcard(t *testing.T) {
tmpDir := t.TempDir()
binPath := filepath.Join(tmpDir, "tmp", "main")
e := Engine{
config: &Config{
Root: tmpDir,
Build: cfgBuild{
IncludeExt: []string{"*"},
Entrypoint: entrypoint{binPath},
},
},
}
// Wildcard should match all file extensions
assert.True(t, e.isIncludeExt("main.go"))
assert.True(t, e.isIncludeExt("/path/to/file.html"))
assert.True(t, e.isIncludeExt("main.js"))
assert.True(t, e.isIncludeExt("file.css"))
assert.True(t, e.isIncludeExt("file")) // files without extension
assert.True(t, e.isIncludeExt("/path/noext")) // files without extension
assert.False(t, e.isIncludeExt(binPath)) // binary file should be excluded
assert.True(t, e.isIncludeExt("some/other/bin")) // other files without extension are ok
}
func TestIsIncludeExtWildcardWithSpaces(t *testing.T) {
e := Engine{
config: &Config{
Build: cfgBuild{
IncludeExt: []string{" * "},
Entrypoint: entrypoint{"/tmp/main"},
},
},
}
// Wildcard with spaces should still work
assert.True(t, e.isIncludeExt("main.go"))
assert.True(t, e.isIncludeExt("file.html"))
}
func TestIsBinPath(t *testing.T) {
tmpDir := t.TempDir()
binPath := filepath.Join(tmpDir, "tmp", "main")
e := Engine{
config: &Config{
Root: tmpDir,
Build: cfgBuild{
Entrypoint: entrypoint{binPath},
},
},
}
// Test matching path returns true
assert.True(t, e.isBinPath(binPath))
// Test non-matching paths return false
assert.False(t, e.isBinPath(filepath.Join(tmpDir, "other", "file")))
assert.False(t, e.isBinPath("unrelated.go"))
}
func TestIsBinPathEmptyBinPath(t *testing.T) {
// Test when binPath is empty (no entrypoint configured)
e := Engine{
config: &Config{
Build: cfgBuild{
Entrypoint: entrypoint{}, // empty entrypoint
},
},
}
// Should return false when binPath is empty
assert.False(t, e.isBinPath("/some/path"))
assert.False(t, e.isBinPath("main.go"))
}
func TestJoinPathRelative(t *testing.T) {
t.Parallel()
root, err := filepath.Abs("test")
if err != nil {
t.Fatalf("couldn't get absolute path for testing: %v", err)
}
result := joinPath(root, "x")
assert.Equal(t, result, filepath.Join(root, "x"))
}
func TestJoinPathAbsolute(t *testing.T) {
root, err := filepath.Abs("test")
if err != nil {
t.Fatalf("couldn't get absolute path for testing: %v", err)
}
path, err := filepath.Abs("x")
if err != nil {
t.Fatalf("couldn't get absolute path for testing: %v", err)
}
result := joinPath(root, path)
assert.Equal(t, result, path)
}
func TestFormatPath(t *testing.T) {
t.Parallel()
type testCase struct {
name string
path string
expected string
}
runTests := func(t *testing.T, tests []testCase) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := formatPath(tt.path)
if result != tt.expected {
t.Errorf("formatPath(%q) = %q, want %q", tt.path, result, tt.expected)
}
})
}
}
t.Run("PathPlatformSpecific", func(t *testing.T) {
if runtime.GOOS == PlatformWindows {
// Windows-specific tests
tests := []testCase{
{
name: "Windows style absolute path with spaces",
path: `C:\My Documents\My Project\tmp\app.exe`,
expected: `& "C:\My Documents\My Project\tmp\app.exe"`,
},
{
name: "Windows style relative path with spaces",
path: `My Project\tmp\app.exe`,
expected: `My Project\tmp\app.exe`,
},
{
name: "Windows style absolute path without spaces",
path: `C:\Documents\Project\tmp\app.exe`,
expected: `C:\Documents\Project\tmp\app.exe`,
},
}
runTests(t, tests)
} else {
// Unix-specific tests
tests := []testCase{
{
name: "Unix style absolute path with spaces",
path: `/usr/local/my project/tmp/main`,
expected: `"/usr/local/my project/tmp/main"`,
},
{
name: "Unix style relative path with spaces",
path: "./my project/tmp/main",
expected: "./my project/tmp/main",
},
{
name: "Unix style absolute path without spaces",
path: `/usr/local/project/tmp/main`,
expected: `/usr/local/project/tmp/main`,
},
}
runTests(t, tests)
}
})
t.Run("CommonCases", func(t *testing.T) {
tests := []testCase{
{
name: "Empty path",
path: "",
expected: "",
},
{
name: "Simple path",
path: "main.go",
expected: "main.go",
},
{
name: "TestShouldIncludeIncludedFile",
path: "sh main.sh",
expected: "sh main.sh",
},
}
runTests(t, tests)
})
}