1007 lines
34 KiB
Go
1007 lines
34 KiB
Go
package helpers
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
func preserveDaemonHooks(t *testing.T) {
|
|
t.Helper()
|
|
oldDetach := daemonDetachEnabled
|
|
oldExecutable := daemonExecutable
|
|
oldCommand := daemonCommand
|
|
oldNow := daemonNow
|
|
oldCreateTemp := daemonCreateTemp
|
|
oldFileChmod := daemonFileChmod
|
|
oldCopy := daemonCopy
|
|
oldFileSync := daemonFileSync
|
|
oldFileClose := daemonFileClose
|
|
oldRename := daemonRename
|
|
oldFindProcess := daemonFindProcess
|
|
oldProcessAlive := daemonProcessAlive
|
|
oldSignalProcess := daemonSignalProcess
|
|
oldSignalContext := daemonSignalContext
|
|
oldReadDir := connectHealthReadDir
|
|
oldDir := connectDaemonDirOverride
|
|
oldAfter := helperAfter
|
|
oldSleep := helperSleep
|
|
t.Cleanup(func() {
|
|
daemonDetachEnabled = oldDetach
|
|
daemonExecutable = oldExecutable
|
|
daemonCommand = oldCommand
|
|
daemonNow = oldNow
|
|
daemonCreateTemp = oldCreateTemp
|
|
daemonFileChmod = oldFileChmod
|
|
daemonCopy = oldCopy
|
|
daemonFileSync = oldFileSync
|
|
daemonFileClose = oldFileClose
|
|
daemonRename = oldRename
|
|
daemonFindProcess = oldFindProcess
|
|
daemonProcessAlive = oldProcessAlive
|
|
daemonSignalProcess = oldSignalProcess
|
|
daemonSignalContext = oldSignalContext
|
|
connectHealthReadDir = oldReadDir
|
|
connectDaemonDirOverride = oldDir
|
|
helperAfter = oldAfter
|
|
helperSleep = oldSleep
|
|
})
|
|
// Tests exercise the platform-independent lifecycle below the public
|
|
// Windows guard. The dedicated unsupported case resets this to false.
|
|
daemonDetachEnabled = true
|
|
}
|
|
|
|
func daemonTestCommand() *cobra.Command {
|
|
cmd := &cobra.Command{Use: "connect"}
|
|
cmd.SetOut(&bytes.Buffer{})
|
|
cmd.SetErr(&bytes.Buffer{})
|
|
return cmd
|
|
}
|
|
|
|
func instantAfter(time.Duration) <-chan time.Time {
|
|
ch := make(chan time.Time, 1)
|
|
ch <- time.Now()
|
|
return ch
|
|
}
|
|
|
|
func TestCrossPlatformCoverageStartDaemonLifecycleEdges(t *testing.T) {
|
|
t.Run("unsupported", func(t *testing.T) {
|
|
preserveDaemonHooks(t)
|
|
daemonDetachEnabled = false
|
|
if err := startDaemon(daemonTestCommand(), "key", "client", "", "custom", "", "", false); err == nil {
|
|
t.Fatal("unsupported daemon start succeeded")
|
|
}
|
|
})
|
|
|
|
t.Run("directory error", func(t *testing.T) {
|
|
preserveDaemonHooks(t)
|
|
blocked := filepath.Join(t.TempDir(), "blocked")
|
|
if err := os.WriteFile(blocked, []byte("file"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
connectDaemonDirOverride = blocked
|
|
if err := startDaemon(daemonTestCommand(), "key", "client", "", "custom", "", "", false); err == nil {
|
|
t.Fatal("daemon start with blocked directory succeeded")
|
|
}
|
|
})
|
|
|
|
t.Run("already running", func(t *testing.T) {
|
|
preserveDaemonHooks(t)
|
|
connectDaemonDirOverride = t.TempDir()
|
|
dir, err := connectDaemonDir("key")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := writeDaemonState(dir, daemonState{Pid: os.Getpid(), DirKey: "key"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := startDaemon(daemonTestCommand(), "key", "client", "", "custom", "", "", false); err == nil {
|
|
t.Fatal("duplicate daemon start succeeded")
|
|
}
|
|
})
|
|
|
|
t.Run("executable error", func(t *testing.T) {
|
|
preserveDaemonHooks(t)
|
|
connectDaemonDirOverride = t.TempDir()
|
|
daemonExecutable = func() (string, error) { return "", errors.New("executable") }
|
|
if err := startDaemon(daemonTestCommand(), "key", "client", "", "custom", "", "", false); err == nil {
|
|
t.Fatal("daemon start without executable succeeded")
|
|
}
|
|
})
|
|
|
|
t.Run("stage error", func(t *testing.T) {
|
|
preserveDaemonHooks(t)
|
|
connectDaemonDirOverride = t.TempDir()
|
|
daemonExecutable = func() (string, error) { return filepath.Join(t.TempDir(), "missing"), nil }
|
|
if err := startDaemon(daemonTestCommand(), "key", "client", "", "custom", "", "", false); err == nil {
|
|
t.Fatal("daemon start with missing source succeeded")
|
|
}
|
|
})
|
|
|
|
t.Run("log error", func(t *testing.T) {
|
|
preserveDaemonHooks(t)
|
|
connectDaemonDirOverride = t.TempDir()
|
|
dir, err := connectDaemonDir("key")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Mkdir(daemonLogPath(dir), 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
daemonExecutable = os.Executable
|
|
if err := startDaemon(daemonTestCommand(), "key", "client", "", "custom", "", "", false); err == nil {
|
|
t.Fatal("daemon start with directory log succeeded")
|
|
}
|
|
})
|
|
|
|
t.Run("child start error", func(t *testing.T) {
|
|
preserveDaemonHooks(t)
|
|
connectDaemonDirOverride = t.TempDir()
|
|
daemonExecutable = os.Executable
|
|
daemonCommand = func(string, ...string) *exec.Cmd {
|
|
return exec.Command(filepath.Join(t.TempDir(), "missing"))
|
|
}
|
|
if err := startDaemon(daemonTestCommand(), "key", "client", "", "custom", "", "", false); err == nil {
|
|
t.Fatal("daemon start with invalid command succeeded")
|
|
}
|
|
})
|
|
|
|
t.Run("success", func(t *testing.T) {
|
|
preserveDaemonHooks(t)
|
|
connectDaemonDirOverride = t.TempDir()
|
|
daemonExecutable = os.Executable
|
|
fixture := writeShellExecutable(t, t.TempDir(), "daemon-success", "exit 0\n")
|
|
daemonCommand = func(string, ...string) *exec.Cmd { return exec.Command(fixture) }
|
|
cmd := daemonTestCommand()
|
|
var out, errOut bytes.Buffer
|
|
cmd.SetOut(&out)
|
|
cmd.SetErr(&errOut)
|
|
if err := startDaemon(cmd, "key", "client", "app", "custom", "staff", "profile", true); err != nil {
|
|
t.Fatalf("startDaemon() error = %v", err)
|
|
}
|
|
// The streaming root remains legacy until a dedicated stream contract.
|
|
if !strings.Contains(out.String(), "daemon started") || !strings.Contains(out.String(), "pid") {
|
|
t.Fatalf("legacy daemon start output = %q", out.String())
|
|
}
|
|
if errOut.Len() != 0 {
|
|
t.Fatalf("legacy daemon start stderr = %q", errOut.String())
|
|
}
|
|
|
|
// startDaemon intentionally releases its detached child. On Windows the
|
|
// child keeps daemon.log locked until it exits, so wait for that handle
|
|
// to close before TempDir cleanup.
|
|
dir, err := connectDaemonDir("key")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for {
|
|
err = os.Remove(daemonLogPath(dir))
|
|
if err == nil || os.IsNotExist(err) {
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatalf("daemon log remained locked: %v", err)
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestCrossPlatformCoverageDaemonFileOperationEdges(t *testing.T) {
|
|
t.Run("default config directory", func(t *testing.T) {
|
|
preserveDaemonHooks(t)
|
|
t.Setenv("HOME", t.TempDir())
|
|
connectDaemonDirOverride = ""
|
|
if _, err := connectDaemonDir("default"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
})
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
configure func()
|
|
}{
|
|
{"create temp", func() {
|
|
daemonCreateTemp = func(string, string) (*os.File, error) { return nil, errors.New("create") }
|
|
}},
|
|
{"chmod", func() {
|
|
daemonFileChmod = func(*os.File, os.FileMode) error { return errors.New("chmod") }
|
|
}},
|
|
{"copy", func() {
|
|
daemonCopy = func(io.Writer, io.Reader) (int64, error) { return 0, errors.New("copy") }
|
|
}},
|
|
{"sync", func() {
|
|
daemonFileSync = func(*os.File) error { return errors.New("sync") }
|
|
}},
|
|
{"close", func() {
|
|
daemonFileClose = func(file *os.File) error {
|
|
_ = file.Close()
|
|
return errors.New("close")
|
|
}
|
|
}},
|
|
{"rename", func() {
|
|
daemonRename = func(string, string) error { return errors.New("rename") }
|
|
}},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
preserveDaemonHooks(t)
|
|
dir := t.TempDir()
|
|
src := filepath.Join(dir, "source")
|
|
if err := os.WriteFile(src, []byte("binary"), 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tc.configure()
|
|
if _, err := stageDaemonExecutable(src, dir); err == nil {
|
|
t.Fatalf("stage with %s failure succeeded", tc.name)
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("state write and rename errors", func(t *testing.T) {
|
|
preserveDaemonHooks(t)
|
|
if err := writeDaemonState(filepath.Join(t.TempDir(), "missing"), daemonState{}); err == nil {
|
|
t.Fatal("state write to missing directory succeeded")
|
|
}
|
|
dir := t.TempDir()
|
|
daemonRename = func(string, string) error { return errors.New("rename") }
|
|
if err := writeDaemonState(dir, daemonState{}); err == nil {
|
|
t.Fatal("state rename failure succeeded")
|
|
}
|
|
})
|
|
|
|
t.Run("state read non-not-exist error", func(t *testing.T) {
|
|
preserveDaemonHooks(t)
|
|
dir := t.TempDir()
|
|
if err := os.Mkdir(daemonStatePath(dir), 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := readDaemonState(dir); err == nil {
|
|
t.Fatal("reading directory as state succeeded")
|
|
}
|
|
})
|
|
|
|
if backoffDelay(1, 2*time.Second, time.Second) != time.Second {
|
|
t.Fatal("backoff base larger than cap was not capped")
|
|
}
|
|
if statusHintArgs("client", "app-id") != " --robot-client-id client" ||
|
|
statusHintArgs("", "app-id") != " --unified-app-id id" || statusHintArgs("", "plain") != "" {
|
|
t.Fatal("status hint variants mismatch")
|
|
}
|
|
workerArgs := buildWorkerArgs([]string{"keep", "--daemon=true", "--daemon-supervise=false", "--daemon-worker=true"})
|
|
if strings.Contains(strings.Join(workerArgs, " "), "=true") || workerArgs[0] != "keep" {
|
|
t.Fatalf("worker args with assigned daemon flags = %#v", workerArgs)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageRunSupervisorLifecycleEdges(t *testing.T) {
|
|
t.Run("missing key", func(t *testing.T) {
|
|
preserveDaemonHooks(t)
|
|
t.Setenv("DWS_CONNECT_DAEMON_DIRKEY", "")
|
|
if err := runSupervisor(daemonTestCommand()); err == nil {
|
|
t.Fatal("supervisor without key succeeded")
|
|
}
|
|
})
|
|
|
|
t.Run("directory error", func(t *testing.T) {
|
|
preserveDaemonHooks(t)
|
|
blocked := filepath.Join(t.TempDir(), "blocked")
|
|
if err := os.WriteFile(blocked, []byte("file"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("DWS_CONNECT_DAEMON_DIRKEY", "key")
|
|
t.Setenv("DWS_CONNECT_DAEMON_DIR", blocked)
|
|
if err := runSupervisor(daemonTestCommand()); err == nil {
|
|
t.Fatal("supervisor with blocked directory succeeded")
|
|
}
|
|
})
|
|
|
|
t.Run("state write error", func(t *testing.T) {
|
|
preserveDaemonHooks(t)
|
|
base := t.TempDir()
|
|
connectDaemonDirOverride = base
|
|
dir, err := connectDaemonDir("key")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Mkdir(daemonStatePath(dir), 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("DWS_CONNECT_DAEMON_DIRKEY", "key")
|
|
t.Setenv("DWS_CONNECT_DAEMON_DIR", base)
|
|
if err := runSupervisor(daemonTestCommand()); err == nil {
|
|
t.Fatal("supervisor with unwritable state succeeded")
|
|
}
|
|
})
|
|
|
|
t.Run("executable error", func(t *testing.T) {
|
|
preserveDaemonHooks(t)
|
|
base := t.TempDir()
|
|
t.Setenv("DWS_CONNECT_DAEMON_DIRKEY", "key")
|
|
t.Setenv("DWS_CONNECT_DAEMON_DIR", base)
|
|
daemonExecutable = func() (string, error) { return "", errors.New("executable") }
|
|
if err := runSupervisor(daemonTestCommand()); err == nil {
|
|
t.Fatal("supervisor without executable succeeded")
|
|
}
|
|
})
|
|
|
|
t.Run("cancel before worker", func(t *testing.T) {
|
|
preserveDaemonHooks(t)
|
|
base := t.TempDir()
|
|
t.Setenv("DWS_CONNECT_DAEMON_DIRKEY", "key")
|
|
t.Setenv("DWS_CONNECT_DAEMON_DIR", base)
|
|
daemonSignalContext = func() (context.Context, context.CancelFunc) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
return ctx, func() {}
|
|
}
|
|
if err := runSupervisor(daemonTestCommand()); err != nil {
|
|
t.Fatalf("cancelled supervisor = %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("single worker without always-on", func(t *testing.T) {
|
|
preserveDaemonHooks(t)
|
|
base := t.TempDir()
|
|
t.Setenv("DWS_CONNECT_DAEMON_DIRKEY", "key")
|
|
t.Setenv("DWS_CONNECT_DAEMON_DIR", base)
|
|
t.Setenv("DWS_CONNECT_DAEMON_CLIENTID", "client")
|
|
t.Setenv("DWS_CONNECT_DAEMON_UNIFIEDAPPID", "app")
|
|
t.Setenv("DWS_CONNECT_DAEMON_CHANNEL", "custom")
|
|
t.Setenv("DWS_CONNECT_DAEMON_PROFILE", "profile")
|
|
t.Setenv("DWS_CONNECT_DAEMON_ALWAYSON", "")
|
|
daemonCommand = func(string, ...string) *exec.Cmd { return exec.Command("sh", "-c", "exit 0") }
|
|
if err := runSupervisor(daemonTestCommand()); err != nil {
|
|
t.Fatalf("single-worker supervisor = %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("repeated start failures", func(t *testing.T) {
|
|
preserveDaemonHooks(t)
|
|
base := t.TempDir()
|
|
t.Setenv("DWS_CONNECT_DAEMON_DIRKEY", "key")
|
|
t.Setenv("DWS_CONNECT_DAEMON_DIR", base)
|
|
t.Setenv("DWS_CONNECT_DAEMON_ALWAYSON", "true")
|
|
daemonCommand = func(string, ...string) *exec.Cmd { return exec.Command(filepath.Join(base, "missing")) }
|
|
helperAfter = instantAfter
|
|
if err := runSupervisor(daemonTestCommand()); err == nil {
|
|
t.Fatal("supervisor did not give up after start failures")
|
|
}
|
|
})
|
|
|
|
t.Run("repeated fast crashes", func(t *testing.T) {
|
|
preserveDaemonHooks(t)
|
|
base := t.TempDir()
|
|
t.Setenv("DWS_CONNECT_DAEMON_DIRKEY", "key")
|
|
t.Setenv("DWS_CONNECT_DAEMON_DIR", base)
|
|
t.Setenv("DWS_CONNECT_DAEMON_ALWAYSON", "true")
|
|
daemonCommand = func(string, ...string) *exec.Cmd { return exec.Command("sh", "-c", "exit 1") }
|
|
helperAfter = instantAfter
|
|
if err := runSupervisor(daemonTestCommand()); err == nil {
|
|
t.Fatal("supervisor did not give up after fast crashes")
|
|
}
|
|
})
|
|
|
|
t.Run("healthy crash resets failures then cancellation", func(t *testing.T) {
|
|
preserveDaemonHooks(t)
|
|
base := t.TempDir()
|
|
t.Setenv("DWS_CONNECT_DAEMON_DIRKEY", "key")
|
|
t.Setenv("DWS_CONNECT_DAEMON_DIR", base)
|
|
t.Setenv("DWS_CONNECT_DAEMON_ALWAYSON", "true")
|
|
secondWorker := make(chan struct{})
|
|
var workers int
|
|
daemonCommand = func(string, ...string) *exec.Cmd {
|
|
workers++
|
|
if workers == 1 {
|
|
return exec.Command("sh", "-c", "exit 0")
|
|
}
|
|
select {
|
|
case <-secondWorker:
|
|
default:
|
|
close(secondWorker)
|
|
}
|
|
return exec.Command("sh", "-c", "sleep 5")
|
|
}
|
|
baseTime := time.Now()
|
|
var tick int
|
|
daemonNow = func() time.Time {
|
|
tick++
|
|
if tick >= 3 {
|
|
return baseTime.Add(daemonHealthyAfter)
|
|
}
|
|
return baseTime
|
|
}
|
|
daemonSignalContext = func() (context.Context, context.CancelFunc) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
go func() {
|
|
<-secondWorker
|
|
cancel()
|
|
}()
|
|
return ctx, cancel
|
|
}
|
|
if err := runSupervisor(daemonTestCommand()); err != nil {
|
|
t.Fatalf("healthy cancellation supervisor = %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("cancel during backoff", func(t *testing.T) {
|
|
preserveDaemonHooks(t)
|
|
base := t.TempDir()
|
|
t.Setenv("DWS_CONNECT_DAEMON_DIRKEY", "key")
|
|
t.Setenv("DWS_CONNECT_DAEMON_DIR", base)
|
|
t.Setenv("DWS_CONNECT_DAEMON_ALWAYSON", "true")
|
|
daemonCommand = func(string, ...string) *exec.Cmd { return exec.Command("sh", "-c", "exit 1") }
|
|
never := make(chan time.Time)
|
|
enteredBackoff := make(chan struct{})
|
|
helperAfter = func(time.Duration) <-chan time.Time {
|
|
select {
|
|
case <-enteredBackoff:
|
|
default:
|
|
close(enteredBackoff)
|
|
}
|
|
return never
|
|
}
|
|
daemonSignalContext = func() (context.Context, context.CancelFunc) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
go func() {
|
|
<-enteredBackoff
|
|
cancel()
|
|
}()
|
|
return ctx, cancel
|
|
}
|
|
if err := runSupervisor(daemonTestCommand()); err != nil {
|
|
t.Fatalf("backoff cancellation supervisor = %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestCrossPlatformCoverageSuperviseWaitForcedKill(t *testing.T) {
|
|
preserveDaemonHooks(t)
|
|
worker := exec.Command("sh", "-c", "trap '' TERM; sleep 5")
|
|
if err := worker.Start(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
time.Sleep(30 * time.Millisecond)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
helperAfter = instantAfter
|
|
_ = superviseWait(ctx, worker)
|
|
}
|
|
|
|
func TestCrossPlatformCoverageDaemonNotifyStateChangeEdges(t *testing.T) {
|
|
preserveDaemonHooks(t)
|
|
daemonExecutable = func() (string, error) { return "", errors.New("missing") }
|
|
daemonNotifyStateChange("staff", "custom", "client", "started", "")
|
|
|
|
daemonExecutable = func() (string, error) { return "/bin/sh", nil }
|
|
done := make(chan struct{}, 4)
|
|
daemonCommand = func(string, ...string) *exec.Cmd {
|
|
done <- struct{}{}
|
|
return exec.Command("sh", "-c", "exit 0")
|
|
}
|
|
for _, event := range []string{"started", "stopped", "crashed", "gave_up"} {
|
|
daemonNotifyStateChange("staff", "custom", "client", event, "detail")
|
|
}
|
|
for range 4 {
|
|
select {
|
|
case <-done:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("notification subprocess was not created")
|
|
}
|
|
}
|
|
time.Sleep(50 * time.Millisecond)
|
|
}
|
|
|
|
func TestCrossPlatformCoverageDaemonStatusAndStopEdges(t *testing.T) {
|
|
t.Run("status directory and corrupt files", func(t *testing.T) {
|
|
preserveDaemonHooks(t)
|
|
blocked := filepath.Join(t.TempDir(), "blocked")
|
|
if err := os.WriteFile(blocked, []byte("file"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
connectDaemonDirOverride = blocked
|
|
if err := daemonStatus(&bytes.Buffer{}, "key", false); err == nil {
|
|
t.Fatal("status with blocked directory succeeded")
|
|
}
|
|
|
|
connectDaemonDirOverride = t.TempDir()
|
|
dir, _ := connectDaemonDir("corrupt-state")
|
|
if err := os.WriteFile(daemonStatePath(dir), []byte("{"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := daemonStatus(&bytes.Buffer{}, "corrupt-state", false); err == nil {
|
|
t.Fatal("status with corrupt state succeeded")
|
|
}
|
|
|
|
dir, _ = connectDaemonDir("corrupt-heartbeat")
|
|
if err := os.WriteFile(connectHeartbeatPath(dir), []byte("{"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := daemonStatus(&bytes.Buffer{}, "corrupt-heartbeat", false); err == nil {
|
|
t.Fatal("status with corrupt heartbeat succeeded")
|
|
}
|
|
})
|
|
|
|
t.Run("status detailed plain and json", func(t *testing.T) {
|
|
preserveDaemonHooks(t)
|
|
connectDaemonDirOverride = t.TempDir()
|
|
base := time.Now()
|
|
daemonNow = func() time.Time { return base }
|
|
dir, _ := connectDaemonDir("detailed")
|
|
if err := writeDaemonState(dir, daemonState{Pid: os.Getpid(), DirKey: "detailed"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
seedHeartbeat(t, "detailed", connectHeartbeat{
|
|
Pid: os.Getpid(), ClientID: "client", Channel: "custom",
|
|
StartUnix: base.Add(-time.Minute).Unix(), ConnectedUnix: base.Add(-time.Minute).Unix(),
|
|
LastPushUnix: base.Add(-time.Second).Unix(), LastError: "last error",
|
|
})
|
|
var plain bytes.Buffer
|
|
if err := daemonStatus(&plain, "detailed", false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, want := range []string{"state", "detail", "pid", "channel", "client", "uptime", "recv", "error", "logs"} {
|
|
if !strings.Contains(plain.String(), want) {
|
|
t.Errorf("plain status missing %q: %s", want, plain.String())
|
|
}
|
|
}
|
|
var jsonOut bytes.Buffer
|
|
if err := daemonStatus(&jsonOut, "detailed", true); err != nil || !strings.Contains(jsonOut.String(), `"state"`) {
|
|
t.Fatalf("json status = %q, %v", jsonOut.String(), err)
|
|
}
|
|
})
|
|
|
|
t.Run("stop directory and corrupt state", func(t *testing.T) {
|
|
preserveDaemonHooks(t)
|
|
blocked := filepath.Join(t.TempDir(), "blocked")
|
|
if err := os.WriteFile(blocked, []byte("file"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
connectDaemonDirOverride = blocked
|
|
if _, err := daemonStopResult(&bytes.Buffer{}, "key"); err == nil {
|
|
t.Fatal("stop with blocked directory succeeded")
|
|
}
|
|
connectDaemonDirOverride = t.TempDir()
|
|
dir, _ := connectDaemonDir("corrupt")
|
|
if err := os.WriteFile(daemonStatePath(dir), []byte("{"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := daemonStopResult(&bytes.Buffer{}, "corrupt"); err == nil {
|
|
t.Fatal("stop with corrupt state succeeded")
|
|
}
|
|
})
|
|
|
|
t.Run("find error", func(t *testing.T) {
|
|
preserveDaemonHooks(t)
|
|
connectDaemonDirOverride = t.TempDir()
|
|
dir, _ := connectDaemonDir("find-error")
|
|
if err := writeDaemonState(dir, daemonState{Pid: 123, DirKey: "find-error"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
daemonProcessAlive = func(int) bool { return true }
|
|
daemonFindProcess = func(int) (*os.Process, error) { return nil, errors.New("find") }
|
|
if _, err := daemonStopResult(&bytes.Buffer{}, "find-error"); err == nil {
|
|
t.Fatal("find process error was ignored")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestCrossPlatformCoverageDaemonStopHookedLifecycleEdges(t *testing.T) {
|
|
const (
|
|
supervisorPID = 101
|
|
workerPID = 202
|
|
)
|
|
|
|
prepare := func(t *testing.T, key string, withWorker bool) {
|
|
t.Helper()
|
|
preserveDaemonHooks(t)
|
|
connectDaemonDirOverride = t.TempDir()
|
|
dir, err := connectDaemonDir(key)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := writeDaemonState(dir, daemonState{Pid: supervisorPID, DirKey: key}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if withWorker {
|
|
seedHeartbeat(t, key, connectHeartbeat{Pid: workerPID})
|
|
}
|
|
daemonFindProcess = func(int) (*os.Process, error) { return new(os.Process), nil }
|
|
}
|
|
|
|
t.Run("orphan worker exits gracefully", func(t *testing.T) {
|
|
prepare(t, "orphan-graceful", true)
|
|
workerChecks := 0
|
|
daemonProcessAlive = func(pid int) bool {
|
|
if pid == supervisorPID {
|
|
return false
|
|
}
|
|
workerChecks++
|
|
return workerChecks <= 2
|
|
}
|
|
base := time.Now()
|
|
daemonNow = func() time.Time { return base }
|
|
var signals []os.Signal
|
|
daemonSignalProcess = func(_ *os.Process, signal os.Signal) error {
|
|
signals = append(signals, signal)
|
|
return nil
|
|
}
|
|
sleeps := 0
|
|
helperSleep = func(time.Duration) { sleeps++ }
|
|
if _, err := daemonStopResult(&bytes.Buffer{}, "orphan-graceful"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(signals) != 1 || signals[0] != syscall.SIGTERM || sleeps != 1 {
|
|
t.Fatalf("signals=%v sleeps=%d", signals, sleeps)
|
|
}
|
|
})
|
|
|
|
t.Run("orphan worker is force killed", func(t *testing.T) {
|
|
prepare(t, "orphan-force-hooked", true)
|
|
daemonProcessAlive = func(pid int) bool { return pid == workerPID }
|
|
base := time.Now()
|
|
nowCalls := 0
|
|
daemonNow = func() time.Time {
|
|
nowCalls++
|
|
if nowCalls <= 2 {
|
|
return base
|
|
}
|
|
return base.Add(daemonStopTimeout + time.Second)
|
|
}
|
|
var signals []os.Signal
|
|
daemonSignalProcess = func(_ *os.Process, signal os.Signal) error {
|
|
signals = append(signals, signal)
|
|
return nil
|
|
}
|
|
helperSleep = func(time.Duration) {}
|
|
if _, err := daemonStopResult(&bytes.Buffer{}, "orphan-force-hooked"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(signals) != 2 || signals[0] != syscall.SIGTERM || signals[1] != syscall.SIGKILL {
|
|
t.Fatalf("signals=%v", signals)
|
|
}
|
|
})
|
|
|
|
t.Run("live supervisor exits gracefully", func(t *testing.T) {
|
|
prepare(t, "live-graceful-hooked", false)
|
|
aliveCalls := 0
|
|
daemonProcessAlive = func(int) bool {
|
|
aliveCalls++
|
|
return aliveCalls == 1
|
|
}
|
|
base := time.Now()
|
|
daemonNow = func() time.Time { return base }
|
|
daemonSignalProcess = func(*os.Process, os.Signal) error { return nil }
|
|
if _, err := daemonStopResult(&bytes.Buffer{}, "live-graceful-hooked"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
})
|
|
|
|
t.Run("live supervisor signal fails", func(t *testing.T) {
|
|
prepare(t, "live-signal-error-hooked", false)
|
|
daemonProcessAlive = func(int) bool { return true }
|
|
daemonSignalProcess = func(*os.Process, os.Signal) error { return errors.New("signal") }
|
|
if _, err := daemonStopResult(&bytes.Buffer{}, "live-signal-error-hooked"); err == nil {
|
|
t.Fatal("signal error was ignored")
|
|
}
|
|
})
|
|
|
|
t.Run("live supervisor is force killed", func(t *testing.T) {
|
|
prepare(t, "live-force-hooked", false)
|
|
daemonProcessAlive = func(int) bool { return true }
|
|
base := time.Now()
|
|
nowCalls := 0
|
|
daemonNow = func() time.Time {
|
|
nowCalls++
|
|
if nowCalls <= 2 {
|
|
return base
|
|
}
|
|
return base.Add(daemonStopTimeout + time.Second)
|
|
}
|
|
var signals []os.Signal
|
|
daemonSignalProcess = func(_ *os.Process, signal os.Signal) error {
|
|
signals = append(signals, signal)
|
|
return nil
|
|
}
|
|
helperSleep = func(time.Duration) {}
|
|
if _, err := daemonStopResult(&bytes.Buffer{}, "live-force-hooked"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(signals) != 2 || signals[0] != syscall.SIGTERM || signals[1] != syscall.SIGKILL {
|
|
t.Fatalf("signals=%v", signals)
|
|
}
|
|
})
|
|
}
|
|
|
|
type daemonSequenceRunner struct {
|
|
responses []map[string]any
|
|
calls int
|
|
}
|
|
|
|
func (r *daemonSequenceRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
|
|
index := r.calls
|
|
r.calls++
|
|
response := map[string]any{}
|
|
if index < len(r.responses) {
|
|
response = r.responses[index]
|
|
}
|
|
return executor.Result{Invocation: invocation, Response: response}, nil
|
|
}
|
|
|
|
func TestCrossPlatformCoverageDaemonListAndNamePaginationEdges(t *testing.T) {
|
|
preserveDaemonHooks(t)
|
|
connectDaemonDirOverride = t.TempDir()
|
|
cmd := &cobra.Command{Use: "list"}
|
|
|
|
runner := &daemonSequenceRunner{responses: []map[string]any{
|
|
{"items": []any{map[string]any{"id": "u-1", "appName": "App One"}}, "hasMore": true, "nextCursor": "next"},
|
|
{"items": []any{map[string]any{"unifiedAppId": "u-2", "name": "App Two"}}, "hasMore": false},
|
|
}}
|
|
names, err := devAppNameMap(cmd, runner)
|
|
if err != nil || names["u-1"] != "App One" || names["u-2"] != "App Two" || runner.calls != 2 {
|
|
t.Fatalf("paginated names = %#v calls=%d err=%v", names, runner.calls, err)
|
|
}
|
|
|
|
runner = &daemonSequenceRunner{responses: []map[string]any{{"hasMore": true}}}
|
|
if _, err := devAppNameMap(cmd, runner); err != nil || runner.calls != 1 {
|
|
t.Fatalf("empty cursor pagination calls=%d err=%v", runner.calls, err)
|
|
}
|
|
|
|
reports := []connectHealthReport{{UnifiedAppID: "u-1"}, {UnifiedAppID: "missing"}, {ClientID: "client"}}
|
|
runner = &daemonSequenceRunner{responses: []map[string]any{{
|
|
"items": []any{map[string]any{"unifiedAppId": "u-1", "name": "Resolved"}}, "hasMore": false,
|
|
}}}
|
|
resolveAppNames(cmd, runner, reports)
|
|
if reports[0].AppName != "Resolved" || reports[1].AppName != "" {
|
|
t.Fatalf("resolved reports = %#v", reports)
|
|
}
|
|
resolveAppNames(cmd, connectResponseRunner{err: errors.New("offline")}, []connectHealthReport{{UnifiedAppID: "u-1"}})
|
|
|
|
list := prepareUnifiedTestCommand(newDevAppRobotConnectListCommand(runner))
|
|
var out bytes.Buffer
|
|
list.SetOut(&out)
|
|
if err := list.Execute(); err != nil || strings.TrimSpace(out.String()) != "no connectors found" {
|
|
t.Fatalf("empty list output = %q, %v", out.String(), err)
|
|
}
|
|
list = prepareUnifiedTestCommand(newDevAppRobotConnectListCommand(runner))
|
|
out.Reset()
|
|
list.SetOut(&out)
|
|
list.SetArgs([]string{"--json"})
|
|
if err := list.Execute(); err != nil || strings.TrimSpace(out.String()) != "[]" {
|
|
t.Fatalf("json list array = %q, %v", out.String(), err)
|
|
}
|
|
|
|
seedHeartbeat(t, "listed", connectHeartbeat{
|
|
Pid: os.Getpid(), ClientID: strings.Repeat("c", 80), Channel: strings.Repeat("x", 80),
|
|
StartUnix: time.Now().Add(-time.Minute).Unix(), ConnectedUnix: time.Now().Add(-time.Minute).Unix(),
|
|
})
|
|
list = prepareUnifiedTestCommand(newDevAppRobotConnectListCommand(runner))
|
|
out.Reset()
|
|
list.SetOut(&out)
|
|
if err := list.Execute(); err != nil || !strings.Contains(out.String(), "STATE") {
|
|
t.Fatalf("table list = %q, %v", out.String(), err)
|
|
}
|
|
|
|
connectHealthReadDir = func(string) ([]os.DirEntry, error) {
|
|
return nil, errors.New("read directory")
|
|
}
|
|
list = prepareUnifiedTestCommand(newDevAppRobotConnectListCommand(runner))
|
|
list.SetOut(&bytes.Buffer{})
|
|
if err := list.Execute(); err == nil {
|
|
t.Fatal("list with blocked directory succeeded")
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageDaemonControlCommandEdges(t *testing.T) {
|
|
preserveDaemonHooks(t)
|
|
connectDaemonDirOverride = t.TempDir()
|
|
baseDir := connectDaemonDirOverride
|
|
defaultProcessAlive := daemonProcessAlive
|
|
defaultFindProcess := daemonFindProcess
|
|
|
|
for _, command := range []*cobra.Command{newDevAppRobotConnectStatusCommand(), newDevAppRobotConnectStopCommand(), newDevAppRobotConnectRestartCommand()} {
|
|
command = prepareUnifiedTestCommand(command)
|
|
command.SetArgs(nil)
|
|
command.SetOut(&bytes.Buffer{})
|
|
command.SetErr(&bytes.Buffer{})
|
|
if err := command.Execute(); err == nil {
|
|
t.Errorf("%s without identity succeeded", command.Name())
|
|
}
|
|
}
|
|
|
|
status := prepareUnifiedTestCommand(newDevAppRobotConnectStatusCommand())
|
|
status.SetArgs([]string{"--robot-client-id", "missing", "--json"})
|
|
status.SetOut(&bytes.Buffer{})
|
|
if err := status.Execute(); err != nil {
|
|
t.Fatalf("status command = %v", err)
|
|
}
|
|
stop := prepareUnifiedTestCommand(newDevAppRobotConnectStopCommand())
|
|
stop.SetArgs([]string{"--unified-app-id", "missing", "--yes"})
|
|
stop.SetOut(&bytes.Buffer{})
|
|
if err := stop.Execute(); err != nil {
|
|
t.Fatalf("stop command = %v", err)
|
|
}
|
|
|
|
restart := prepareUnifiedTestCommand(newDevAppRobotConnectRestartCommand())
|
|
restart.SetArgs([]string{"--robot-client-id", "missing"})
|
|
restart.SetOut(&bytes.Buffer{})
|
|
restart.SetErr(&bytes.Buffer{})
|
|
if err := restart.Execute(); err == nil {
|
|
t.Fatal("restart without state succeeded")
|
|
}
|
|
|
|
blocked := filepath.Join(t.TempDir(), "blocked")
|
|
if err := os.WriteFile(blocked, []byte("file"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
connectDaemonDirOverride = blocked
|
|
restart = prepareUnifiedTestCommand(newDevAppRobotConnectRestartCommand())
|
|
restart.SetArgs([]string{"--robot-client-id", "blocked"})
|
|
restart.SetOut(&bytes.Buffer{})
|
|
restart.SetErr(&bytes.Buffer{})
|
|
if err := restart.Execute(); err == nil {
|
|
t.Fatal("restart with blocked directory succeeded")
|
|
}
|
|
connectDaemonDirOverride = baseDir
|
|
corruptDir, _ := connectDaemonDir("corrupt-restart")
|
|
if err := os.WriteFile(daemonStatePath(corruptDir), []byte("{"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
restart = prepareUnifiedTestCommand(newDevAppRobotConnectRestartCommand())
|
|
restart.SetArgs([]string{"--robot-client-id", "corrupt-restart"})
|
|
restart.SetOut(&bytes.Buffer{})
|
|
restart.SetErr(&bytes.Buffer{})
|
|
if err := restart.Execute(); err == nil {
|
|
t.Fatal("restart with corrupt state succeeded")
|
|
}
|
|
|
|
dir, _ := connectDaemonDir("no-unified")
|
|
if err := writeDaemonState(dir, daemonState{Pid: deadPid(t), DirKey: "no-unified"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
restart = newDevAppRobotConnectRestartCommand()
|
|
restart.SetArgs([]string{"--robot-client-id", "no-unified"})
|
|
restart.SetOut(&bytes.Buffer{})
|
|
restart.SetErr(&bytes.Buffer{})
|
|
if err := restart.Execute(); err == nil {
|
|
t.Fatal("restart without unified app ID succeeded")
|
|
}
|
|
|
|
dir, _ = connectDaemonDir("restart")
|
|
state := daemonState{Pid: deadPid(t), DirKey: "restart", UnifiedAppID: "app", Channel: "custom", NotifyStaffID: "staff", Profile: "saved", AlwaysOn: true}
|
|
if err := writeDaemonState(dir, state); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
daemonExecutable = func() (string, error) { return "/bin/sh", nil }
|
|
daemonCommand = func(string, ...string) *exec.Cmd { return exec.Command("sh", "-c", "exit 0") }
|
|
restart = prepareUnifiedTestCommand(newDevAppRobotConnectRestartCommand())
|
|
restart.SetArgs([]string{"--robot-client-id", "restart", "--yes"})
|
|
restart.SetOut(&bytes.Buffer{})
|
|
restart.SetErr(&bytes.Buffer{})
|
|
if err := restart.Execute(); err != nil {
|
|
t.Fatalf("restart command = %v", err)
|
|
}
|
|
|
|
if err := writeDaemonState(dir, state); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
daemonProcessAlive = func(int) bool { return true }
|
|
daemonFindProcess = func(int) (*os.Process, error) { return nil, errors.New("find") }
|
|
restart = prepareUnifiedTestCommand(newDevAppRobotConnectRestartCommand())
|
|
restart.SetArgs([]string{"--robot-client-id", "restart", "--yes"})
|
|
restart.SetOut(&bytes.Buffer{})
|
|
restart.SetErr(&bytes.Buffer{})
|
|
if err := restart.Execute(); err != nil {
|
|
t.Fatalf("restart after stop warning = %v", err)
|
|
}
|
|
daemonProcessAlive = defaultProcessAlive
|
|
daemonFindProcess = defaultFindProcess
|
|
|
|
if err := writeDaemonState(dir, state); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
root := &cobra.Command{Use: "dws"}
|
|
ctx, _ := output.WithResultStore(context.Background())
|
|
root.SetContext(ctx)
|
|
root.PersistentFlags().String("profile", "", "")
|
|
root.PersistentFlags().String("format", "json", "")
|
|
root.PersistentFlags().Bool("dry-run", false, "")
|
|
root.PersistentFlags().Bool("yes", false, "")
|
|
root.PersistentPostRunE = func(cmd *cobra.Command, _ []string) error {
|
|
_, _, err := output.EmitStoredResult(cmd)
|
|
return err
|
|
}
|
|
restart = newDevAppRobotConnectRestartCommand()
|
|
root.AddCommand(restart)
|
|
root.SetArgs([]string{"restart", "--robot-client-id", "restart", "--profile", "override", "--yes"})
|
|
root.SetOut(&bytes.Buffer{})
|
|
root.SetErr(&bytes.Buffer{})
|
|
if err := root.Execute(); err != nil {
|
|
t.Fatalf("restart with profile override = %v", err)
|
|
}
|
|
|
|
if err := writeDaemonState(dir, state); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
daemonExecutable = func() (string, error) { return "", errors.New("missing") }
|
|
restart = newDevAppRobotConnectRestartCommand()
|
|
restart.SetArgs([]string{"--robot-client-id", "restart"})
|
|
restart.SetOut(&bytes.Buffer{})
|
|
restart.SetErr(&bytes.Buffer{})
|
|
if err := restart.Execute(); err == nil {
|
|
t.Fatal("restart without executable succeeded")
|
|
}
|
|
|
|
daemonExecutable = func() (string, error) { return "/bin/sh", nil }
|
|
daemonCommand = func(string, ...string) *exec.Cmd { return exec.Command("sh", "-c", "exit 1") }
|
|
if err := writeDaemonState(dir, state); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
restart = newDevAppRobotConnectRestartCommand()
|
|
restart.SetArgs([]string{"--robot-client-id", "restart"})
|
|
restart.SetOut(&bytes.Buffer{})
|
|
restart.SetErr(&bytes.Buffer{})
|
|
if err := restart.Execute(); err == nil {
|
|
t.Fatal("failing restart subprocess succeeded")
|
|
}
|
|
}
|
|
|
|
func TestFrameworkConnectControlDryRunPlansAndLegacyListRollback(t *testing.T) {
|
|
preserveDaemonHooks(t)
|
|
connectDaemonDirOverride = t.TempDir()
|
|
|
|
stop := prepareUnifiedTestCommand(newDevAppRobotConnectStopCommand())
|
|
stop.SetArgs([]string{"--unified-app-id", "app", "--dry-run"})
|
|
var stopOut bytes.Buffer
|
|
stop.SetOut(&stopOut)
|
|
if err := stop.Execute(); err != nil || !strings.Contains(stopOut.String(), `"preview_kind": "plan"`) {
|
|
t.Fatalf("stop preview=%q err=%v", stopOut.String(), err)
|
|
}
|
|
|
|
restart := prepareUnifiedTestCommand(newDevAppRobotConnectRestartCommand())
|
|
restart.SetArgs([]string{"--unified-app-id", "app", "--dry-run"})
|
|
var restartOut bytes.Buffer
|
|
restart.SetOut(&restartOut)
|
|
if err := restart.Execute(); err != nil || !strings.Contains(restartOut.String(), `"preview_kind": "plan"`) {
|
|
t.Fatalf("restart preview=%q err=%v", restartOut.String(), err)
|
|
}
|
|
|
|
dir, err := connectDaemonDir("saved")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := writeDaemonState(dir, daemonState{DirKey: "saved", UnifiedAppID: "saved-app", Channel: "custom"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
restart = prepareUnifiedTestCommand(newDevAppRobotConnectRestartCommand())
|
|
restart.SetArgs([]string{"--robot-client-id", "saved", "--dry-run"})
|
|
restartOut.Reset()
|
|
restart.SetOut(&restartOut)
|
|
if err := restart.Execute(); err != nil || !strings.Contains(restartOut.String(), "saved-app") {
|
|
t.Fatalf("saved restart preview=%q err=%v", restartOut.String(), err)
|
|
}
|
|
|
|
list := prepareUnifiedTestCommand(newDevAppRobotConnectListCommand(connectResponseRunner{response: map[string]any{"items": []any{}, "hasMore": false}}))
|
|
output.SetCommandRollout(list, output.RolloutLegacyOnly)
|
|
for _, format := range []string{"json", "table", "pretty"} {
|
|
var out bytes.Buffer
|
|
list.SetOut(&out)
|
|
list.SetArgs([]string{"--format", format})
|
|
if err := list.Execute(); err != nil {
|
|
t.Fatalf("legacy list %s: %v", format, err)
|
|
}
|
|
}
|
|
}
|