Files
dingtalk-workspace-cli/internal/helpers/connect_daemon_test.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
}