d29a90a122
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)
614 lines
14 KiB
Go
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)
|
|
})
|
|
}
|