326 lines
11 KiB
Go
326 lines
11 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package helpers
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"os"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestBackoffDelay(t *testing.T) {
|
|
base := time.Second
|
|
cap := 60 * time.Second
|
|
cases := []struct {
|
|
failures int
|
|
want time.Duration
|
|
}{
|
|
{0, 0},
|
|
{-1, 0},
|
|
{1, time.Second},
|
|
{2, 2 * time.Second},
|
|
{3, 4 * time.Second},
|
|
{4, 8 * time.Second},
|
|
{5, 16 * time.Second},
|
|
{6, 32 * time.Second},
|
|
{7, cap}, // 64s capped to 60s
|
|
{8, cap},
|
|
{100, cap},
|
|
}
|
|
for _, c := range cases {
|
|
if got := backoffDelay(c.failures, base, cap); got != c.want {
|
|
t.Errorf("backoffDelay(%d) = %v, want %v", c.failures, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDaemonDirKey(t *testing.T) {
|
|
cases := []struct {
|
|
clientID string
|
|
unifiedAppID string
|
|
want string
|
|
}{
|
|
{"clientABC", "", "clientABC"},
|
|
{"client/with:bad*chars", "", "client_with_bad_chars"},
|
|
{"", "app-123", "app-app-123"},
|
|
{"", "u/n.id", "app-u_n_id"},
|
|
{" ", " ", ""},
|
|
{"cid", "uid", "cid"}, // clientID wins
|
|
}
|
|
for _, c := range cases {
|
|
if got := daemonDirKey(c.clientID, c.unifiedAppID); got != c.want {
|
|
t.Errorf("daemonDirKey(%q,%q) = %q, want %q", c.clientID, c.unifiedAppID, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestStageDaemonExecutable(t *testing.T) {
|
|
dir := t.TempDir()
|
|
src := dir + "/source"
|
|
if err := os.WriteFile(src, []byte("test-binary"), 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
dst, err := stageDaemonExecutable(src, dir)
|
|
if err != nil {
|
|
t.Fatalf("stageDaemonExecutable: %v", err)
|
|
}
|
|
if dst != daemonExecutablePath(dir) {
|
|
t.Fatalf("path = %q, want %q", dst, daemonExecutablePath(dir))
|
|
}
|
|
got, err := os.ReadFile(dst)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(got) != "test-binary" {
|
|
t.Fatalf("content = %q, want test-binary", got)
|
|
}
|
|
if runtime.GOOS != "windows" {
|
|
info, err := os.Stat(dst)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if info.Mode().Perm() != daemonExecutablePerm {
|
|
t.Fatalf("mode = %o, want %o", info.Mode().Perm(), daemonExecutablePerm)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildWorkerArgs(t *testing.T) {
|
|
in := []string{"devapp", "robot", "connect", "--daemon", "--robot-client-id", "abc", "--channel=claudecode"}
|
|
got := buildWorkerArgs(in)
|
|
joined := strings.Join(got, " ")
|
|
// only the appended --daemon-worker may contain "daemon"
|
|
if strings.Contains(joined, "--daemon ") || strings.HasSuffix(joined, "--daemon") {
|
|
t.Errorf("worker args must not contain bare --daemon, got %q", joined)
|
|
}
|
|
if !strings.HasSuffix(joined, "--daemon-worker") {
|
|
t.Errorf("worker args must end with --daemon-worker, got %q", joined)
|
|
}
|
|
for _, a := range got[:len(got)-1] {
|
|
if a == "--daemon" || a == "--daemon-supervise" || a == "--daemon-worker" {
|
|
t.Errorf("daemon-control flag leaked into worker args: %q", a)
|
|
}
|
|
}
|
|
if !strings.Contains(joined, "--robot-client-id abc") || !strings.Contains(joined, "--channel=claudecode") {
|
|
t.Errorf("credential/channel flags must be preserved, got %q", joined)
|
|
}
|
|
}
|
|
|
|
func TestBuildSuperviseArgs(t *testing.T) {
|
|
in := []string{"devapp", "robot", "connect", "--daemon", "--robot-client-id", "abc"}
|
|
got := buildSuperviseArgs(in)
|
|
joined := strings.Join(got, " ")
|
|
if !strings.HasSuffix(joined, "--daemon-supervise") {
|
|
t.Errorf("supervise args must end with --daemon-supervise, got %q", joined)
|
|
}
|
|
if strings.Contains(joined, " --daemon ") || strings.Contains(joined, " --daemon\b") {
|
|
t.Errorf("--daemon should be stripped, got %q", joined)
|
|
}
|
|
for _, a := range got[:len(got)-1] {
|
|
if a == "--daemon" {
|
|
t.Errorf("--daemon leaked into supervise args: %q", a)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWriteConnectDaemonStartedIncludesLocalDebugNotice(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
writeConnectDaemonStarted(&buf, 1234, "/tmp/daemon.log", "cid", "cid")
|
|
out := buf.String()
|
|
for _, want := range []string{"connect daemon started", "/tmp/daemon.log", "本地调试", "不代表线上发布完成", "不会提交版本发布"} {
|
|
if !strings.Contains(out, want) {
|
|
t.Fatalf("daemon started output missing %q:\n%s", want, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDaemonStateRoundTrip(t *testing.T) {
|
|
connectDaemonDirOverride = t.TempDir()
|
|
t.Cleanup(func() { connectDaemonDirOverride = "" })
|
|
|
|
dir, err := connectDaemonDir("roundtrip")
|
|
if err != nil {
|
|
t.Fatalf("connectDaemonDir: %v", err)
|
|
}
|
|
// No file yet → nil, nil.
|
|
if st, err := readDaemonState(dir); err != nil || st != nil {
|
|
t.Fatalf("expected (nil,nil) for missing pid file, got (%v,%v)", st, err)
|
|
}
|
|
want := daemonState{Pid: 4242, StartUnix: time.Now().Unix(), LogPath: "/x/y.log", DirKey: "roundtrip", ClientID: "cid", Profile: "ding123", AlwaysOn: true}
|
|
if err := writeDaemonState(dir, want); err != nil {
|
|
t.Fatalf("writeDaemonState: %v", err)
|
|
}
|
|
got, err := readDaemonState(dir)
|
|
if err != nil || got == nil {
|
|
t.Fatalf("readDaemonState: (%v,%v)", got, err)
|
|
}
|
|
if got.Pid != want.Pid || got.DirKey != want.DirKey || got.ClientID != want.ClientID || got.LogPath != want.LogPath {
|
|
t.Errorf("round trip mismatch: got %+v want %+v", *got, want)
|
|
}
|
|
if got.Profile != want.Profile {
|
|
t.Errorf("Profile round trip: got %q want %q", got.Profile, want.Profile)
|
|
}
|
|
if got.AlwaysOn != want.AlwaysOn {
|
|
t.Errorf("AlwaysOn round trip: got %v want %v", got.AlwaysOn, want.AlwaysOn)
|
|
}
|
|
}
|
|
|
|
func TestReadDaemonStateCorrupt(t *testing.T) {
|
|
connectDaemonDirOverride = t.TempDir()
|
|
t.Cleanup(func() { connectDaemonDirOverride = "" })
|
|
dir, _ := connectDaemonDir("corrupt")
|
|
if err := os.WriteFile(daemonPidPath(dir), []byte("{not json"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := readDaemonState(dir); err == nil {
|
|
t.Error("expected error for corrupt pid file")
|
|
}
|
|
}
|
|
|
|
// seedHeartbeat writes a connector heartbeat under dirKey for status tests.
|
|
func seedHeartbeat(t *testing.T, dirKey string, hb connectHeartbeat) string {
|
|
t.Helper()
|
|
dir, err := connectDaemonDir(dirKey)
|
|
if err != nil {
|
|
t.Fatalf("connectDaemonDir: %v", err)
|
|
}
|
|
data, err := json.MarshalIndent(hb, "", " ")
|
|
if err != nil {
|
|
t.Fatalf("marshal heartbeat: %v", err)
|
|
}
|
|
if err := os.WriteFile(connectHeartbeatPath(dir), data, 0o644); err != nil {
|
|
t.Fatalf("write heartbeat: %v", err)
|
|
}
|
|
return dir
|
|
}
|
|
|
|
func TestDaemonStatusNotRunning(t *testing.T) {
|
|
connectDaemonDirOverride = t.TempDir()
|
|
t.Cleanup(func() { connectDaemonDirOverride = "" })
|
|
var buf bytes.Buffer
|
|
// No daemon pid file and no connector heartbeat.
|
|
if err := daemonStatus(&buf, "nope", false); err != nil {
|
|
t.Fatalf("daemonStatus: %v", err)
|
|
}
|
|
if !strings.Contains(buf.String(), healthNotRunning) {
|
|
t.Errorf("expected %q, got %q", healthNotRunning, buf.String())
|
|
}
|
|
}
|
|
|
|
func TestDaemonStatusConnectorDown(t *testing.T) {
|
|
connectDaemonDirOverride = t.TempDir()
|
|
t.Cleanup(func() { connectDaemonDirOverride = "" })
|
|
// Heartbeat from a connector whose process is gone: down.
|
|
seedHeartbeat(t, "gone", connectHeartbeat{Pid: deadPid(t), StartUnix: time.Now().Unix() - 100, ConnectedUnix: time.Now().Unix() - 90})
|
|
var buf bytes.Buffer
|
|
if err := daemonStatus(&buf, "gone", false); err != nil {
|
|
t.Fatalf("daemonStatus: %v", err)
|
|
}
|
|
if !strings.Contains(buf.String(), healthDown) {
|
|
t.Errorf("expected %q, got %q", healthDown, buf.String())
|
|
}
|
|
}
|
|
|
|
func TestDaemonStatusHealthy(t *testing.T) {
|
|
connectDaemonDirOverride = t.TempDir()
|
|
t.Cleanup(func() { connectDaemonDirOverride = "" })
|
|
// Live connector (our pid) that connected: healthy. Also file a live
|
|
// supervisor to exercise the supervised label.
|
|
dir, _ := connectDaemonDir("live")
|
|
writeDaemonState(dir, daemonState{Pid: os.Getpid(), StartUnix: time.Now().Add(-90 * time.Second).Unix(), LogPath: "/l.log", DirKey: "live", ClientID: "cidX"})
|
|
seedHeartbeat(t, "live", connectHeartbeat{Pid: os.Getpid(), Channel: "opencode", ClientID: "cidX", StartUnix: time.Now().Unix() - 90, ConnectedUnix: time.Now().Unix() - 88, LastReplyUnix: time.Now().Unix() - 5})
|
|
var buf bytes.Buffer
|
|
if err := daemonStatus(&buf, "live", false); err != nil {
|
|
t.Fatalf("daemonStatus: %v", err)
|
|
}
|
|
out := buf.String()
|
|
for _, want := range []string{healthHealthy, "pid:", "channel:", "opencode", "cidX", "super:"} {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("status output missing %q; got %q", want, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDaemonStatusJSON(t *testing.T) {
|
|
connectDaemonDirOverride = t.TempDir()
|
|
t.Cleanup(func() { connectDaemonDirOverride = "" })
|
|
seedHeartbeat(t, "j", connectHeartbeat{Pid: os.Getpid(), Channel: "codex", ClientID: "cidJ", StartUnix: time.Now().Unix() - 30, ConnectedUnix: time.Now().Unix() - 28})
|
|
var buf bytes.Buffer
|
|
if err := daemonStatus(&buf, "j", true); err != nil {
|
|
t.Fatalf("daemonStatus json: %v", err)
|
|
}
|
|
var report connectHealthReport
|
|
if err := json.Unmarshal(buf.Bytes(), &report); err != nil {
|
|
t.Fatalf("output is not valid JSON: %v\n%s", err, buf.String())
|
|
}
|
|
// --json is a published supervisor API: health fields remain at top level.
|
|
if strings.Contains(buf.String(), `"outcome"`) || strings.Contains(buf.String(), `"data"`) {
|
|
t.Fatalf("status compatibility JSON was enveloped: %s", buf.String())
|
|
}
|
|
if report.State != healthHealthy {
|
|
t.Errorf("state = %q, want %q", report.State, healthHealthy)
|
|
}
|
|
if report.Channel != "codex" {
|
|
t.Errorf("channel = %q, want codex", report.Channel)
|
|
}
|
|
}
|
|
|
|
func TestDaemonStopNotRunning(t *testing.T) {
|
|
connectDaemonDirOverride = t.TempDir()
|
|
t.Cleanup(func() { connectDaemonDirOverride = "" })
|
|
var buf bytes.Buffer
|
|
if _, err := daemonStopResult(&buf, "ghost"); err != nil {
|
|
t.Fatalf("daemonStop: %v", err)
|
|
}
|
|
if !strings.Contains(buf.String(), "not running") {
|
|
t.Errorf("expected 'not running', got %q", buf.String())
|
|
}
|
|
}
|
|
|
|
func TestDaemonStopStaleCleansPidFile(t *testing.T) {
|
|
connectDaemonDirOverride = t.TempDir()
|
|
t.Cleanup(func() { connectDaemonDirOverride = "" })
|
|
dir, _ := connectDaemonDir("stalestop")
|
|
writeDaemonState(dir, daemonState{Pid: deadPid(t), StartUnix: time.Now().Unix(), DirKey: "stalestop"})
|
|
var buf bytes.Buffer
|
|
if _, err := daemonStopResult(&buf, "stalestop"); err != nil {
|
|
t.Fatalf("daemonStop: %v", err)
|
|
}
|
|
if _, err := os.Stat(daemonPidPath(dir)); !os.IsNotExist(err) {
|
|
t.Errorf("stale pid file should be removed, stat err=%v", err)
|
|
}
|
|
if !strings.Contains(buf.String(), "stale") {
|
|
t.Errorf("expected stale cleanup message, got %q", buf.String())
|
|
}
|
|
}
|
|
|
|
// deadPid returns a pid that is not alive. It spawns `true`, waits for it to
|
|
// exit, and returns its pid — guaranteed reaped and gone.
|
|
func deadPid(t *testing.T) int {
|
|
t.Helper()
|
|
// A very high pid is almost never live; combine with a sanity probe.
|
|
for _, candidate := range []int{999999, 524287, 99999} {
|
|
if !processAlive(candidate) {
|
|
return candidate
|
|
}
|
|
}
|
|
t.Skip("could not find a guaranteed-dead pid on this host")
|
|
return 0
|
|
}
|