mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
feat(web): display backend version info in sidebar (#2087)
* feat(web): display backend version info in sidebar * fix(web): improve version parsing and timeout behavior * refactor(web): remove useless --version fallback * feat(web): implement version info caching and improve retrieval logic * fix(web): clarify version timeout rationale * fix(web): harden gateway version probing and tests * style(web): split regexp to two lines for lint
This commit is contained in:
@@ -76,6 +76,9 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||
// Launcher service parameters (port/public)
|
||||
h.registerLauncherConfigRoutes(mux)
|
||||
|
||||
// Runtime build/version metadata
|
||||
h.registerVersionRoutes(mux)
|
||||
|
||||
// WeChat QR login flow
|
||||
h.registerWeixinRoutes(mux)
|
||||
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/web/backend/utils"
|
||||
)
|
||||
|
||||
type systemVersionResponse struct {
|
||||
Version string `json:"version"`
|
||||
GitCommit string `json:"git_commit,omitempty"`
|
||||
BuildTime string `json:"build_time,omitempty"`
|
||||
GoVersion string `json:"go_version"`
|
||||
}
|
||||
|
||||
type cachedSystemVersion struct {
|
||||
value systemVersionResponse
|
||||
gatewayPID int
|
||||
}
|
||||
|
||||
type systemVersionCache struct {
|
||||
mu sync.Mutex
|
||||
current cachedSystemVersion
|
||||
hasCurrent bool
|
||||
inflightCh chan struct{}
|
||||
}
|
||||
|
||||
func newSystemVersionCache() *systemVersionCache {
|
||||
return &systemVersionCache{}
|
||||
}
|
||||
|
||||
var (
|
||||
// 15 seconds matches the gateway startup window used elsewhere in launcher flow,
|
||||
// giving slow/embedded hosts enough time for first command invocation while
|
||||
// staying independent from cross-file init ordering.
|
||||
versionCmdTimeout = 15 * time.Second
|
||||
maxVersionResolveAttempts = 3
|
||||
findPicoclawBinaryForInfo = resolveGatewayBinaryForVersionInfo
|
||||
runPicoclawVersionOutput = executePicoclawVersion
|
||||
currentGatewayVersionState = gatewayVersionState
|
||||
launcherBuildInfoForVersion = fallbackSystemVersionInfoFromConfig
|
||||
versionInfoCache = newSystemVersionCache()
|
||||
ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;]*m`)
|
||||
versionLinePattern = regexp.MustCompile(
|
||||
`^(?:[^A-Za-z0-9]*\s*)?picoclaw(?:\.exe)?\s+([^\s(]+)` +
|
||||
`(?:\s+\(git:\s*([^)]+)\))?\s*$`,
|
||||
)
|
||||
)
|
||||
|
||||
func (h *Handler) registerVersionRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/system/version", h.handleGetVersion)
|
||||
}
|
||||
|
||||
// handleGetVersion returns runtime version information for web clients.
|
||||
func (h *Handler) handleGetVersion(w http.ResponseWriter, r *http.Request) {
|
||||
versionInfo := h.resolveSystemVersionInfo(r.Context())
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(versionInfo); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// resolveSystemVersionInfo prefers the actual picoclaw binary version output,
|
||||
// and falls back to launcher build metadata when command execution fails.
|
||||
func (h *Handler) resolveSystemVersionInfo(ctx context.Context) systemVersionResponse {
|
||||
for range maxVersionResolveAttempts {
|
||||
gatewayPID, gatewayAlive := currentGatewayVersionState()
|
||||
if cached, ok := versionInfoCache.get(gatewayPID, gatewayAlive); ok {
|
||||
return cached
|
||||
}
|
||||
|
||||
leader, ok := versionInfoCache.waitOrStart(ctx)
|
||||
if !ok {
|
||||
return fallbackSystemVersionInfo()
|
||||
}
|
||||
if !leader {
|
||||
continue
|
||||
}
|
||||
|
||||
resolved := h.resolveSystemVersionInfoUncached(ctx)
|
||||
gatewayPID, gatewayAlive = currentGatewayVersionState()
|
||||
versionInfoCache.finishResolve(resolved, gatewayPID, gatewayAlive)
|
||||
return resolved
|
||||
}
|
||||
|
||||
return fallbackSystemVersionInfo()
|
||||
}
|
||||
|
||||
func (h *Handler) resolveSystemVersionInfoUncached(ctx context.Context) systemVersionResponse {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
fallback := fallbackSystemVersionInfo()
|
||||
|
||||
execPath := strings.TrimSpace(findPicoclawBinaryForInfo())
|
||||
if execPath == "" {
|
||||
return fallback
|
||||
}
|
||||
|
||||
cmdCtx, cancel := context.WithTimeout(ctx, versionCmdTimeout)
|
||||
defer cancel()
|
||||
|
||||
output, err := runPicoclawVersionOutput(cmdCtx, execPath)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
|
||||
parsed, ok := parsePicoclawVersionOutput(output)
|
||||
if !ok {
|
||||
return fallback
|
||||
}
|
||||
|
||||
if parsed.GoVersion == "" {
|
||||
parsed.GoVersion = fallback.GoVersion
|
||||
if parsed.GoVersion == "" {
|
||||
parsed.GoVersion = runtime.Version()
|
||||
}
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
func fallbackSystemVersionInfo() systemVersionResponse {
|
||||
return launcherBuildInfoForVersion()
|
||||
}
|
||||
|
||||
func fallbackSystemVersionInfoFromConfig() systemVersionResponse {
|
||||
buildTime, goVer := config.FormatBuildInfo()
|
||||
return systemVersionResponse{
|
||||
Version: config.GetVersion(),
|
||||
GitCommit: config.GitCommit,
|
||||
BuildTime: buildTime,
|
||||
GoVersion: goVer,
|
||||
}
|
||||
}
|
||||
|
||||
// resolveGatewayBinaryForVersionInfo uses the same executable as the launcher
|
||||
// gateway start path when available, then falls back to launcher binary lookup.
|
||||
// This keeps version probing aligned with the actual gateway startup behavior,
|
||||
// so web and gateway do not drift onto different binaries.
|
||||
func resolveGatewayBinaryForVersionInfo() string {
|
||||
gateway.mu.Lock()
|
||||
cmd := gateway.cmd
|
||||
gateway.mu.Unlock()
|
||||
|
||||
if cmd != nil {
|
||||
if execPath := strings.TrimSpace(cmd.Path); execPath != "" {
|
||||
return execPath
|
||||
}
|
||||
}
|
||||
|
||||
return utils.FindPicoclawBinary()
|
||||
}
|
||||
|
||||
func gatewayVersionState() (int, bool) {
|
||||
gateway.mu.Lock()
|
||||
defer gateway.mu.Unlock()
|
||||
|
||||
if gateway.cmd == nil || gateway.cmd.Process == nil {
|
||||
return 0, false
|
||||
}
|
||||
pid := gateway.cmd.Process.Pid
|
||||
if pid <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return pid, isCmdProcessAliveLocked(gateway.cmd)
|
||||
}
|
||||
|
||||
func (c *systemVersionCache) get(gatewayPID int, gatewayAlive bool) (systemVersionResponse, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.hasCurrent && (!gatewayAlive || gatewayPID <= 0 || gatewayPID != c.current.gatewayPID) {
|
||||
c.clearCurrentLocked()
|
||||
}
|
||||
|
||||
if c.hasCurrent {
|
||||
return c.current.value, true
|
||||
}
|
||||
|
||||
return systemVersionResponse{}, false
|
||||
}
|
||||
|
||||
func (c *systemVersionCache) waitOrStart(ctx context.Context) (bool, bool) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return false, false
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
if c.inflightCh == nil {
|
||||
c.inflightCh = make(chan struct{})
|
||||
c.mu.Unlock()
|
||||
return true, true
|
||||
}
|
||||
waitCh := c.inflightCh
|
||||
c.mu.Unlock()
|
||||
|
||||
select {
|
||||
case <-waitCh:
|
||||
return false, true
|
||||
case <-ctx.Done():
|
||||
return false, false
|
||||
}
|
||||
}
|
||||
|
||||
func (c *systemVersionCache) finishResolve(value systemVersionResponse, gatewayPID int, gatewayAlive bool) {
|
||||
c.mu.Lock()
|
||||
if gatewayAlive && gatewayPID > 0 {
|
||||
c.current = cachedSystemVersion{value: value, gatewayPID: gatewayPID}
|
||||
c.hasCurrent = true
|
||||
} else {
|
||||
c.clearCurrentLocked()
|
||||
}
|
||||
|
||||
inflightCh := c.inflightCh
|
||||
c.inflightCh = nil
|
||||
c.mu.Unlock()
|
||||
|
||||
if inflightCh != nil {
|
||||
close(inflightCh)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *systemVersionCache) clearCurrentLocked() {
|
||||
c.hasCurrent = false
|
||||
c.current = cachedSystemVersion{}
|
||||
}
|
||||
|
||||
func (c *systemVersionCache) resetForTest() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.current = cachedSystemVersion{}
|
||||
c.hasCurrent = false
|
||||
if c.inflightCh != nil {
|
||||
close(c.inflightCh)
|
||||
c.inflightCh = nil
|
||||
}
|
||||
}
|
||||
|
||||
// executePicoclawVersion runs the version subcommand against the
|
||||
// discovered picoclaw executable.
|
||||
func executePicoclawVersion(ctx context.Context, execPath string) (string, error) {
|
||||
out, err := exec.CommandContext(ctx, execPath, "version").CombinedOutput()
|
||||
if err == nil {
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
return string(out), fmt.Errorf("failed to execute version command: %w", err)
|
||||
}
|
||||
|
||||
// parsePicoclawVersionOutput extracts version/build/go fields from CLI output.
|
||||
// It accepts banner/ANSI-decorated output and only requires the version line.
|
||||
func parsePicoclawVersionOutput(raw string) (systemVersionResponse, bool) {
|
||||
var result systemVersionResponse
|
||||
|
||||
scanner := bufio.NewScanner(strings.NewReader(raw))
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(ansiEscapePattern.ReplaceAllString(scanner.Text(), ""))
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if match := versionLinePattern.FindStringSubmatch(line); len(match) > 0 {
|
||||
candidateVersion := strings.TrimSpace(match[1])
|
||||
if !isLikelyVersionValue(candidateVersion) {
|
||||
continue
|
||||
}
|
||||
result.Version = candidateVersion
|
||||
if len(match) > 2 {
|
||||
result.GitCommit = strings.TrimSpace(match[2])
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if buildValue, ok := strings.CutPrefix(line, "Build:"); ok {
|
||||
result.BuildTime = strings.TrimSpace(buildValue)
|
||||
continue
|
||||
}
|
||||
|
||||
if goValue, ok := strings.CutPrefix(line, "Go:"); ok {
|
||||
result.GoVersion = strings.TrimSpace(goValue)
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return systemVersionResponse{}, false
|
||||
}
|
||||
|
||||
if result.Version == "" {
|
||||
return systemVersionResponse{}, false
|
||||
}
|
||||
|
||||
return result, true
|
||||
}
|
||||
|
||||
func isLikelyVersionValue(value string) bool {
|
||||
v := strings.TrimSpace(strings.ToLower(value))
|
||||
if v == "" {
|
||||
return false
|
||||
}
|
||||
if v == "dev" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Accept git-like short/long hashes even when they contain only letters (a-f).
|
||||
if len(v) >= 7 && len(v) <= 40 {
|
||||
allHex := true
|
||||
for _, ch := range v {
|
||||
if (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') {
|
||||
continue
|
||||
}
|
||||
allHex = false
|
||||
break
|
||||
}
|
||||
if allHex {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
for _, ch := range v {
|
||||
if ch >= '0' && ch <= '9' {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func setupVersionTestIsolation(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
originalGatewayState := currentGatewayVersionState
|
||||
originalFinder := findPicoclawBinaryForInfo
|
||||
originalRunner := runPicoclawVersionOutput
|
||||
originalFallback := launcherBuildInfoForVersion
|
||||
t.Cleanup(func() {
|
||||
currentGatewayVersionState = originalGatewayState
|
||||
findPicoclawBinaryForInfo = originalFinder
|
||||
runPicoclawVersionOutput = originalRunner
|
||||
launcherBuildInfoForVersion = originalFallback
|
||||
versionInfoCache.resetForTest()
|
||||
})
|
||||
|
||||
currentGatewayVersionState = func() (int, bool) { return 0, false }
|
||||
versionInfoCache.resetForTest()
|
||||
}
|
||||
|
||||
func TestGetSystemVersionUsesPicoclawBinaryInfo(t *testing.T) {
|
||||
setupVersionTestIsolation(t)
|
||||
|
||||
launcherBuildInfoForVersion = func() systemVersionResponse {
|
||||
return systemVersionResponse{Version: "fallback", GoVersion: "go-fallback"}
|
||||
}
|
||||
|
||||
findPicoclawBinaryForInfo = func() string { return "picoclaw" }
|
||||
runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) {
|
||||
return "🦞 picoclaw v1.2.3 (git: deadbeef)\n Build: 2026-03-27T12:34:56Z\n Go: go1.25.8\n", nil
|
||||
}
|
||||
|
||||
h := NewHandler("")
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/system/version", nil)
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
var got systemVersionResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
if got.Version != "v1.2.3" {
|
||||
t.Fatalf("version = %q, want %q", got.Version, "v1.2.3")
|
||||
}
|
||||
if got.GitCommit != "deadbeef" {
|
||||
t.Fatalf("git_commit = %q, want %q", got.GitCommit, "deadbeef")
|
||||
}
|
||||
if got.BuildTime != "2026-03-27T12:34:56Z" {
|
||||
t.Fatalf("build_time = %q, want %q", got.BuildTime, "2026-03-27T12:34:56Z")
|
||||
}
|
||||
if got.GoVersion != "go1.25.8" {
|
||||
t.Fatalf("go_version = %q, want %q", got.GoVersion, "go1.25.8")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSystemVersionFallsBackToLauncherInfoWhenCommandFails(t *testing.T) {
|
||||
setupVersionTestIsolation(t)
|
||||
|
||||
expected := systemVersionResponse{
|
||||
Version: "v9.9.9",
|
||||
GitCommit: "cafebabe",
|
||||
BuildTime: "2026-03-27T10:43:34+0000",
|
||||
GoVersion: "go1.25.8",
|
||||
}
|
||||
launcherBuildInfoForVersion = func() systemVersionResponse { return expected }
|
||||
|
||||
findPicoclawBinaryForInfo = func() string { return "picoclaw" }
|
||||
runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) {
|
||||
return "", errors.New("binary unavailable")
|
||||
}
|
||||
|
||||
h := NewHandler("")
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/system/version", nil)
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
var got systemVersionResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
if got.Version != expected.Version {
|
||||
t.Fatalf("version = %q, want %q", got.Version, expected.Version)
|
||||
}
|
||||
if got.GitCommit != expected.GitCommit {
|
||||
t.Fatalf("git_commit = %q, want %q", got.GitCommit, expected.GitCommit)
|
||||
}
|
||||
if got.BuildTime != expected.BuildTime {
|
||||
t.Fatalf("build_time = %q, want %q", got.BuildTime, expected.BuildTime)
|
||||
}
|
||||
if got.GoVersion != expected.GoVersion {
|
||||
t.Fatalf("go_version = %q, want %q", got.GoVersion, expected.GoVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePicoclawVersionOutput(t *testing.T) {
|
||||
setupVersionTestIsolation(t)
|
||||
|
||||
raw := "\u001b[1;31m████\u001b[0m\n🦞 picoclaw 18ec263 (git: 18ec2631)\n Build: 2026-03-27T10:43:34+0000\n Go: go1.25.8\n"
|
||||
got, ok := parsePicoclawVersionOutput(raw)
|
||||
if !ok {
|
||||
t.Fatal("parsePicoclawVersionOutput() should parse valid output")
|
||||
}
|
||||
if got.Version != "18ec263" {
|
||||
t.Fatalf("version = %q, want %q", got.Version, "18ec263")
|
||||
}
|
||||
if got.GitCommit != "18ec2631" {
|
||||
t.Fatalf("git_commit = %q, want %q", got.GitCommit, "18ec2631")
|
||||
}
|
||||
if got.BuildTime != "2026-03-27T10:43:34+0000" {
|
||||
t.Fatalf("build_time = %q, want %q", got.BuildTime, "2026-03-27T10:43:34+0000")
|
||||
}
|
||||
if got.GoVersion != "go1.25.8" {
|
||||
t.Fatalf("go_version = %q, want %q", got.GoVersion, "go1.25.8")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePicoclawVersionOutputIgnoresUsageLine(t *testing.T) {
|
||||
setupVersionTestIsolation(t)
|
||||
|
||||
raw := "Usage: picoclaw version [flags]\n"
|
||||
got, ok := parsePicoclawVersionOutput(raw)
|
||||
if ok {
|
||||
t.Fatalf("parsePicoclawVersionOutput() parsed usage line unexpectedly: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePicoclawVersionOutputAcceptsLetterOnlyHashVersion(t *testing.T) {
|
||||
setupVersionTestIsolation(t)
|
||||
|
||||
raw := "picoclaw abcdefa (git: abcdefabcdefabcdefabcdefabcdefabcdefabcd)\n"
|
||||
got, ok := parsePicoclawVersionOutput(raw)
|
||||
if !ok {
|
||||
t.Fatal("parsePicoclawVersionOutput() should parse letter-only hash version")
|
||||
}
|
||||
if got.Version != "abcdefa" {
|
||||
t.Fatalf("version = %q, want %q", got.Version, "abcdefa")
|
||||
}
|
||||
if got.GitCommit != "abcdefabcdefabcdefabcdefabcdefabcdefabcd" {
|
||||
t.Fatalf("git_commit = %q, want %q", got.GitCommit, "abcdefabcdefabcdefabcdefabcdefabcdefabcd")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSystemVersionInfoFallsBackRuntimeGoVersion(t *testing.T) {
|
||||
setupVersionTestIsolation(t)
|
||||
|
||||
launcherBuildInfoForVersion = func() systemVersionResponse {
|
||||
return systemVersionResponse{Version: "dev", GoVersion: ""}
|
||||
}
|
||||
|
||||
findPicoclawBinaryForInfo = func() string { return "picoclaw" }
|
||||
runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) {
|
||||
return "picoclaw v1.0.0\n", nil
|
||||
}
|
||||
|
||||
h := NewHandler("")
|
||||
got := h.resolveSystemVersionInfo(context.Background())
|
||||
if got.GoVersion != runtime.Version() {
|
||||
t.Fatalf("go_version = %q, want runtime version %q", got.GoVersion, runtime.Version())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSystemVersionInfoCachesWhileGatewayAlive(t *testing.T) {
|
||||
setupVersionTestIsolation(t)
|
||||
|
||||
launcherBuildInfoForVersion = func() systemVersionResponse {
|
||||
return systemVersionResponse{Version: "dev", GoVersion: "go-fallback"}
|
||||
}
|
||||
findPicoclawBinaryForInfo = func() string { return "picoclaw" }
|
||||
|
||||
pid := 4321
|
||||
currentGatewayVersionState = func() (int, bool) { return pid, true }
|
||||
|
||||
runCount := 0
|
||||
runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) {
|
||||
runCount++
|
||||
return fmt.Sprintf("picoclaw v1.2.%d\n", runCount), nil
|
||||
}
|
||||
|
||||
h := NewHandler("")
|
||||
first := h.resolveSystemVersionInfo(context.Background())
|
||||
second := h.resolveSystemVersionInfo(context.Background())
|
||||
|
||||
if first.Version != "v1.2.1" {
|
||||
t.Fatalf("first version = %q, want %q", first.Version, "v1.2.1")
|
||||
}
|
||||
if second.Version != "v1.2.1" {
|
||||
t.Fatalf("second version = %q, want cached %q", second.Version, "v1.2.1")
|
||||
}
|
||||
if runCount != 1 {
|
||||
t.Fatalf("run count = %d, want %d", runCount, 1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSystemVersionInfoInvalidatesCacheWhenGatewayStops(t *testing.T) {
|
||||
setupVersionTestIsolation(t)
|
||||
|
||||
launcherBuildInfoForVersion = func() systemVersionResponse {
|
||||
return systemVersionResponse{Version: "dev", GoVersion: "go-fallback"}
|
||||
}
|
||||
findPicoclawBinaryForInfo = func() string { return "picoclaw" }
|
||||
|
||||
alive := true
|
||||
pid := 9876
|
||||
currentGatewayVersionState = func() (int, bool) {
|
||||
if !alive {
|
||||
return 0, false
|
||||
}
|
||||
return pid, true
|
||||
}
|
||||
|
||||
runCount := 0
|
||||
runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) {
|
||||
runCount++
|
||||
return fmt.Sprintf("picoclaw v2.0.%d\n", runCount), nil
|
||||
}
|
||||
|
||||
h := NewHandler("")
|
||||
first := h.resolveSystemVersionInfo(context.Background())
|
||||
second := h.resolveSystemVersionInfo(context.Background())
|
||||
|
||||
if first.Version != "v2.0.1" || second.Version != "v2.0.1" {
|
||||
t.Fatalf("expected cached version v2.0.1, got first=%q second=%q", first.Version, second.Version)
|
||||
}
|
||||
if runCount != 1 {
|
||||
t.Fatalf("run count after cache hit = %d, want %d", runCount, 1)
|
||||
}
|
||||
|
||||
alive = false
|
||||
third := h.resolveSystemVersionInfo(context.Background())
|
||||
if third.Version != "v2.0.2" {
|
||||
t.Fatalf("third version = %q, want refreshed %q", third.Version, "v2.0.2")
|
||||
}
|
||||
if runCount != 2 {
|
||||
t.Fatalf("run count after invalidation = %d, want %d", runCount, 2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSystemVersionInfoSkipsCommandWhenContextCanceled(t *testing.T) {
|
||||
setupVersionTestIsolation(t)
|
||||
|
||||
launcherBuildInfoForVersion = func() systemVersionResponse {
|
||||
return systemVersionResponse{Version: "v3.0.0", GoVersion: "go-fallback"}
|
||||
}
|
||||
findPicoclawBinaryForInfo = func() string { return "picoclaw" }
|
||||
|
||||
runCount := 0
|
||||
runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) {
|
||||
runCount++
|
||||
return "picoclaw v9.9.9\n", nil
|
||||
}
|
||||
|
||||
canceledCtx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
h := NewHandler("")
|
||||
got := h.resolveSystemVersionInfo(canceledCtx)
|
||||
|
||||
if runCount != 0 {
|
||||
t.Fatalf("run count = %d, want %d", runCount, 0)
|
||||
}
|
||||
if got.Version != "v3.0.0" {
|
||||
t.Fatalf("version = %q, want fallback %q", got.Version, "v3.0.0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveGatewayBinaryForVersionInfoPrefersGatewayCommandPath(t *testing.T) {
|
||||
setupVersionTestIsolation(t)
|
||||
|
||||
originalFinder := findPicoclawBinaryForInfo
|
||||
t.Cleanup(func() {
|
||||
findPicoclawBinaryForInfo = originalFinder
|
||||
})
|
||||
|
||||
gateway.mu.Lock()
|
||||
originalCmd := gateway.cmd
|
||||
gateway.cmd = &exec.Cmd{Path: "/tmp/picoclaw-from-gateway"}
|
||||
gateway.mu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
gateway.mu.Lock()
|
||||
gateway.cmd = originalCmd
|
||||
gateway.mu.Unlock()
|
||||
})
|
||||
|
||||
got := resolveGatewayBinaryForVersionInfo()
|
||||
if got != "/tmp/picoclaw-from-gateway" {
|
||||
t.Fatalf("exec path = %q, want %q", got, "/tmp/picoclaw-from-gateway")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user