Files
kandev/apps/backend/internal/launcher/dev_test.go
T
2026-08-21 19:28:21 +01:00

409 lines
14 KiB
Go

package launcher
import (
"context"
"errors"
"os"
"path/filepath"
"reflect"
"strconv"
"strings"
"testing"
"time"
)
func itoa(n int) string { return strconv.Itoa(n) }
var errTestHealth = errors.New("test health failure")
func TestRunDevLaunchesBackendThenWebAndOpensBrowser(t *testing.T) {
repo := makeRepoTree(t)
t.Chdir(repo)
t.Setenv("KANDEV_TASK_ID", "")
t.Setenv("KANDEV_DATABASE_PATH", "")
t.Setenv("KANDEV_HOME_DIR", "")
oldNewSupervisor := newSupervisorFn
oldAttachSignals := attachSignalsFn
oldLaunchBackend := launchBackendFn
oldWaitForHealth := waitForHealthFn
oldWaitForURL := waitForURLFn
oldOpenBrowser := openBrowserFn
oldStartProcess := startProcessFn
t.Cleanup(func() {
newSupervisorFn = oldNewSupervisor
attachSignalsFn = oldAttachSignals
launchBackendFn = oldLaunchBackend
waitForHealthFn = oldWaitForHealth
waitForURLFn = oldWaitForURL
openBrowserFn = oldOpenBrowser
startProcessFn = oldStartProcess
})
var events []string
var backendCfg backendLaunchConfig
var webCmd, webArgs []string
var webCWD, webEnvJoined string
var webLabel string
newSupervisorFn = func() *processSupervisor {
events = append(events, "new-supervisor")
return newSupervisor()
}
attachSignalsFn = func(_ *processSupervisor) {
events = append(events, "attach-signals")
}
launchBackendFn = func(cfg backendLaunchConfig) (*restartableBackend, func(), error) {
backendCfg = cfg
events = append(events, "launch-backend")
exitCh := make(chan int, 1)
exitCh <- 0
return &restartableBackend{exitCh: exitCh}, func() {}, nil
}
waitForHealthFn = func(_ context.Context, url string, _ childState, _ time.Duration, _ string, _ func()) error {
events = append(events, "wait-health:"+url)
return nil
}
waitForURLFn = func(_ context.Context, url string, _ childState, _ time.Duration) error {
events = append(events, "wait-url:"+url)
return nil
}
openBrowserFn = func(url string) {
events = append(events, "open:"+url)
}
startProcessFn = func(cmd string, args []string, cwd string, env []string, quiet bool, label string, _ *processSupervisor) (*managedProcess, func(), error) {
webCmd = append([]string{cmd}, args...)
webArgs = args
webLabel = label
webCWD = cwd
webEnvJoined = strings.Join(env, "\n")
events = append(events, "start-web:"+label)
return &managedProcess{}, func() {}, nil
}
code := runDev(context.Background(), Options{Command: CommandDev})
if code != 0 {
t.Fatalf("runDev() = %d, want 0", code)
}
want := []string{
"new-supervisor",
"attach-signals",
"launch-backend",
"wait-health:http://localhost:" + itoa(backendCfg.Ports.BackendPort),
"start-web:web",
"wait-url:http://localhost:" + itoa(backendCfg.Ports.WebPort),
"open:http://localhost:" + itoa(backendCfg.Ports.BackendPort),
}
if !reflect.DeepEqual(events, want) {
t.Fatalf("events = %v, want %v", events, want)
}
if backendCfg.Command != "make" || !reflect.DeepEqual(backendCfg.Args, []string{"-C", "apps/backend", "dev"}) {
t.Fatalf("backend child = %q %v, want make -C apps/backend dev", backendCfg.Command, backendCfg.Args)
}
if backendCfg.CWD != repo {
t.Fatalf("backend cwd = %q, want %q", backendCfg.CWD, repo)
}
if backendCfg.HomeDir != filepath.Join(repo, ".kandev-dev") {
t.Fatalf("backend supervisor home = %q, want %q", backendCfg.HomeDir, filepath.Join(repo, ".kandev-dev"))
}
if backendCfg.Mode != "dev" {
t.Fatalf("backend mode = %q, want dev", backendCfg.Mode)
}
backendEnv := strings.Join(backendCfg.Env, "\n")
for _, expected := range []string{
"KANDEV_WEB_INTERNAL_URL=http://localhost:" + itoa(backendCfg.Ports.WebPort),
"KANDEV_DEBUG_DEV_MODE=true",
"KANDEV_HOME_DIR=" + filepath.Join(repo, ".kandev-dev"),
"KANDEV_DATABASE_PATH=",
"KANDEV_BACKEND_PID_FILE=" + filepath.Join(repo, ".kandev-dev", "supervisor", "backend.pid"),
} {
if !strings.Contains(backendEnv, expected) {
t.Fatalf("backend environment missing %q:\n%s", expected, backendEnv)
}
}
if webCWD != repo {
t.Fatalf("web cwd = %q, want %q", webCWD, repo)
}
if webLabel != "web" {
t.Fatalf("web label = %q, want web", webLabel)
}
if !reflect.DeepEqual(webArgs, []string{"-C", "apps", "--filter", "@kandev/web", "dev"}) {
t.Fatalf("web args = %v, want the Vite dev command", webArgs)
}
for _, expected := range []string{
"KANDEV_API_BASE_URL=http://localhost:" + itoa(backendCfg.Ports.BackendPort),
"PORT=" + itoa(backendCfg.Ports.WebPort),
"HOSTNAME=127.0.0.1",
"KANDEV_DEBUG=true",
"VITE_KANDEV_DEBUG=true",
} {
if !strings.Contains(webEnvJoined, expected) {
t.Fatalf("web environment missing %q:\n%s", expected, webEnvJoined)
}
}
if strings.Contains(webEnvJoined, "VITE_KANDEV_API_PORT") {
t.Fatalf("web environment leaked VITE_KANDEV_API_PORT:\n%s", webEnvJoined)
}
if webCmd[0] == "cmd.exe" {
t.Fatalf("web command = %v, want a plain pnpm invocation on this platform", webCmd)
}
}
func TestRunDevUsesConfiguredHealthTimeoutForBackendAndWeb(t *testing.T) {
repo := makeRepoTree(t)
t.Chdir(repo)
writeLauncherConfig(t, filepath.Join(repo, "config.yaml"), "launcher:\n healthTimeoutMs: 12345\n")
t.Setenv("KANDEV_TASK_ID", "")
t.Setenv("KANDEV_DATABASE_PATH", "")
t.Setenv("KANDEV_HOME_DIR", "")
oldNewSupervisor := newSupervisorFn
oldAttachSignals := attachSignalsFn
oldLaunchBackend := launchBackendFn
oldWaitForHealth := waitForHealthFn
oldWaitForURL := waitForURLFn
oldOpenBrowser := openBrowserFn
oldStartProcess := startProcessFn
t.Cleanup(func() {
newSupervisorFn = oldNewSupervisor
attachSignalsFn = oldAttachSignals
launchBackendFn = oldLaunchBackend
waitForHealthFn = oldWaitForHealth
waitForURLFn = oldWaitForURL
openBrowserFn = oldOpenBrowser
startProcessFn = oldStartProcess
})
newSupervisorFn = newSupervisor
attachSignalsFn = func(_ *processSupervisor) {}
launchBackendFn = func(_ backendLaunchConfig) (*restartableBackend, func(), error) {
exitCh := make(chan int, 1)
exitCh <- 0
return &restartableBackend{exitCh: exitCh}, func() {}, nil
}
var backendTimeout, webTimeout time.Duration
waitForHealthFn = func(_ context.Context, _ string, _ childState, timeout time.Duration, _ string, _ func()) error {
backendTimeout = timeout
return nil
}
waitForURLFn = func(_ context.Context, _ string, _ childState, timeout time.Duration) error {
webTimeout = timeout
return nil
}
openBrowserFn = func(string) {}
startProcessFn = func(_ string, _ []string, _ string, _ []string, _ bool, _ string, _ *processSupervisor) (*managedProcess, func(), error) {
return &managedProcess{}, func() {}, nil
}
if code := runDev(context.Background(), Options{Command: CommandDev}); code != 0 {
t.Fatalf("runDev() = %d, want 0", code)
}
want := 12345 * time.Millisecond
if backendTimeout != want || webTimeout != want {
t.Fatalf("health timeouts = backend %s, web %s; want %s for both", backendTimeout, webTimeout, want)
}
}
func TestRunDevHeadlessSkipsBrowser(t *testing.T) {
repo := makeRepoTree(t)
t.Chdir(repo)
t.Setenv("KANDEV_TASK_ID", "")
t.Setenv("KANDEV_DATABASE_PATH", "")
t.Setenv("KANDEV_HOME_DIR", "")
oldLaunchBackend := launchBackendFn
oldWaitForHealth := waitForHealthFn
oldWaitForURL := waitForURLFn
oldOpenBrowser := openBrowserFn
oldStartProcess := startProcessFn
oldAttachSignals := attachSignalsFn
t.Cleanup(func() {
launchBackendFn = oldLaunchBackend
waitForHealthFn = oldWaitForHealth
waitForURLFn = oldWaitForURL
openBrowserFn = oldOpenBrowser
startProcessFn = oldStartProcess
attachSignalsFn = oldAttachSignals
})
browserOpened := false
attachSignalsFn = func(_ *processSupervisor) {}
launchBackendFn = func(cfg backendLaunchConfig) (*restartableBackend, func(), error) {
exitCh := make(chan int, 1)
exitCh <- 0
return &restartableBackend{exitCh: exitCh}, func() {}, nil
}
waitForHealthFn = func(_ context.Context, _ string, _ childState, _ time.Duration, _ string, _ func()) error {
return nil
}
waitForURLFn = func(_ context.Context, _ string, _ childState, _ time.Duration) error {
return nil
}
openBrowserFn = func(_ string) { browserOpened = true }
startProcessFn = func(_ string, _ []string, _ string, _ []string, _ bool, _ string, _ *processSupervisor) (*managedProcess, func(), error) {
return &managedProcess{}, func() {}, nil
}
code := runDev(context.Background(), Options{Command: CommandDev, Headless: true})
if code != 0 {
t.Fatalf("runDev(headless) = %d, want 0", code)
}
if browserOpened {
t.Fatal("runDev(headless) opened the browser")
}
}
func TestRunDevAbortsOnBackupFailureBeforeLaunch(t *testing.T) {
repo := makeRepoTree(t)
t.Chdir(repo)
t.Setenv("KANDEV_TASK_ID", "")
// A path without a .kandev-dev segment is treated as production; making it
// a directory forces the copy to fail so the abort path runs.
t.Setenv("HOME", t.TempDir())
dbDir := t.TempDir()
t.Setenv("KANDEV_DATABASE_PATH", filepath.Join(dbDir, "kandev.db"))
if err := os.Mkdir(filepath.Join(dbDir, "kandev.db"), 0o700); err != nil {
t.Fatal(err)
}
oldLaunchBackend := launchBackendFn
t.Cleanup(func() { launchBackendFn = oldLaunchBackend })
launched := false
launchBackendFn = func(_ backendLaunchConfig) (*restartableBackend, func(), error) {
launched = true
return nil, nil, nil
}
code := runDev(context.Background(), Options{Command: CommandDev})
if code != 1 {
t.Fatalf("runDev() = %d, want 1", code)
}
if launched {
t.Fatal("backend launched despite a failed production-db backup")
}
}
func TestRunDevShutsDownTreeOnBackendHealthFailure(t *testing.T) {
repo := makeRepoTree(t)
t.Chdir(repo)
t.Setenv("KANDEV_TASK_ID", "")
t.Setenv("KANDEV_DATABASE_PATH", "")
t.Setenv("KANDEV_HOME_DIR", "")
oldLaunchBackend := launchBackendFn
oldWaitForHealth := waitForHealthFn
oldWaitForURL := waitForURLFn
oldOpenBrowser := openBrowserFn
oldStartProcess := startProcessFn
oldAttachSignals := attachSignalsFn
oldStatusOutput := launcherStatusOutput
t.Cleanup(func() {
launchBackendFn = oldLaunchBackend
waitForHealthFn = oldWaitForHealth
waitForURLFn = oldWaitForURL
openBrowserFn = oldOpenBrowser
startProcessFn = oldStartProcess
attachSignalsFn = oldAttachSignals
launcherStatusOutput = oldStatusOutput
})
var output strings.Builder
launcherStatusOutput = &output
browserOpened := false
attachSignalsFn = func(_ *processSupervisor) {}
launchBackendFn = func(_ backendLaunchConfig) (*restartableBackend, func(), error) {
return &restartableBackend{exitCh: make(chan int, 1)}, func() {}, nil
}
waitForHealthFn = func(_ context.Context, _ string, _ childState, _ time.Duration, _ string, _ func()) error {
return errTestHealth
}
waitForURLFn = func(_ context.Context, _ string, _ childState, _ time.Duration) error {
t.Fatal("waitForURL called after backend health failure")
return nil
}
openBrowserFn = func(_ string) { browserOpened = true }
startProcessFn = func(_ string, _ []string, _ string, _ []string, _ bool, _ string, _ *processSupervisor) (*managedProcess, func(), error) {
t.Fatal("web child launched after backend health failure")
return nil, nil, nil
}
code := runDev(context.Background(), Options{Command: CommandDev})
if code != 1 {
t.Fatalf("runDev() = %d, want 1", code)
}
if !strings.Contains(output.String(), "graceful shutdown started") {
t.Fatalf("supervisor shutdown not triggered on health failure:\n%s", output.String())
}
if browserOpened {
t.Fatal("browser opened despite health failure")
}
}
func TestRunDevShutsDownTreeWhenWebChildExits(t *testing.T) {
repo := makeRepoTree(t)
t.Chdir(repo)
t.Setenv("KANDEV_TASK_ID", "")
t.Setenv("KANDEV_DATABASE_PATH", "")
t.Setenv("KANDEV_HOME_DIR", "")
oldLaunchBackend := launchBackendFn
oldWaitForHealth := waitForHealthFn
oldWaitForURL := waitForURLFn
oldOpenBrowser := openBrowserFn
oldStartProcess := startProcessFn
oldAttachSignals := attachSignalsFn
oldStatusOutput := launcherStatusOutput
t.Cleanup(func() {
launchBackendFn = oldLaunchBackend
waitForHealthFn = oldWaitForHealth
waitForURLFn = oldWaitForURL
openBrowserFn = oldOpenBrowser
startProcessFn = oldStartProcess
attachSignalsFn = oldAttachSignals
launcherStatusOutput = oldStatusOutput
})
var output strings.Builder
launcherStatusOutput = &output
attachSignalsFn = func(_ *processSupervisor) {}
// The backend stays alive; only the web child exits. waitForAppExit must
// select the web child and take the launcher down with its exit code.
launchBackendFn = func(_ backendLaunchConfig) (*restartableBackend, func(), error) {
return &restartableBackend{exitCh: make(chan int, 1)}, func() {}, nil
}
waitForHealthFn = func(_ context.Context, _ string, _ childState, _ time.Duration, _ string, _ func()) error {
return nil
}
waitForURLFn = func(_ context.Context, _ string, _ childState, _ time.Duration) error {
return nil
}
openBrowserFn = func(_ string) {}
webDone := make(chan struct{})
startProcessFn = func(_ string, _ []string, _ string, _ []string, _ bool, label string, _ *processSupervisor) (*managedProcess, func(), error) {
return &managedProcess{label: label, exited: true, exitCode: 7, done: webDone}, func() {}, nil
}
// The web child has already exited: a pre-closed done channel is the
// signal waitForAppExit selects on.
close(webDone)
code := runDev(context.Background(), Options{Command: CommandDev, Headless: true})
if code != 7 {
t.Fatalf("runDev() = %d, want the web child's exit code 7", code)
}
if !strings.Contains(output.String(), "reason=web exit") {
t.Fatalf("supervisor not shut down for the web child exit:\n%s", output.String())
}
}
func TestRunDevFailsWhenRepoRootNotFound(t *testing.T) {
t.Chdir(t.TempDir())
code := runDev(context.Background(), Options{Command: CommandDev})
if code != 2 {
t.Fatalf("runDev() = %d, want 2", code)
}
}