547626a83c
Air could delete build.entrypoint while stopping the previous app process when stop_on_error was enabled. After builds were changed to keep the old app alive until a replacement build succeeds, that cleanup could remove the freshly rebuilt binary before it was started, causing issue #910. Failed builds should not delete the last executable either. Keeping the binary in place makes stop_on_error stop the process without destroying the artifact a later rebuild or manual restart may need. Windows needs different sequencing because running executables are locked. Stop the old process before build.cmd on Windows, while keeping the retained-app behavior on Unix platforms. Validation: go test ./runner -run 'TestAddPlatformOverridesForInit|TestPlatformBuildOverridesSelection|TestShouldStopBinBeforeBuild|TestStopBinBeforeBuildIfNeeded|TestBuildRunKeepsIssue910GoBuildEntrypoint' -count=1 -v; go test ./runner -run 'TestBuildRunKeepsIssue910GoBuildEntrypoint|TestShouldStopBinBeforeBuild|TestBuildRunKeeps.*Binary.*StopOnError|TestBuildRunStopsExistingBinWhenBuildFailsWithStopOnError|TestBuildRunStopsExistingBinAfterSuccessfulBuild|TestRebuild$' -count=1 -v; go test ./...; make check 🤖 Generated with [OpenAI Codex](https://openai.com/codex) Co-authored-by: Marius van Niekerk <mariusvniekerk@mbp-marius-kenn.emperor-gopher.ts.net> Co-authored-by: OpenAI Codex <noreply@openai.com>
1834 lines
42 KiB
Go
1834 lines
42 KiB
Go
package runner
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/pelletier/go-toml"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestNewEngine(t *testing.T) {
|
|
_ = os.Unsetenv(airWd)
|
|
engine, err := NewEngine("", nil, true)
|
|
if err != nil {
|
|
t.Fatalf("Should not be fail: %s.", err)
|
|
}
|
|
if engine.logger == nil {
|
|
t.Fatal("logger should not be nil")
|
|
}
|
|
if engine.config == nil {
|
|
t.Fatal("Config should not be nil")
|
|
}
|
|
if engine.watcher == nil {
|
|
t.Fatal("watcher should not be nil")
|
|
}
|
|
}
|
|
|
|
func TestCheckRunEnv(t *testing.T) {
|
|
_ = os.Unsetenv(airWd)
|
|
engine, err := NewEngine("", nil, true)
|
|
if err != nil {
|
|
t.Fatalf("Should not be fail: %s.", err)
|
|
}
|
|
nestedTmpDir := filepath.Join(t.TempDir(), "nested", "build")
|
|
engine.config.TmpDir = nestedTmpDir
|
|
|
|
err = engine.checkRunEnv()
|
|
require.NoError(t, err)
|
|
assert.DirExists(t, nestedTmpDir)
|
|
}
|
|
|
|
func TestWatching(t *testing.T) {
|
|
engine, err := NewEngine("", nil, true)
|
|
if err != nil {
|
|
t.Fatalf("Should not be fail: %s.", err)
|
|
}
|
|
path, err := os.Getwd()
|
|
if err != nil {
|
|
t.Fatalf("Should not be fail: %s.", err)
|
|
}
|
|
path = strings.Replace(path, filepath.Join("_testdata", "toml"), "", 1)
|
|
err = engine.watching(filepath.Join(path, "_testdata", "watching"))
|
|
if err != nil {
|
|
t.Fatalf("Should not be fail: %s.", err)
|
|
}
|
|
}
|
|
|
|
func TestRegexes(t *testing.T) {
|
|
engine, err := NewEngine("", nil, true)
|
|
if err != nil {
|
|
t.Fatalf("Should not be fail: %s.", err)
|
|
}
|
|
engine.config.Build.ExcludeRegex = []string{"foo\\.html$", "bar", "_test\\.go"}
|
|
err = engine.config.preprocess(nil)
|
|
if err != nil {
|
|
t.Fatalf("Should not be fail: %s.", err)
|
|
}
|
|
|
|
result, err := engine.isExcludeRegex("./test/foo.html")
|
|
if err != nil {
|
|
t.Fatalf("Should not be fail: %s.", err)
|
|
}
|
|
if result != true {
|
|
t.Errorf("expected '%t' but got '%t'", true, result)
|
|
}
|
|
|
|
result, err = engine.isExcludeRegex("./test/bar/index.html")
|
|
if err != nil {
|
|
t.Fatalf("Should not be fail: %s.", err)
|
|
}
|
|
if result != true {
|
|
t.Errorf("expected '%t' but got '%t'", true, result)
|
|
}
|
|
|
|
result, err = engine.isExcludeRegex("./test/unrelated.html")
|
|
if err != nil {
|
|
t.Fatalf("Should not be fail: %s.", err)
|
|
}
|
|
if result {
|
|
t.Errorf("expected '%t' but got '%t'", false, result)
|
|
}
|
|
|
|
result, err = engine.isExcludeRegex("./myPackage/goFile_testxgo")
|
|
if err != nil {
|
|
t.Fatalf("Should not be fail: %s.", err)
|
|
}
|
|
if result {
|
|
t.Errorf("expected '%t' but got '%t'", false, result)
|
|
}
|
|
result, err = engine.isExcludeRegex("./myPackage/goFile_test.go")
|
|
if err != nil {
|
|
t.Fatalf("Should not be fail: %s.", err)
|
|
}
|
|
if result != true {
|
|
t.Errorf("expected '%t' but got '%t'", true, result)
|
|
}
|
|
}
|
|
|
|
func TestRunCommand(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("requires touch")
|
|
}
|
|
|
|
// generate a random port
|
|
port, f := GetPort()
|
|
f()
|
|
t.Logf("port: %d", port)
|
|
tmpDir := initTestEnv(t, port)
|
|
// change dir to tmpDir
|
|
chdir(t, tmpDir)
|
|
engine, err := NewEngine("", nil, true)
|
|
if err != nil {
|
|
t.Fatalf("Should not be fail: %s.", err)
|
|
}
|
|
err = engine.runCommand("touch test.txt")
|
|
if err != nil {
|
|
t.Fatalf("Should not be fail: %s.", err)
|
|
}
|
|
if _, err := os.Stat("./test.txt"); err != nil {
|
|
if os.IsNotExist(err) {
|
|
t.Fatalf("Should not be fail: %s.", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunPreCmd(t *testing.T) {
|
|
// generate a random port
|
|
port, f := GetPort()
|
|
f()
|
|
t.Logf("port: %d", port)
|
|
tmpDir := initTestEnv(t, port)
|
|
// change dir to tmpDir
|
|
chdir(t, tmpDir)
|
|
engine, err := NewEngine("", nil, true)
|
|
if err != nil {
|
|
t.Fatalf("Should not be fail: %s.", err)
|
|
}
|
|
if runtime.GOOS == "windows" {
|
|
engine.config.Build.PreCmd = []string{`cmd.exe /c "echo hello air > pre_cmd.txt"`}
|
|
} else {
|
|
engine.config.Build.PreCmd = []string{"echo 'hello air' > pre_cmd.txt"}
|
|
}
|
|
err = engine.runPreCmd()
|
|
if err != nil {
|
|
t.Fatalf("Should not be fail: %s.", err)
|
|
}
|
|
if _, err := os.Stat("./pre_cmd.txt"); err != nil {
|
|
if os.IsNotExist(err) {
|
|
t.Fatalf("Should not be fail: %s.", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunPostCmd(t *testing.T) {
|
|
// generate a random port
|
|
port, f := GetPort()
|
|
f()
|
|
t.Logf("port: %d", port)
|
|
tmpDir := initTestEnv(t, port)
|
|
// change dir to tmpDir
|
|
chdir(t, tmpDir)
|
|
|
|
engine, err := NewEngine("", nil, true)
|
|
if err != nil {
|
|
t.Fatalf("Should not be fail: %s.", err)
|
|
}
|
|
if runtime.GOOS == "windows" {
|
|
engine.config.Build.PostCmd = []string{`cmd.exe /c "echo hello air > post_cmd.txt"`}
|
|
} else {
|
|
engine.config.Build.PostCmd = []string{"echo 'hello air' > post_cmd.txt"}
|
|
}
|
|
err = engine.runPostCmd()
|
|
if err != nil {
|
|
t.Fatalf("Should not be fail: %s.", err)
|
|
}
|
|
|
|
if _, err := os.Stat("./post_cmd.txt"); err != nil {
|
|
if os.IsNotExist(err) {
|
|
t.Fatalf("Should not be fail: %s.", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunBin(t *testing.T) {
|
|
engine, err := NewEngine("", nil, true)
|
|
if err != nil {
|
|
t.Fatalf("Should not be fail: %s.", err)
|
|
}
|
|
|
|
err = engine.runBin()
|
|
if err != nil {
|
|
t.Fatalf("Should not be fail: %s.", err)
|
|
}
|
|
}
|
|
|
|
func TestRunBinDoesNotStopExistingBin(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("uses POSIX sleep command")
|
|
}
|
|
|
|
engine, err := NewEngine("", nil, true)
|
|
require.NoError(t, err)
|
|
engine.config.Log.Silent = true
|
|
engine.config.Build.Entrypoint = entrypoint{}
|
|
engine.config.Build.Bin = "sleep 1"
|
|
|
|
oldStopped := make(chan struct{})
|
|
shutdown := make(chan chan int)
|
|
engine.binStopCh = shutdown
|
|
go func() {
|
|
closer, ok := <-shutdown
|
|
if !ok {
|
|
return
|
|
}
|
|
close(closer)
|
|
close(oldStopped)
|
|
}()
|
|
|
|
err = engine.runBin()
|
|
require.NoError(t, err)
|
|
defer func() {
|
|
engine.stopBin()
|
|
close(shutdown)
|
|
}()
|
|
|
|
select {
|
|
case <-oldStopped:
|
|
t.Fatal("runBin should not stop the previous binary; buildRun stops it after a successful build")
|
|
default:
|
|
}
|
|
|
|
err = waitForCondition(t, time.Second, func() bool {
|
|
stillOld := false
|
|
engine.withLock(func() {
|
|
stillOld = engine.binStopCh == shutdown
|
|
})
|
|
return !stillOld
|
|
}, "runBin to register the new binary stop channel")
|
|
require.NoError(t, err)
|
|
|
|
select {
|
|
case <-oldStopped:
|
|
t.Fatal("runBin should not stop the previous binary; buildRun stops it after a successful build")
|
|
default:
|
|
}
|
|
}
|
|
|
|
func TestBuildRunStopsExistingBinAfterSuccessfulBuild(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("uses POSIX shell commands")
|
|
}
|
|
|
|
engine, err := NewEngine("", nil, true)
|
|
require.NoError(t, err)
|
|
|
|
engine.config.Log.Silent = true
|
|
engine.config.Build.Cmd = "true"
|
|
engine.config.Build.Entrypoint = entrypoint{}
|
|
engine.config.Build.Bin = "sleep 1"
|
|
|
|
stopped := make(chan struct{})
|
|
shutdown := make(chan chan int)
|
|
engine.binStopCh = shutdown
|
|
go func() {
|
|
closer := <-shutdown
|
|
close(closer)
|
|
close(stopped)
|
|
}()
|
|
defer engine.stopBin()
|
|
|
|
engine.buildRun()
|
|
|
|
select {
|
|
case <-stopped:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("expected successful build to stop the existing binary before running the new one")
|
|
}
|
|
}
|
|
|
|
func TestBuildRunKeepsFreshBinaryAfterSuccessfulBuildWithStopOnError(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("uses POSIX shell commands")
|
|
}
|
|
|
|
tmpDir := t.TempDir()
|
|
chdir(t, tmpDir)
|
|
|
|
require.NoError(t, os.Mkdir("tmp", 0o755))
|
|
binPath := filepath.Join("tmp", "dev")
|
|
initialScript := []byte("#!/bin/sh\ntrap 'exit 0' INT TERM\nwhile :; do sleep 1; done\n")
|
|
require.NoError(t, os.WriteFile(binPath, initialScript, 0o755))
|
|
require.NoError(t, os.WriteFile("next.sh", initialScript, 0o755))
|
|
|
|
engine, err := NewEngine("", nil, true)
|
|
require.NoError(t, err)
|
|
engine.config.Log.Silent = true
|
|
engine.config.Build.Cmd = "cp ./next.sh ./tmp/dev && chmod +x ./tmp/dev"
|
|
engine.config.Build.Entrypoint = entrypoint{"./tmp/dev"}
|
|
engine.config.Build.StopOnError = true
|
|
engine.config.Build.SendInterrupt = true
|
|
engine.config.Build.KillDelay = 100 * time.Millisecond
|
|
|
|
require.NoError(t, engine.runBin())
|
|
defer engine.stopBin()
|
|
|
|
err = waitForCondition(t, time.Second, func() bool {
|
|
started := false
|
|
engine.withLock(func() {
|
|
started = engine.binStopCh != nil
|
|
})
|
|
return started
|
|
}, "initial binary process to start")
|
|
require.NoError(t, err)
|
|
|
|
engine.buildRun()
|
|
|
|
err = waitForCondition(t, time.Second, func() bool {
|
|
started := false
|
|
engine.withLock(func() {
|
|
started = engine.binStopCh != nil
|
|
})
|
|
return started
|
|
}, "rebuilt binary process to start")
|
|
require.NoError(t, err)
|
|
require.FileExists(t, binPath)
|
|
}
|
|
|
|
func TestBuildRunKeepsIssue910GoBuildEntrypoint(t *testing.T) {
|
|
if runtime.GOOS == PlatformWindows {
|
|
t.Skip("issue #910 was reported on Linux; Windows stops before build because running executables are locked")
|
|
}
|
|
|
|
tmpDir := t.TempDir()
|
|
chdir(t, tmpDir)
|
|
|
|
require.NoError(t, os.Mkdir("tmp", 0o755))
|
|
require.NoError(t, os.WriteFile("go.mod", []byte("module issue910.test\n\ngo 1.17\n"), 0o644))
|
|
require.NoError(t, os.WriteFile("main.go", []byte(`package main
|
|
|
|
import "time"
|
|
|
|
func main() {
|
|
for {
|
|
time.Sleep(time.Second)
|
|
}
|
|
}
|
|
`), 0o644))
|
|
|
|
binPath := filepath.Join("tmp", "dev")
|
|
require.NoError(t, exec.Command("go", "build", "-o", binPath, ".").Run())
|
|
|
|
engine, err := NewEngine("", nil, true)
|
|
require.NoError(t, err)
|
|
engine.config.Log.Silent = true
|
|
engine.config.Build.Cmd = "go build -o ./tmp/dev .; echo 'build ok'; sleep 1"
|
|
engine.config.Build.Entrypoint = entrypoint{"./tmp/dev"}
|
|
engine.config.Build.StopOnError = true
|
|
engine.config.Build.SendInterrupt = true
|
|
engine.config.Build.KillDelay = 500 * time.Millisecond
|
|
|
|
require.NoError(t, engine.runBin())
|
|
defer engine.stopBin()
|
|
|
|
err = waitForCondition(t, time.Second, func() bool {
|
|
started := false
|
|
engine.withLock(func() {
|
|
started = engine.binStopCh != nil
|
|
})
|
|
return started
|
|
}, "initial issue #910 binary process to start")
|
|
require.NoError(t, err)
|
|
|
|
engine.buildRun()
|
|
|
|
err = waitForCondition(t, time.Second, func() bool {
|
|
started := false
|
|
engine.withLock(func() {
|
|
started = engine.binStopCh != nil
|
|
})
|
|
return started
|
|
}, "rebuilt issue #910 binary process to start")
|
|
require.NoError(t, err)
|
|
require.FileExists(t, binPath)
|
|
}
|
|
|
|
func TestBuildRunStopsExistingBinWhenBuildFailsWithStopOnError(t *testing.T) {
|
|
engine, err := NewEngine("", nil, true)
|
|
require.NoError(t, err)
|
|
|
|
engine.config.Log.Silent = true
|
|
engine.config.Build.Cmd = "exit 1"
|
|
engine.config.Build.StopOnError = true
|
|
|
|
stopped := make(chan struct{})
|
|
shutdown := make(chan chan int, 1)
|
|
engine.binStopCh = shutdown
|
|
go func() {
|
|
closer := <-shutdown
|
|
close(closer)
|
|
close(stopped)
|
|
}()
|
|
|
|
engine.buildRun()
|
|
|
|
select {
|
|
case <-stopped:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("expected failed build with stop_on_error to stop the existing binary")
|
|
}
|
|
}
|
|
|
|
func TestBuildRunKeepsBinaryAfterFailedBuildWithStopOnError(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("uses POSIX shell commands")
|
|
}
|
|
|
|
tmpDir := t.TempDir()
|
|
chdir(t, tmpDir)
|
|
|
|
require.NoError(t, os.Mkdir("tmp", 0o755))
|
|
binPath := filepath.Join("tmp", "dev")
|
|
script := []byte("#!/bin/sh\ntrap 'exit 0' INT TERM\nwhile :; do sleep 1; done\n")
|
|
require.NoError(t, os.WriteFile(binPath, script, 0o755))
|
|
|
|
engine, err := NewEngine("", nil, true)
|
|
require.NoError(t, err)
|
|
engine.config.Log.Silent = true
|
|
engine.config.Build.Cmd = "false"
|
|
engine.config.Build.Entrypoint = entrypoint{"./tmp/dev"}
|
|
engine.config.Build.StopOnError = true
|
|
engine.config.Build.SendInterrupt = true
|
|
engine.config.Build.KillDelay = 100 * time.Millisecond
|
|
|
|
require.NoError(t, engine.runBin())
|
|
defer engine.stopBin()
|
|
|
|
err = waitForCondition(t, time.Second, func() bool {
|
|
started := false
|
|
engine.withLock(func() {
|
|
started = engine.binStopCh != nil
|
|
})
|
|
return started
|
|
}, "initial binary process to start")
|
|
require.NoError(t, err)
|
|
|
|
engine.buildRun()
|
|
|
|
require.FileExists(t, binPath)
|
|
}
|
|
|
|
func TestShouldStopBinBeforeBuild(t *testing.T) {
|
|
tests := []struct {
|
|
goos string
|
|
want bool
|
|
}{
|
|
{goos: PlatformWindows, want: true},
|
|
{goos: PlatformDarwin, want: false},
|
|
{goos: PlatformLinux, want: false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.goos, func(t *testing.T) {
|
|
assert.Equal(t, tt.want, shouldStopBinBeforeBuild(tt.goos))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestStopBinBeforeBuildIfNeeded(t *testing.T) {
|
|
engine, err := NewEngine("", nil, true)
|
|
require.NoError(t, err)
|
|
engine.config.Log.Silent = true
|
|
|
|
stopped := make(chan struct{})
|
|
shutdown := make(chan chan int, 1)
|
|
engine.binStopCh = shutdown
|
|
go func() {
|
|
closer := <-shutdown
|
|
close(closer)
|
|
close(stopped)
|
|
}()
|
|
|
|
engine.stopBinBeforeBuildIfNeeded(PlatformWindows)
|
|
|
|
select {
|
|
case <-stopped:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("expected Windows pre-build stop to stop the existing binary")
|
|
}
|
|
|
|
shutdown = make(chan chan int, 1)
|
|
engine.binStopCh = shutdown
|
|
|
|
engine.stopBinBeforeBuildIfNeeded(PlatformLinux)
|
|
|
|
select {
|
|
case <-shutdown:
|
|
t.Fatal("non-Windows pre-build step should keep the existing binary running")
|
|
default:
|
|
}
|
|
}
|
|
|
|
func GetPort() (int, func()) {
|
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
port := l.Addr().(*net.TCPAddr).Port
|
|
return port, func() {
|
|
_ = l.Close()
|
|
}
|
|
}
|
|
|
|
func TestRebuild(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("unstable on Windows")
|
|
}
|
|
|
|
// generate a random port
|
|
port, f := GetPort()
|
|
f()
|
|
t.Logf("port: %d", port)
|
|
|
|
tmpDir := initTestEnv(t, port)
|
|
// change dir to tmpDir
|
|
chdir(t, tmpDir)
|
|
engine, err := NewEngine("", nil, true)
|
|
engine.config.Build.ExcludeUnchanged = true
|
|
engine.config.Build.PreCmd = []string{"sleep 1"}
|
|
if err != nil {
|
|
t.Fatalf("Should not be fail: %s.", err)
|
|
}
|
|
wg := sync.WaitGroup{}
|
|
wg.Add(1)
|
|
go func() {
|
|
engine.Run()
|
|
t.Logf("engine stopped")
|
|
wg.Done()
|
|
}()
|
|
err = waitingPortReady(t, port, time.Second*10)
|
|
if err != nil {
|
|
t.Fatalf("Should not be fail: %s.", err)
|
|
}
|
|
t.Logf("port is ready")
|
|
|
|
// start rebuild
|
|
|
|
t.Logf("start change main.go")
|
|
// change file of main.go
|
|
err = writeGoCode(tmpDir, port, `http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = w.Write([]byte("rebuilt"))
|
|
})`)
|
|
if err != nil {
|
|
t.Fatalf("Should not be fail: %s.", err)
|
|
}
|
|
|
|
deadline := time.Now().Add(500 * time.Millisecond)
|
|
for time.Now().Before(deadline) {
|
|
if !checkPortHaveBeenUsed(port) {
|
|
t.Fatal("previous binary should keep serving while rebuild is in progress")
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
|
|
err = waitingHTTPBody(t, port, "rebuilt", time.Second*10)
|
|
if err != nil {
|
|
t.Fatalf("Should not be fail: %s.", err)
|
|
}
|
|
t.Logf("port is ready")
|
|
// stop engine
|
|
engine.Stop()
|
|
t.Logf("engine stopped")
|
|
// Wait for engine to fully stop
|
|
err = waitForEngineState(t, engine, false, time.Second*3)
|
|
if err != nil {
|
|
t.Fatalf("engine did not stop: %s.", err)
|
|
}
|
|
wg.Wait()
|
|
assert.True(t, checkPortConnectionRefused(port))
|
|
}
|
|
|
|
func waitingHTTPBody(t *testing.T, port int, want string, timeout time.Duration) error {
|
|
t.Helper()
|
|
t.Logf("waiting port %d HTTP body %q", port, want)
|
|
|
|
client := http.Client{Timeout: 200 * time.Millisecond}
|
|
deadline := time.Now().Add(timeout)
|
|
ticker := time.NewTicker(20 * time.Millisecond)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
resp, err := client.Get(fmt.Sprintf("http://127.0.0.1:%d/", port))
|
|
if err == nil {
|
|
body, readErr := io.ReadAll(resp.Body)
|
|
_ = resp.Body.Close()
|
|
if readErr == nil && string(body) == want {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
if time.Now().After(deadline) {
|
|
return fmt.Errorf("timeout waiting for port %d HTTP body %q", port, want)
|
|
}
|
|
<-ticker.C
|
|
}
|
|
}
|
|
|
|
func waitingPortConnectionRefused(t *testing.T, port int, timeout time.Duration) error {
|
|
t.Helper()
|
|
t.Logf("waiting port %d connection refused", port)
|
|
|
|
// Use environment-aware timeout for CI compatibility
|
|
timeoutMultiplier := 1.0
|
|
if os.Getenv("CI") != "" {
|
|
timeoutMultiplier = 2.0
|
|
}
|
|
adjustedTimeout := time.Duration(float64(timeout) * timeoutMultiplier)
|
|
|
|
deadline := time.Now().Add(adjustedTimeout)
|
|
ticker := time.NewTicker(20 * time.Millisecond) // Reduced from 100ms to 20ms
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
_, err := net.Dial("tcp", fmt.Sprintf("localhost:%d", port))
|
|
if errors.Is(err, syscall.ECONNREFUSED) {
|
|
return nil
|
|
}
|
|
|
|
if time.Now().After(deadline) {
|
|
return fmt.Errorf("timeout waiting for port %d connection refused (timeout: %v)", port, adjustedTimeout)
|
|
}
|
|
|
|
<-ticker.C
|
|
}
|
|
}
|
|
|
|
func TestCtrlCWhenHaveKillDelay(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("unstable on Windows")
|
|
}
|
|
|
|
// fix https://github.com/air-verse/air/issues/278
|
|
// generate a random port
|
|
data := []byte("[build]\n kill_delay = \"2s\"")
|
|
c := Config{}
|
|
if err := toml.Unmarshal(data, &c); err != nil {
|
|
t.Fatalf("Should not be fail: %s.", err)
|
|
}
|
|
|
|
port, f := GetPort()
|
|
f()
|
|
t.Logf("port: %d", port)
|
|
|
|
tmpDir := initTestEnv(t, port)
|
|
// change dir to tmpDir
|
|
chdir(t, tmpDir)
|
|
engine, err := NewEngine("", nil, true)
|
|
if err != nil {
|
|
t.Fatalf("Should not be fail: %s.", err)
|
|
}
|
|
engine.config.Build.KillDelay = c.Build.KillDelay
|
|
engine.config.Build.Delay = 2000
|
|
engine.config.Build.SendInterrupt = true
|
|
if err := engine.config.preprocess(nil); err != nil {
|
|
t.Fatalf("Should not be fail: %s.", err)
|
|
}
|
|
|
|
go func() {
|
|
engine.Run()
|
|
t.Logf("engine stopped")
|
|
}()
|
|
sigs := make(chan os.Signal, 1)
|
|
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
|
|
go func() {
|
|
<-sigs
|
|
engine.Stop()
|
|
t.Logf("engine stopped")
|
|
}()
|
|
if err := waitingPortReady(t, port, time.Second*10); err != nil {
|
|
t.Fatalf("Should not be fail: %s.", err)
|
|
}
|
|
sigs <- syscall.SIGINT
|
|
err = waitingPortConnectionRefused(t, port, time.Second*10)
|
|
if err != nil {
|
|
t.Fatalf("Should not be fail: %s.", err)
|
|
}
|
|
// Wait for engine to fully stop - the test has kill_delay="2s"
|
|
err = waitForEngineState(t, engine, false, time.Second*5)
|
|
if err != nil {
|
|
t.Logf("engine may not have stopped in time: %s", err)
|
|
}
|
|
assert.False(t, engine.running.Load())
|
|
}
|
|
|
|
func TestCtrlCWhenREngineIsRunning(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("unstable on Windows")
|
|
}
|
|
|
|
// generate a random port
|
|
port, f := GetPort()
|
|
f()
|
|
t.Logf("port: %d", port)
|
|
|
|
tmpDir := initTestEnv(t, port)
|
|
// change dir to tmpDir
|
|
chdir(t, tmpDir)
|
|
engine, err := NewEngine("", nil, true)
|
|
if err != nil {
|
|
t.Fatalf("Should not be fail: %s.", err)
|
|
}
|
|
go func() {
|
|
engine.Run()
|
|
t.Logf("engine stopped")
|
|
}()
|
|
sigs := make(chan os.Signal, 1)
|
|
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
|
|
go func() {
|
|
<-sigs
|
|
engine.Stop()
|
|
t.Logf("engine stopped")
|
|
}()
|
|
if err := waitingPortReady(t, port, time.Second*10); err != nil {
|
|
t.Fatalf("Should not be fail: %s.", err)
|
|
}
|
|
sigs <- syscall.SIGINT
|
|
time.Sleep(time.Second * 1)
|
|
err = waitingPortConnectionRefused(t, port, time.Second*10)
|
|
if err != nil {
|
|
t.Fatalf("Should not be fail: %s.", err)
|
|
}
|
|
assert.False(t, engine.running.Load())
|
|
}
|
|
|
|
func TestCtrlCWithFailedBin(t *testing.T) {
|
|
timeout := 5 * time.Second
|
|
done := make(chan struct{})
|
|
go func() {
|
|
dir := initWithQuickExitGoCode(t)
|
|
chdir(t, dir)
|
|
engine, err := NewEngine("", nil, true)
|
|
assert.NoError(t, err)
|
|
engine.config.Build.Bin = "<WRONGCOMAMND>"
|
|
sigs := make(chan os.Signal, 1)
|
|
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
|
|
var wg sync.WaitGroup
|
|
wg.Add(1)
|
|
go func() {
|
|
engine.Run()
|
|
t.Logf("engine stopped")
|
|
wg.Done()
|
|
}()
|
|
go func() {
|
|
<-sigs
|
|
engine.Stop()
|
|
t.Logf("engine stopped")
|
|
}()
|
|
time.Sleep(time.Second * 1)
|
|
sigs <- syscall.SIGINT
|
|
wg.Wait()
|
|
close(done)
|
|
}()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(timeout):
|
|
t.Error("Test timed out")
|
|
}
|
|
}
|
|
|
|
func TestFixCloseOfChannelAfterCtrlC(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("unstable on Windows")
|
|
}
|
|
|
|
// fix https://github.com/air-verse/air/issues/294
|
|
dir := initWithBuildFailedCode(t)
|
|
chdir(t, dir)
|
|
engine, err := NewEngine("", nil, true)
|
|
if err != nil {
|
|
t.Fatalf("Should not be fail: %s.", err)
|
|
}
|
|
// Silence engine logs to keep this test output readable.
|
|
engine.config.Log.Silent = true
|
|
silenceBuildCmd(engine.config)
|
|
sigs := make(chan os.Signal, 1)
|
|
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
|
|
defer signal.Stop(sigs)
|
|
go func() {
|
|
engine.Run()
|
|
t.Logf("engine stopped")
|
|
}()
|
|
|
|
go func() {
|
|
<-sigs
|
|
engine.Stop()
|
|
t.Logf("engine stopped")
|
|
}()
|
|
buildLogPath := engine.config.buildLogPath()
|
|
if err := waitForCondition(t, time.Second*5, func() bool {
|
|
info, err := os.Stat(buildLogPath)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return info.Size() > 0
|
|
}, "first build failure log"); err != nil {
|
|
t.Fatalf("build did not fail as expected: %s", err)
|
|
}
|
|
port, f := GetPort()
|
|
f()
|
|
// correct code
|
|
err = generateGoCode(dir, port)
|
|
if err != nil {
|
|
t.Fatalf("Should not be fail: %s.", err)
|
|
}
|
|
|
|
if err := waitingPortReady(t, port, time.Second*10); err != nil {
|
|
t.Fatalf("Should not be fail: %s.", err)
|
|
}
|
|
|
|
// ctrl + c
|
|
sigs <- syscall.SIGINT
|
|
if err := waitingPortConnectionRefused(t, port, time.Second*10); err != nil {
|
|
t.Fatalf("Should not be fail: %s.", err)
|
|
}
|
|
if err := waitForEngineState(t, engine, false, time.Second*5); err != nil {
|
|
t.Fatalf("engine did not stop: %s", err)
|
|
}
|
|
assert.False(t, engine.running.Load())
|
|
}
|
|
|
|
func TestFixCloseOfChannelAfterTwoFailedBuild(t *testing.T) {
|
|
// fix https://github.com/air-verse/air/issues/294
|
|
// happens after two failed builds
|
|
dir := initWithBuildFailedCode(t)
|
|
// change dir to tmpDir
|
|
chdir(t, dir)
|
|
engine, err := NewEngine("", nil, true)
|
|
if err != nil {
|
|
t.Fatalf("Should not be fail: %s.", err)
|
|
}
|
|
engine.config.Log.Silent = true
|
|
silenceBuildCmd(engine.config)
|
|
sigs := make(chan os.Signal, 1)
|
|
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
|
|
go func() {
|
|
engine.Run()
|
|
t.Logf("engine stopped")
|
|
}()
|
|
|
|
go func() {
|
|
<-sigs
|
|
engine.Stop()
|
|
t.Logf("engine stopped")
|
|
}()
|
|
|
|
// Wait for first build to complete (with error) - reduced from 3s to 1s
|
|
// Since the build fails immediately, 1s is sufficient
|
|
time.Sleep(time.Millisecond * 500)
|
|
|
|
// edit *.go file to create build error again
|
|
file, err := os.OpenFile("main.go", os.O_APPEND|os.O_WRONLY, 0o644)
|
|
if err != nil {
|
|
t.Fatalf("Should not be fail: %s.", err)
|
|
}
|
|
defer file.Close()
|
|
_, err = file.WriteString("\n")
|
|
if err != nil {
|
|
t.Fatalf("Should not be fail: %s.", err)
|
|
}
|
|
// Wait for second build attempt - reduced from 3s to 500ms
|
|
time.Sleep(time.Millisecond * 500)
|
|
// ctrl + c
|
|
sigs <- syscall.SIGINT
|
|
// Wait for engine to stop
|
|
err = waitForEngineState(t, engine, false, time.Second*3)
|
|
if err != nil {
|
|
t.Logf("engine may not have stopped cleanly: %s", err)
|
|
}
|
|
assert.False(t, engine.running.Load())
|
|
}
|
|
|
|
// waitingPortReady waits until the port is ready to be used.
|
|
func waitingPortReady(t *testing.T, port int, timeout time.Duration) error {
|
|
t.Helper()
|
|
t.Logf("waiting port %d ready", port)
|
|
|
|
// Use environment-aware timeout for CI compatibility
|
|
timeoutMultiplier := 1.0
|
|
if os.Getenv("CI") != "" {
|
|
timeoutMultiplier = 2.0
|
|
}
|
|
adjustedTimeout := time.Duration(float64(timeout) * timeoutMultiplier)
|
|
|
|
deadline := time.Now().Add(adjustedTimeout)
|
|
ticker := time.NewTicker(20 * time.Millisecond) // Reduced from 100ms to 20ms
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
conn, err := net.Dial("tcp", fmt.Sprintf("localhost:%d", port))
|
|
if err == nil {
|
|
_ = conn.Close()
|
|
return nil
|
|
}
|
|
|
|
if time.Now().After(deadline) {
|
|
return fmt.Errorf("timeout waiting for port %d ready (timeout: %v)", port, adjustedTimeout)
|
|
}
|
|
|
|
<-ticker.C
|
|
}
|
|
}
|
|
|
|
func TestRun(t *testing.T) {
|
|
// generate a random port
|
|
port, f := GetPort()
|
|
f()
|
|
t.Logf("port: %d", port)
|
|
|
|
tmpDir := initTestEnv(t, port)
|
|
// change dir to tmpDir
|
|
chdir(t, tmpDir)
|
|
engine, err := NewEngine("", nil, true)
|
|
if err != nil {
|
|
t.Fatalf("Should not be fail: %s.", err)
|
|
}
|
|
|
|
go func() {
|
|
engine.Run()
|
|
}()
|
|
|
|
// Wait for port to be ready instead of fixed sleep
|
|
err = waitingPortReady(t, port, time.Second*10)
|
|
if err != nil {
|
|
t.Fatalf("Should not be fail: %s.", err)
|
|
}
|
|
assert.True(t, checkPortHaveBeenUsed(port))
|
|
t.Logf("try to stop")
|
|
engine.Stop()
|
|
|
|
// Wait for engine to stop instead of fixed sleep
|
|
err = waitForEngineState(t, engine, false, time.Second*3)
|
|
if err != nil {
|
|
t.Fatalf("engine did not stop: %s.", err)
|
|
}
|
|
assert.False(t, checkPortHaveBeenUsed(port))
|
|
t.Logf("stopped")
|
|
}
|
|
|
|
func checkPortConnectionRefused(port int) bool {
|
|
conn, err := net.Dial("tcp", fmt.Sprintf("localhost:%d", port))
|
|
defer func() {
|
|
if conn != nil {
|
|
_ = conn.Close()
|
|
}
|
|
}()
|
|
return errors.Is(err, syscall.ECONNREFUSED)
|
|
}
|
|
|
|
func checkPortHaveBeenUsed(port int) bool {
|
|
conn, err := net.Dial("tcp", fmt.Sprintf("localhost:%d", port))
|
|
if err != nil {
|
|
return false
|
|
}
|
|
_ = conn.Close()
|
|
return true
|
|
}
|
|
|
|
func initTestEnv(t *testing.T, port int) string {
|
|
tempDir := t.TempDir()
|
|
t.Setenv(airWd, tempDir)
|
|
t.Logf("tempDir: %s", tempDir)
|
|
// generate golang code to tempdir
|
|
err := generateGoCode(tempDir, port)
|
|
if err != nil {
|
|
t.Fatalf("Should not be fail: %s.", err)
|
|
}
|
|
return tempDir
|
|
}
|
|
|
|
func initWithBuildFailedCode(t *testing.T) string {
|
|
tempDir := t.TempDir()
|
|
t.Setenv(airWd, tempDir)
|
|
t.Logf("tempDir: %s", tempDir)
|
|
// generate golang code to tempdir
|
|
err := generateBuildErrorGoCode(tempDir)
|
|
if err != nil {
|
|
t.Fatalf("Should not be fail: %s.", err)
|
|
}
|
|
return tempDir
|
|
}
|
|
|
|
func initWithQuickExitGoCode(t *testing.T) string {
|
|
tempDir := t.TempDir()
|
|
t.Setenv(airWd, tempDir)
|
|
t.Logf("tempDir: %s", tempDir)
|
|
// generate golang code to tempdir
|
|
err := generateQuickExitGoCode(tempDir)
|
|
if err != nil {
|
|
t.Fatalf("Should not be fail: %s.", err)
|
|
}
|
|
return tempDir
|
|
}
|
|
|
|
func generateQuickExitGoCode(dir string) error {
|
|
code := `package main
|
|
// You can edit this code!
|
|
// Click here and start typing.
|
|
|
|
import "fmt"
|
|
|
|
func main() {
|
|
fmt.Println("Hello, 世界")
|
|
}
|
|
`
|
|
file, err := os.Create(dir + "/main.go")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = file.WriteString(code)
|
|
if err != nil {
|
|
_ = file.Close()
|
|
return err
|
|
}
|
|
if err := file.Close(); err != nil {
|
|
return err
|
|
}
|
|
|
|
// generate go mod file
|
|
mod := `module air.sample.com
|
|
|
|
go 1.17
|
|
`
|
|
file, err = os.Create(dir + "/go.mod")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = file.WriteString(mod)
|
|
if err != nil {
|
|
_ = file.Close()
|
|
return err
|
|
}
|
|
if err := file.Close(); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func generateBuildErrorGoCode(dir string) error {
|
|
code := `package main
|
|
// You can edit this code!
|
|
// Click here and start typing.
|
|
|
|
func main() {
|
|
Println("Hello, 世界")
|
|
|
|
}
|
|
`
|
|
file, err := os.Create(dir + "/main.go")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = file.WriteString(code)
|
|
if err != nil {
|
|
_ = file.Close()
|
|
return err
|
|
}
|
|
if err := file.Close(); err != nil {
|
|
return err
|
|
}
|
|
|
|
// generate go mod file
|
|
mod := `module air.sample.com
|
|
|
|
go 1.17
|
|
`
|
|
file, err = os.Create(dir + "/go.mod")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = file.WriteString(mod)
|
|
if err != nil {
|
|
_ = file.Close()
|
|
return err
|
|
}
|
|
if err := file.Close(); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func writeGoCode(dir string, port int, setup string) error {
|
|
code := fmt.Sprintf(`package main
|
|
|
|
import (
|
|
"log"
|
|
"net/http"
|
|
)
|
|
|
|
func main() {
|
|
%s
|
|
log.Fatal(http.ListenAndServe("127.0.0.1:%v", nil))
|
|
}
|
|
`, setup, port)
|
|
file, err := os.Create(dir + "/main.go")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = file.WriteString(code)
|
|
if err != nil {
|
|
_ = file.Close()
|
|
return err
|
|
}
|
|
if err := file.Close(); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// generateGoCode generates golang code to tempdir
|
|
func generateGoCode(dir string, port int) error {
|
|
if err := writeGoCode(dir, port, ""); err != nil {
|
|
return err
|
|
}
|
|
|
|
// generate go mod file
|
|
mod := `module air.sample.com
|
|
|
|
go 1.17
|
|
`
|
|
file, err := os.Create(dir + "/go.mod")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = file.WriteString(mod)
|
|
if err != nil {
|
|
_ = file.Close()
|
|
return err
|
|
}
|
|
if err := file.Close(); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func silenceBuildCmd(cfg *Config) {
|
|
if cfg == nil {
|
|
return
|
|
}
|
|
if runtime.GOOS == "windows" {
|
|
cfg.Build.Cmd = fmt.Sprintf("%s > $null 2>&1", cfg.Build.Cmd)
|
|
return
|
|
}
|
|
cfg.Build.Cmd = fmt.Sprintf("%s >/dev/null 2>&1", cfg.Build.Cmd)
|
|
}
|
|
|
|
func TestRebuildWhenRunCmdUsingDLV(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("requires touch")
|
|
}
|
|
|
|
if _, err := exec.LookPath("dlv"); err != nil {
|
|
t.Skip("dlv not available in PATH")
|
|
}
|
|
|
|
// generate a random port
|
|
port, f := GetPort()
|
|
f()
|
|
t.Logf("port: %d", port)
|
|
tmpDir := initTestEnv(t, port)
|
|
// change dir to tmpDir
|
|
chdir(t, tmpDir)
|
|
engine, err := NewEngine("", nil, true)
|
|
if err != nil {
|
|
t.Fatalf("Should not be fail: %s.", err)
|
|
}
|
|
engine.config.Build.Cmd = "go build -gcflags='all=-N -l' -o ./tmp/main ."
|
|
engine.config.Build.Bin = ""
|
|
dlvPort, f := GetPort()
|
|
f()
|
|
engine.config.Build.FullBin = fmt.Sprintf("dlv exec --accept-multiclient --log --headless --continue --listen :%d --api-version 2 ./tmp/main", dlvPort)
|
|
_ = engine.config.preprocess(nil)
|
|
go func() {
|
|
engine.Run()
|
|
}()
|
|
if err := waitingPortReady(t, port, time.Second*40); err != nil {
|
|
t.Fatalf("Should not be fail: %s.", err)
|
|
}
|
|
|
|
t.Logf("start change main.go")
|
|
// change file of main.go
|
|
// just append a new empty line to main.go
|
|
go func() {
|
|
file, err := os.OpenFile("main.go", os.O_APPEND|os.O_WRONLY, 0o644)
|
|
if err != nil {
|
|
log.Fatalf("Should not be fail: %s.", err)
|
|
}
|
|
defer file.Close()
|
|
_, err = file.WriteString("\n")
|
|
if err != nil {
|
|
log.Fatalf("Should not be fail: %s.", err)
|
|
}
|
|
}()
|
|
err = waitingPortConnectionRefused(t, port, time.Second*10)
|
|
if err != nil {
|
|
t.Fatalf("timeout: %s.", err)
|
|
}
|
|
t.Logf("connection refused")
|
|
err = waitingPortReady(t, port, time.Second*40)
|
|
if err != nil {
|
|
t.Fatalf("Should not be fail: %s.", err)
|
|
}
|
|
t.Logf("port is ready")
|
|
// stop engine
|
|
engine.Stop()
|
|
// Wait for engine to stop
|
|
err = waitForEngineState(t, engine, false, time.Second*5)
|
|
if err != nil {
|
|
t.Fatalf("engine did not stop: %s.", err)
|
|
}
|
|
t.Logf("engine stopped")
|
|
assert.True(t, checkPortConnectionRefused(port))
|
|
}
|
|
|
|
func TestWriteDefaultConfig(t *testing.T) {
|
|
port, f := GetPort()
|
|
f()
|
|
t.Logf("port: %d", port)
|
|
|
|
tmpDir := initTestEnv(t, port)
|
|
// change dir to tmpDir
|
|
chdir(t, tmpDir)
|
|
configName, err := writeDefaultConfig()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// check the file exists
|
|
if _, err := os.Stat(configName); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
raw, err := os.ReadFile(configName)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
expectedPrefix := schemaHeader + "\n\n"
|
|
assert.True(t, strings.HasPrefix(string(raw), expectedPrefix), "config should start with schema header")
|
|
|
|
// check the file content is right
|
|
actual, err := readConfig(configName)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
expect := defaultConfigBase()
|
|
setEntrypointFromBin(&expect)
|
|
addPlatformOverridesForInit(&expect, runtime.GOOS)
|
|
if expect.Build.Windows != nil {
|
|
if expect.Build.Windows.PreCmd == nil {
|
|
expect.Build.Windows.PreCmd = []string{}
|
|
}
|
|
if expect.Build.Windows.PostCmd == nil {
|
|
expect.Build.Windows.PostCmd = []string{}
|
|
}
|
|
if expect.Build.Windows.ArgsBin == nil {
|
|
expect.Build.Windows.ArgsBin = []string{}
|
|
}
|
|
}
|
|
|
|
assert.Equal(t, expect, *actual)
|
|
}
|
|
|
|
func TestCheckNilSliceShouldBeenOverwrite(t *testing.T) {
|
|
port, f := GetPort()
|
|
f()
|
|
t.Logf("port: %d", port)
|
|
|
|
tmpDir := initTestEnv(t, port)
|
|
|
|
// change dir to tmpDir
|
|
chdir(t, tmpDir)
|
|
|
|
// write easy config file
|
|
|
|
config := `
|
|
[build]
|
|
cmd = "go build ."
|
|
bin = "tmp/main"
|
|
exclude_regex = []
|
|
exclude_dir = ["test"]
|
|
exclude_file = ["main.go"]
|
|
include_file = ["test/not_a_test.go"]
|
|
|
|
`
|
|
if err := os.WriteFile(dftTOML, []byte(config), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
engine, err := NewEngine(".air.toml", nil, true)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assert.Equal(t, []string{"go", "tpl", "tmpl", "html"}, engine.config.Build.IncludeExt)
|
|
assert.Equal(t, []string{}, engine.config.Build.ExcludeRegex)
|
|
assert.Equal(t, []string{"test"}, engine.config.Build.ExcludeDir)
|
|
// add new config
|
|
assert.Equal(t, []string{"main.go"}, engine.config.Build.ExcludeFile)
|
|
assert.Equal(t, []string{"test/not_a_test.go"}, engine.config.Build.IncludeFile)
|
|
assert.Equal(t, "go build .", engine.config.Build.Cmd)
|
|
}
|
|
|
|
func TestShouldIncludeGoTestFile(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("requires sed")
|
|
}
|
|
|
|
port, f := GetPort()
|
|
f()
|
|
t.Logf("port: %d", port)
|
|
|
|
tmpDir := initTestEnv(t, port)
|
|
// change dir to tmpDir
|
|
chdir(t, tmpDir)
|
|
_, err := writeDefaultConfig()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// write go test file
|
|
file, err := os.Create("main_test.go")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err = file.WriteString(`package main
|
|
|
|
import "testing"
|
|
|
|
func Test(t *testing.T) {
|
|
t.Log("testing")
|
|
}
|
|
`)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// run sed
|
|
// check the file exists
|
|
if _, err := os.Stat(dftTOML); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// check is MacOS
|
|
var cmd *exec.Cmd
|
|
toolName := "sed"
|
|
|
|
if runtime.GOOS == "darwin" {
|
|
toolName = "gsed"
|
|
}
|
|
|
|
cmd = exec.Command(toolName, "-i", "s/\"_test.*go\"//g", ".air.toml")
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
if err := cmd.Run(); err != nil {
|
|
t.Skipf("unable to run %s, make sure the tool is installed to run this test", toolName)
|
|
}
|
|
|
|
time.Sleep(time.Second * 2)
|
|
engine, err := NewEngine(".air.toml", nil, false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
go func() {
|
|
engine.Run()
|
|
}()
|
|
|
|
t.Logf("start change main_test.go")
|
|
// change file of main_test.go
|
|
// just append a new empty line to main_test.go
|
|
if err = waitingPortReady(t, port, time.Second*40); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
go func() {
|
|
file, err = os.OpenFile("main_test.go", os.O_APPEND|os.O_WRONLY, 0o644)
|
|
assert.NoError(t, err)
|
|
defer file.Close()
|
|
_, err = file.WriteString("\n")
|
|
assert.NoError(t, err)
|
|
}()
|
|
// should Have rebuild
|
|
if err = waitingPortReady(t, port, time.Second*10); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestCreateNewDir(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("requires touch")
|
|
}
|
|
|
|
// generate a random port
|
|
port, f := GetPort()
|
|
f()
|
|
t.Logf("port: %d", port)
|
|
|
|
tmpDir := initTestEnv(t, port)
|
|
// change dir to tmpDir
|
|
chdir(t, tmpDir)
|
|
engine, err := NewEngine("", nil, true)
|
|
if err != nil {
|
|
t.Fatalf("Should not be fail: %s.", err)
|
|
}
|
|
|
|
go func() {
|
|
engine.Run()
|
|
}()
|
|
if err := waitingPortReady(t, port, 5*time.Second); err != nil {
|
|
t.Fatalf("Should not be fail: %s.", err)
|
|
}
|
|
|
|
// create a new dir make dir
|
|
if err = os.Mkdir(tmpDir+"/dir", 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// no need reload
|
|
if err = waitingPortConnectionRefused(t, port, 3*time.Second); err == nil {
|
|
t.Fatal("should raise a error")
|
|
}
|
|
engine.Stop()
|
|
time.Sleep(2 * time.Second)
|
|
}
|
|
|
|
func TestShouldIncludeIncludedFile(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("requires sh")
|
|
}
|
|
|
|
port, f := GetPort()
|
|
f()
|
|
t.Logf("port: %d", port)
|
|
|
|
tmpDir := initTestEnv(t, port)
|
|
|
|
chdir(t, tmpDir)
|
|
|
|
config := `
|
|
[build]
|
|
cmd = "true" # do nothing
|
|
full_bin = "sh main.sh"
|
|
include_ext = ["sh"]
|
|
include_dir = ["nonexist"] # prevent default "." watch from taking effect
|
|
include_file = ["main.sh"]
|
|
`
|
|
if err := os.WriteFile(dftTOML, []byte(config), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
err := os.WriteFile("main.sh", []byte("#!/bin/sh\nprintf original > output"), 0o755)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
engine, err := NewEngine(dftTOML, nil, false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
go func() {
|
|
engine.Run()
|
|
}()
|
|
|
|
time.Sleep(time.Second * 1)
|
|
|
|
bytes, err := os.ReadFile("output")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assert.Equal(t, []byte("original"), bytes)
|
|
|
|
t.Logf("start change main.sh")
|
|
go func() {
|
|
err := os.WriteFile("main.sh", []byte("#!/bin/sh\nprintf modified > output"), 0o755)
|
|
if err != nil {
|
|
log.Fatalf("Error updating file: %s.", err)
|
|
}
|
|
}()
|
|
|
|
time.Sleep(time.Second * 3)
|
|
|
|
bytes, err = os.ReadFile("output")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assert.Equal(t, []byte("modified"), bytes)
|
|
}
|
|
|
|
func TestShouldIncludeIncludedFileWithoutIncludedExt(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("requires sh")
|
|
}
|
|
|
|
port, f := GetPort()
|
|
f()
|
|
t.Logf("port: %d", port)
|
|
|
|
tmpDir := initTestEnv(t, port)
|
|
|
|
chdir(t, tmpDir)
|
|
|
|
config := `
|
|
[build]
|
|
cmd = "true" # do nothing
|
|
full_bin = "sh main.sh"
|
|
include_ext = ["go"]
|
|
include_dir = ["nonexist"] # prevent default "." watch from taking effect
|
|
include_file = ["main.sh"]
|
|
`
|
|
if err := os.WriteFile(dftTOML, []byte(config), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
err := os.WriteFile("main.sh", []byte("#!/bin/sh\nprintf original > output"), 0o755)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
engine, err := NewEngine(dftTOML, nil, false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
go func() {
|
|
engine.Run()
|
|
}()
|
|
|
|
time.Sleep(time.Second * 1)
|
|
|
|
bytes, err := os.ReadFile("output")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assert.Equal(t, []byte("original"), bytes)
|
|
|
|
t.Logf("start change main.sh")
|
|
go func() {
|
|
err = os.WriteFile("main.sh", []byte("#!/bin/sh\nprintf modified > output"), 0o755)
|
|
if err != nil {
|
|
log.Fatalf("Error updating file: %s.", err)
|
|
}
|
|
}()
|
|
|
|
time.Sleep(time.Second * 3)
|
|
|
|
bytes, err = os.ReadFile("output")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assert.Equal(t, []byte("modified"), bytes)
|
|
}
|
|
|
|
type testExiter struct {
|
|
t *testing.T
|
|
called bool
|
|
expectCode int
|
|
}
|
|
|
|
func (te *testExiter) Exit(code int) {
|
|
te.called = true
|
|
if code != te.expectCode {
|
|
te.t.Fatalf("expected exit code %d, got %d", te.expectCode, code)
|
|
}
|
|
}
|
|
|
|
func TestEngineExit(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
setup func(*Engine, chan<- int)
|
|
expectCode int
|
|
wantCalled bool
|
|
}{
|
|
{
|
|
name: "normal exit - no error",
|
|
setup: func(_ *Engine, exitCode chan<- int) {
|
|
go func() {
|
|
exitCode <- 0
|
|
}()
|
|
},
|
|
expectCode: 0,
|
|
wantCalled: false,
|
|
},
|
|
{
|
|
name: "error exit - non-zero code",
|
|
setup: func(_ *Engine, exitCode chan<- int) {
|
|
go func() {
|
|
exitCode <- 1
|
|
}()
|
|
},
|
|
expectCode: 1,
|
|
wantCalled: true,
|
|
},
|
|
{
|
|
name: "process timeout",
|
|
setup: func(_ *Engine, _ chan<- int) {
|
|
},
|
|
expectCode: 0,
|
|
wantCalled: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
e, err := NewEngine("", nil, true)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
exiter := &testExiter{
|
|
t: t,
|
|
expectCode: tt.expectCode,
|
|
}
|
|
e.exiter = exiter
|
|
|
|
exitCode := make(chan int)
|
|
|
|
if tt.setup != nil {
|
|
tt.setup(e, exitCode)
|
|
}
|
|
select {
|
|
case ret := <-exitCode:
|
|
if ret != 0 {
|
|
e.exiter.Exit(ret)
|
|
}
|
|
case <-time.After(1 * time.Millisecond):
|
|
// timeout case
|
|
}
|
|
|
|
if tt.wantCalled != exiter.called {
|
|
t.Errorf("Exit() called = %v, want %v", exiter.called, tt.wantCalled)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestBuildRunRaceCondition tests that a new build does not receive
|
|
// stop signals meant for a previous build. This is a regression test for issue #784.
|
|
//
|
|
// The fix uses a channel-of-channels pattern where each build gets its own unique
|
|
// stop channel. When a new build is triggered, it retrieves the previous build's
|
|
// stop channel and closes it to signal cancellation.
|
|
func TestBuildRunRaceCondition(t *testing.T) {
|
|
e, err := NewEngine("", nil, true)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
e.config.Log.Silent = true
|
|
|
|
// Simulate the race condition scenario from issue #784:
|
|
// 1. Build A starts and puts its stop channel in buildRunCh
|
|
// 2. Build B is triggered, retrieves Build A's channel and closes it
|
|
// 3. Build B puts its own fresh channel in buildRunCh
|
|
// 4. Build B should NOT be affected by Build A's closed channel
|
|
|
|
// Simulate Build A putting its stop channel in buildRunCh
|
|
buildAStopCh := make(chan struct{})
|
|
e.buildRunCh <- buildAStopCh
|
|
|
|
// Simulate Build B being triggered (mimics what start() does)
|
|
var retrievedChannel chan struct{}
|
|
select {
|
|
case retrievedChannel = <-e.buildRunCh:
|
|
close(retrievedChannel) // Signal Build A to stop
|
|
default:
|
|
t.Fatal("Expected Build A's stop channel to be in buildRunCh")
|
|
}
|
|
|
|
// Verify we got Build A's channel
|
|
if retrievedChannel != buildAStopCh {
|
|
t.Error("Should have retrieved Build A's stop channel")
|
|
}
|
|
|
|
// Verify Build A's channel is closed
|
|
select {
|
|
case <-buildAStopCh:
|
|
// Good - Build A was signaled to stop
|
|
default:
|
|
t.Error("Build A's stop channel should have been closed")
|
|
}
|
|
|
|
// Now simulate Build B starting with its own channel
|
|
buildBStopCh := make(chan struct{})
|
|
e.buildRunCh <- buildBStopCh
|
|
|
|
// Build B should NOT be affected by Build A's closed channel
|
|
select {
|
|
case <-buildBStopCh:
|
|
t.Error("Build B's stop channel should NOT be closed yet")
|
|
case <-time.After(50 * time.Millisecond):
|
|
// Good - Build B is still running
|
|
}
|
|
|
|
// Test that closing Build B's channel does signal Build B to stop
|
|
close(buildBStopCh)
|
|
select {
|
|
case <-buildBStopCh:
|
|
// Good - Build B received the stop signal
|
|
case <-time.After(50 * time.Millisecond):
|
|
t.Error("Build B should have been stopped when its channel was closed")
|
|
}
|
|
|
|
// Clean up - remove Build B's channel from buildRunCh
|
|
select {
|
|
case <-e.buildRunCh:
|
|
// Successfully cleaned up
|
|
default:
|
|
t.Error("Expected Build B's channel to still be in buildRunCh")
|
|
}
|
|
}
|
|
|
|
// TestBuildRunRaceConditionRapidChanges tests rapid file changes don't cause deadlock
|
|
func TestBuildRunRaceConditionRapidChanges(t *testing.T) {
|
|
e, err := NewEngine("", nil, true)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
e.config.Log.Silent = true
|
|
|
|
// Simulate 5 rapid builds in succession
|
|
channels := make([]chan struct{}, 5)
|
|
|
|
for i := 0; i < 5; i++ {
|
|
// If there's a previous build, stop it
|
|
select {
|
|
case oldCh := <-e.buildRunCh:
|
|
close(oldCh)
|
|
default:
|
|
}
|
|
|
|
// Start new build
|
|
channels[i] = make(chan struct{})
|
|
e.buildRunCh <- channels[i]
|
|
}
|
|
|
|
// All previous builds should be signaled to stop
|
|
for i := 0; i < 4; i++ {
|
|
select {
|
|
case <-channels[i]:
|
|
// Good - was signaled to stop
|
|
default:
|
|
t.Errorf("Build %d should have been signaled to stop", i)
|
|
}
|
|
}
|
|
|
|
// Last build should NOT be stopped
|
|
select {
|
|
case <-channels[4]:
|
|
t.Error("Last build should still be running")
|
|
default:
|
|
// Good
|
|
}
|
|
|
|
// Clean up
|
|
<-e.buildRunCh
|
|
}
|
|
|
|
func TestEngineLoadEnvFile(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
envPath := filepath.Join(tmpDir, ".env")
|
|
|
|
originalValue := "original_global_value"
|
|
t.Setenv("TEST_GLOBAL_VAR", originalValue)
|
|
|
|
const initialEnv = `TEST_VAR1=value1
|
|
TEST_VAR2=value2
|
|
TEST_GLOBAL_VAR=overridden_value
|
|
`
|
|
|
|
err := os.WriteFile(envPath, []byte(initialEnv), 0o644)
|
|
require.NoError(t, err)
|
|
|
|
cfg := defaultConfig()
|
|
cfg.Root = tmpDir
|
|
cfg.EnvFiles = []string{".env"}
|
|
|
|
engine, err := NewEngineWithConfig(&cfg, false)
|
|
require.NoError(t, err)
|
|
|
|
engine.loadEnvFile()
|
|
|
|
assert.Equal(t, "value1", os.Getenv("TEST_VAR1"), "TEST_VAR1 should be set")
|
|
assert.Equal(t, "value2", os.Getenv("TEST_VAR2"), "TEST_VAR2 should be set")
|
|
assert.Equal(t, "original_global_value", os.Getenv("TEST_GLOBAL_VAR"), "TEST_GLOBAL_VAR should NOT be overridden")
|
|
|
|
// remove TEST_VAR2
|
|
const updatedEnv = `TEST_VAR1=updated_value1
|
|
TEST_GLOBAL_VAR=still_overridden
|
|
`
|
|
err = os.WriteFile(envPath, []byte(updatedEnv), 0o644)
|
|
require.NoError(t, err)
|
|
|
|
engine.loadEnvFile()
|
|
|
|
assert.Equal(t, "updated_value1", os.Getenv("TEST_VAR1"), "TEST_VAR1 should be updated")
|
|
// since TEST_VAR2 only exists in environment thanks to air, it should get unset on removal
|
|
_, exists := os.LookupEnv("TEST_VAR2")
|
|
assert.False(t, exists, "TEST_VAR2 should be unset after removal from .env")
|
|
assert.Equal(t, "original_global_value", os.Getenv("TEST_GLOBAL_VAR"), "TEST_GLOBAL_VAR should NOT be overridden")
|
|
|
|
const finalEnv = `TEST_VAR1=final_value`
|
|
err = os.WriteFile(envPath, []byte(finalEnv), 0o644)
|
|
require.NoError(t, err)
|
|
|
|
engine.loadEnvFile()
|
|
|
|
assert.Equal(t, "final_value", os.Getenv("TEST_VAR1"), "TEST_VAR1 should be final")
|
|
assert.Equal(t, originalValue, os.Getenv("TEST_GLOBAL_VAR"), "TEST_GLOBAL_VAR should be restored to original value")
|
|
}
|