Files
wehub-resource-sync f99010fae1
Desktop Artifacts / Desktop Build (Linux) (push) Waiting to run
Desktop Artifacts / Desktop Build (Windows) (push) Waiting to run
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Waiting to run
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

2201 lines
66 KiB
Go

package main
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.kenn.io/agentsview/internal/config"
"go.kenn.io/agentsview/internal/db"
"go.kenn.io/agentsview/internal/dbtest"
"go.kenn.io/agentsview/internal/parser"
"go.kenn.io/agentsview/internal/remotesync"
agentsync "go.kenn.io/agentsview/internal/sync"
"go.kenn.io/agentsview/internal/testjsonl"
"go.kenn.io/kit/daemon"
)
type fakeCLIPreparedHTTPRebuild struct {
contributors []agentsync.RebuildContributor
closed int
released bool
closeReleased bool
}
type cliLifecycleError struct{ err error }
func (e *cliLifecycleError) Error() string { return "lifecycle: " + e.err.Error() }
func (e *cliLifecycleError) Unwrap() error { return e.err }
func (p *fakeCLIPreparedHTTPRebuild) BorrowRebuildContributors() (
[]agentsync.RebuildContributor, func(), error,
) {
return p.contributors, func() { p.released = true }, nil
}
func (p *fakeCLIPreparedHTTPRebuild) Close() error {
p.closed++
p.closeReleased = p.released
return nil
}
func newDirectSyncFixture(t *testing.T) (config.Config, *db.DB) {
t.Helper()
dataDir := t.TempDir()
localRoot := filepath.Join(dataDir, "local-claude")
require.NoError(t, os.MkdirAll(filepath.Join(localRoot, "project"), 0o755))
require.NoError(t, os.WriteFile(
filepath.Join(localRoot, "project", "session.jsonl"),
[]byte(testjsonl.NewSessionBuilder().
AddClaudeUser("2026-07-12T00:00:00Z", "local direct sync").
String()),
0o600,
))
cfg := config.Config{
DataDir: dataDir,
DBPath: filepath.Join(dataDir, "sessions.db"),
LocalMachineName: "collector-host",
AgentDirs: map[parser.AgentType][]string{
parser.AgentClaude: {localRoot},
},
}
database, err := db.Open(cfg.DBPath)
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, database.Close()) })
return cfg, database
}
func isolateDirectCLISources(t *testing.T) {
t.Helper()
root := t.TempDir()
t.Setenv("HOME", root)
for _, def := range parser.Registry {
if def.EnvVar != "" {
t.Setenv(def.EnvVar, filepath.Join(root, string(def.Type)))
}
if def.DefaultRootEnvVar != "" {
t.Setenv(def.DefaultRootEnvVar, root)
}
}
}
func TestDoSyncConfiguredFullUsesUnifiedHTTPContributorBeforeSSH(t *testing.T) {
cfg, database := newDirectSyncFixture(t)
httpHost := config.RemoteHost{
Host: "http-box", Transport: config.RemoteTransportHTTP,
URL: "http://127.0.0.1:1", Token: "token",
}
sshHost := config.RemoteHost{Host: "ssh-box"}
var order []string
prepared := &fakeCLIPreparedHTTPRebuild{contributors: []agentsync.RebuildContributor{{
Name: "http-box",
AfterSync: func(*agentsync.Engine, *db.DB) error {
order = append(order, "http contributor")
return nil
},
}}}
originalPrepare := prepareHTTPRebuildCLI
prepareHTTPRebuildCLI = func(
context.Context, []remotesync.HTTPSync,
) (preparedHTTPRebuildCLI, error) {
order = append(order, "prepare")
return prepared, nil
}
t.Cleanup(func() { prepareHTTPRebuildCLI = originalPrepare })
originalSSH := runSSHRemoteSync
runSSHRemoteSync = func(
_ context.Context, _ config.Config, _ *db.DB,
rh config.RemoteHost, full bool,
) (remotesync.SyncStats, error) {
order = append(order, "ssh")
assert.Equal(t, sshHost, rh)
assert.True(t, full)
return remotesync.SyncStats{}, nil
}
t.Cleanup(func() { runSSHRemoteSync = originalSSH })
didResync, failures, err := runConfiguredLocalAndRemotes(
context.Background(), cfg, database,
[]config.RemoteHost{sshHost, httpHost}, true, nil,
)
require.NoError(t, err)
assert.True(t, didResync)
assert.Empty(t, failures)
assert.Equal(t, []string{"prepare", "http contributor", "ssh"}, order)
assert.Equal(t, 1, prepared.closed)
assert.True(t, prepared.closeReleased,
"contributor borrow must release before prepared sources close")
}
func TestDoSyncConfiguredFullIgnoresSSHHistoryDuringUnifiedSafetyCheck(t *testing.T) {
cfg, database := newDirectSyncFixture(t)
for _, roots := range cfg.AgentDirs {
for _, root := range roots {
require.NoError(t, os.RemoveAll(root))
}
}
missingPath := filepath.Join(t.TempDir(), "missing-ssh-session.jsonl")
require.NoError(t, database.UpsertSession(db.Session{
ID: "preserved-ssh-session", Project: "archive", Machine: "ssh-box",
Agent: "claude", FilePath: &missingPath, MessageCount: 1,
}))
httpRoot := t.TempDir()
prepared := &fakeCLIPreparedHTTPRebuild{contributors: []agentsync.RebuildContributor{{
Name: "http-box",
Config: agentsync.EngineConfig{
AgentDirs: map[parser.AgentType][]string{parser.AgentClaude: {httpRoot}},
Machine: "http-box", IDPrefix: "http-box~", Ephemeral: true,
},
}}}
originalPrepare := prepareHTTPRebuildCLI
prepareHTTPRebuildCLI = func(
context.Context, []remotesync.HTTPSync,
) (preparedHTTPRebuildCLI, error) {
return prepared, nil
}
t.Cleanup(func() { prepareHTTPRebuildCLI = originalPrepare })
sshCalls := 0
originalSSH := runSSHRemoteSync
runSSHRemoteSync = func(
_ context.Context, _ config.Config, database *db.DB,
rh config.RemoteHost, full bool,
) (remotesync.SyncStats, error) {
sshCalls++
assert.Equal(t, "ssh-box", rh.Host)
assert.True(t, full)
preserved, err := database.GetSession(
context.Background(), "preserved-ssh-session",
)
require.NoError(t, err)
assert.NotNil(t, preserved,
"SSH pass must observe its archived session after the unified swap")
return remotesync.SyncStats{}, nil
}
t.Cleanup(func() { runSSHRemoteSync = originalSSH })
didResync, failures, err := runConfiguredLocalAndRemotes(
context.Background(), cfg, database,
[]config.RemoteHost{
{Host: "http-box", Transport: config.RemoteTransportHTTP, Token: "token"},
{Host: "ssh-box"},
}, true, nil,
)
require.NoError(t, err)
assert.True(t, didResync)
assert.Empty(t, failures)
assert.Equal(t, 1, sshCalls,
"SSH synchronization must run after an empty local/HTTP rebuild")
}
func TestDoSyncConfiguredFullSSHOnlyFallsBackBeforeRemoteImport(t *testing.T) {
cfg, database := newDirectSyncFixture(t)
for _, roots := range cfg.AgentDirs {
for _, root := range roots {
require.NoError(t, os.RemoveAll(root))
}
}
missingPath := filepath.Join(t.TempDir(), "missing-remote.jsonl")
require.NoError(t, database.UpsertSession(db.Session{
ID: "preserved-ssh-session", Project: "archive", Machine: "ssh-box",
Agent: "claude", FilePath: &missingPath, MessageCount: 1,
}))
sshHost := config.RemoteHost{Host: "ssh-box"}
sshCalls := 0
originalSSH := runSSHRemoteSync
runSSHRemoteSync = func(
_ context.Context, _ config.Config, _ *db.DB,
rh config.RemoteHost, full bool,
) (remotesync.SyncStats, error) {
sshCalls++
assert.Equal(t, sshHost, rh)
assert.True(t, full)
return remotesync.SyncStats{}, nil
}
t.Cleanup(func() { runSSHRemoteSync = originalSSH })
didResync, failures, err := runConfiguredLocalAndRemotes(
context.Background(), cfg, database,
[]config.RemoteHost{sshHost}, true, nil,
)
require.NoError(t, err)
assert.True(t, didResync)
assert.Empty(t, failures)
assert.Equal(t, 1, sshCalls,
"SSH import must run after the legacy local fallback")
preserved, err := database.GetSession(context.Background(), "preserved-ssh-session")
require.NoError(t, err)
assert.NotNil(t, preserved)
}
func TestDoSyncAutomaticResyncUsesUnifiedHTTPContributor(t *testing.T) {
cfg, database := newDirectSyncFixture(t)
require.NoError(t, database.Close())
raw, err := sql.Open("sqlite3", cfg.DBPath)
require.NoError(t, err)
_, err = raw.Exec("PRAGMA user_version = 0")
require.NoError(t, err)
require.NoError(t, raw.Close())
database, err = db.Open(cfg.DBPath)
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, database.Close()) })
require.True(t, database.NeedsResync())
prepared := &fakeCLIPreparedHTTPRebuild{}
prepareCalls := 0
originalPrepare := prepareHTTPRebuildCLI
prepareHTTPRebuildCLI = func(
context.Context, []remotesync.HTTPSync,
) (preparedHTTPRebuildCLI, error) {
prepareCalls++
return prepared, nil
}
t.Cleanup(func() { prepareHTTPRebuildCLI = originalPrepare })
didResync, failures, err := runConfiguredLocalAndRemotes(
context.Background(), cfg, database,
[]config.RemoteHost{{
Host: "http-box", Transport: config.RemoteTransportHTTP,
URL: "http://127.0.0.1:1", Token: "token",
}}, false, nil,
)
require.NoError(t, err)
assert.True(t, didResync)
assert.Empty(t, failures)
assert.Equal(t, 1, prepareCalls)
assert.False(t, database.NeedsResync())
}
func TestDoSyncPreparationFailureMapsRemoteAndSkipsSSH(t *testing.T) {
cfg, database := newDirectSyncFixture(t)
require.NoError(t, database.UpsertSession(db.Session{
ID: "preserved", Project: "archive", Machine: "local", Agent: "codex",
}))
prepCause := errors.New("manifest unavailable")
prepared := &fakeCLIPreparedHTTPRebuild{}
originalPrepare := prepareHTTPRebuildCLI
prepareHTTPRebuildCLI = func(
context.Context, []remotesync.HTTPSync,
) (preparedHTTPRebuildCLI, error) {
return prepared, &remotesync.HostError{
Host: "http-box", Operation: "prepare", Err: prepCause,
}
}
t.Cleanup(func() { prepareHTTPRebuildCLI = originalPrepare })
sshCalls := 0
originalSSH := runSSHRemoteSync
runSSHRemoteSync = func(
context.Context, config.Config, *db.DB, config.RemoteHost, bool,
) (remotesync.SyncStats, error) {
sshCalls++
return remotesync.SyncStats{}, nil
}
t.Cleanup(func() { runSSHRemoteSync = originalSSH })
didResync, failures, err := runConfiguredLocalAndRemotes(
context.Background(), cfg, database, []config.RemoteHost{
{Host: "http-box", Transport: config.RemoteTransportHTTP,
URL: "http://127.0.0.1:1", Token: "token"},
{Host: "ssh-box"},
}, true, nil,
)
require.NoError(t, err)
assert.True(t, didResync)
require.Len(t, failures, 1)
assert.Equal(t, "http-box", failures[0].Host.Host)
assert.ErrorIs(t, failures[0].Err, prepCause)
assert.Equal(t, 0, sshCalls)
assert.Equal(t, 1, prepared.closed,
"partial preparation ownership must be closed on failure")
preserved, getErr := database.GetSession(context.Background(), "preserved")
require.NoError(t, getErr)
assert.NotNil(t, preserved, "failed preparation must not swap")
}
func TestDoSyncContributorFailureMapsRemotePreservesCauseAndSkipsSSH(t *testing.T) {
cfg, database := newDirectSyncFixture(t)
require.NoError(t, database.UpsertSession(db.Session{
ID: "preserved", Project: "archive", Machine: "local", Agent: "codex",
}))
cause := errors.New("cache snapshot failed")
prepared := &fakeCLIPreparedHTTPRebuild{contributors: []agentsync.RebuildContributor{{
Name: "http-box",
AfterSync: func(*agentsync.Engine, *db.DB) error { return cause },
}}}
originalPrepare := prepareHTTPRebuildCLI
prepareHTTPRebuildCLI = func(
context.Context, []remotesync.HTTPSync,
) (preparedHTTPRebuildCLI, error) {
return prepared, nil
}
t.Cleanup(func() { prepareHTTPRebuildCLI = originalPrepare })
sshCalls := 0
originalSSH := runSSHRemoteSync
runSSHRemoteSync = func(
context.Context, config.Config, *db.DB, config.RemoteHost, bool,
) (remotesync.SyncStats, error) {
sshCalls++
return remotesync.SyncStats{}, nil
}
t.Cleanup(func() { runSSHRemoteSync = originalSSH })
didResync, failures, err := runConfiguredLocalAndRemotes(
context.Background(), cfg, database, []config.RemoteHost{
{Host: "http-box", Transport: config.RemoteTransportHTTP,
URL: "http://127.0.0.1:1", Token: "token"},
{Host: "ssh-box"},
}, true, nil,
)
require.NoError(t, err)
assert.True(t, didResync)
require.Len(t, failures, 1)
assert.Equal(t, "http-box", failures[0].Host.Host)
assert.ErrorIs(t, failures[0].Err, cause)
assert.Equal(t, 0, sshCalls)
preserved, getErr := database.GetSession(context.Background(), "preserved")
require.NoError(t, getErr)
assert.NotNil(t, preserved, "failed contributor must not swap")
}
func TestDoSyncUnknownCoordinatorFailureRemainsLocalError(t *testing.T) {
cfg, database := newDirectSyncFixture(t)
cause := errors.New("temporary database unavailable")
originalPrepare := prepareHTTPRebuildCLI
prepareHTTPRebuildCLI = func(
context.Context, []remotesync.HTTPSync,
) (preparedHTTPRebuildCLI, error) {
return nil, cause
}
t.Cleanup(func() { prepareHTTPRebuildCLI = originalPrepare })
_, failures, err := runConfiguredLocalAndRemotes(
context.Background(), cfg, database, []config.RemoteHost{{
Host: "http-box", Transport: config.RemoteTransportHTTP,
URL: "http://127.0.0.1:1", Token: "token",
}}, true, nil,
)
assert.Empty(t, failures)
assert.ErrorIs(t, err, cause)
}
func TestConfiguredHTTPCoordinatorFailureUsesPrimaryOperationOnly(t *testing.T) {
hosts := []config.RemoteHost{
{Host: "alpha", Transport: config.RemoteTransportHTTP},
{Host: "cleanup", Transport: config.RemoteTransportHTTP},
}
localCause := errors.New("local database failed")
prepCause := errors.New("alpha manifest failed")
contributorCause := errors.New("alpha contributor failed")
cleanupCause := errors.New("cleanup failed")
cleanupHost := &remotesync.HostError{
Host: "cleanup", Operation: "cleanup", Err: cleanupCause,
}
tests := []struct {
name string
err error
wantHost string
wantIs error
wantMap bool
}{
{
name: "pending cleanup wrapping prior host stays coordinator error",
err: &remotesync.PendingCleanupError{Err: &remotesync.HostError{
Host: "alpha", Operation: "prepare", Err: prepCause,
}},
wantIs: prepCause,
},
{
name: "secondary cleanup host cannot steal local failure",
err: errors.Join(localCause, cleanupHost),
wantIs: localCause,
wantMap: false,
},
{
name: "primary preparation host wins over cleanup host",
err: &cliLifecycleError{err: errors.Join(
&remotesync.HostError{
Host: "alpha", Operation: "prepare", Err: prepCause,
},
cleanupHost,
)},
wantHost: "alpha",
wantIs: prepCause,
wantMap: true,
},
{
name: "primary contributor wins over cleanup host",
err: errors.Join(&agentsync.RebuildContributorError{
Contributor: "alpha", Err: contributorCause,
}, cleanupHost),
wantHost: "alpha",
wantIs: contributorCause,
wantMap: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
failure, ok := configuredHTTPCoordinatorFailure(hosts, tt.err)
assert.Equal(t, tt.wantMap, ok)
assert.ErrorIs(t, tt.err, tt.wantIs)
if tt.wantMap {
assert.Equal(t, tt.wantHost, failure.Host.Host)
assert.ErrorIs(t, failure.Err, tt.wantIs)
}
})
}
}
func TestRunConfiguredLocalAndRemotesKeepsPendingCleanupBlocked(t *testing.T) {
cfg, database := newDirectSyncFixture(t)
pendingCause := errors.New("prior alpha cleanup")
pending := &remotesync.PendingCleanupError{Err: &remotesync.HostError{
Host: "http-box", Operation: "cleanup", Err: pendingCause,
}}
originalRun := runLocalSyncWithRebuildCLI
runLocalSyncWithRebuildCLI = func(
context.Context, config.Config, *db.DB, bool, agentsync.ProgressFunc,
func() (agentsync.RebuildOptions, agentsync.RebuildCleanup, error),
func(bool, bool) error,
) (bool, error) {
return true, pending
}
t.Cleanup(func() { runLocalSyncWithRebuildCLI = originalRun })
_, failures, err := runConfiguredLocalAndRemotes(
context.Background(), cfg, database, []config.RemoteHost{{
Host: "http-box", Transport: config.RemoteTransportHTTP,
URL: "http://127.0.0.1:1", Token: "token",
}}, true, nil,
)
assert.Empty(t, failures)
var gotPending *remotesync.PendingCleanupError
require.ErrorAs(t, err, &gotPending)
assert.ErrorIs(t, err, pendingCause)
}
func TestRunLocalSyncWithRebuildReturnsAbortedSentinelWithoutSummary(t *testing.T) {
dataDir := t.TempDir()
cfg := config.Config{DataDir: dataDir, DBPath: filepath.Join(dataDir, "sessions.db")}
database, err := db.Open(cfg.DBPath)
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, database.Close()) })
missingPath := filepath.Join(dataDir, "missing.jsonl")
require.NoError(t, database.UpsertSession(db.Session{
ID: "old-file-session", Agent: "claude", Machine: "local",
Project: "preserved", FilePath: &missingPath, MessageCount: 1,
}))
workCalls := 0
var runErr error
out := captureStdout(t, func() {
_, runErr = runLocalSyncWithRebuild(
context.Background(), cfg, database, true, nil,
func() (agentsync.RebuildOptions, agentsync.RebuildCleanup, error) {
return agentsync.RebuildOptions{Contributors: []agentsync.RebuildContributor{{
Name: "http-box",
}}}, nil, nil
},
func(bool, bool) error {
workCalls++
return nil
},
)
})
assert.ErrorIs(t, runErr, errUnifiedRebuildAborted)
assert.Equal(t, 0, workCalls)
assert.NotContains(t, out, "Sync complete")
preserved, getErr := database.GetSession(context.Background(), "old-file-session")
require.NoError(t, getErr)
assert.NotNil(t, preserved)
}
func TestRunLocalSyncWithRebuildCancellationPreservesContextError(t *testing.T) {
cfg, database := newDirectSyncFixture(t)
ctx, cancel := context.WithCancel(context.Background())
cancel()
var runErr error
out := captureStdout(t, func() {
_, runErr = runLocalSyncWithRebuild(
ctx, cfg, database, true, nil,
func() (agentsync.RebuildOptions, agentsync.RebuildCleanup, error) {
return agentsync.RebuildOptions{Contributors: []agentsync.RebuildContributor{{
Name: "http-box",
}}}, nil, nil
},
func(bool, bool) error { return nil },
)
})
assert.ErrorIs(t, runErr, context.Canceled)
assert.NotErrorIs(t, runErr, errUnifiedRebuildAborted)
assert.NotContains(t, out, "Sync complete")
}
func TestRunLocalSyncZeroContributorRetainsAbortFallback(t *testing.T) {
dataDir := t.TempDir()
cfg := config.Config{DataDir: dataDir, DBPath: filepath.Join(dataDir, "sessions.db")}
database, err := db.Open(cfg.DBPath)
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, database.Close()) })
missingPath := filepath.Join(dataDir, "missing.jsonl")
require.NoError(t, database.UpsertSession(db.Session{
ID: "old-file-session", Agent: "claude", Machine: "local",
Project: "preserved", FilePath: &missingPath, MessageCount: 1,
}))
didResync := runLocalSync(context.Background(), cfg, database, true)
assert.True(t, didResync)
preserved, getErr := database.GetSession(context.Background(), "old-file-session")
require.NoError(t, getErr)
assert.NotNil(t, preserved,
"legacy zero-contributor fallback must keep the active archive")
}
func TestDoSyncConfiguredFullUnifiedHTTPUsesManifestDeltaAndOrderedProgress(
t *testing.T,
) {
cfg, database := newDirectSyncFixture(t)
remoteRoot := filepath.Join(t.TempDir(), "remote-claude")
require.NoError(t, os.MkdirAll(filepath.Join(remoteRoot, "project"), 0o755))
require.NoError(t, os.WriteFile(
filepath.Join(remoteRoot, "project", "remote.jsonl"),
[]byte(testjsonl.NewSessionBuilder().
AddClaudeUser("2026-07-12T01:00:00Z", "remote direct sync").
String()),
0o600,
))
targets := remotesync.TargetSet{Dirs: map[parser.AgentType][]string{
parser.AgentClaude: {remoteRoot},
}}
var archiveRequests atomic.Int32
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/remote-sync/targets":
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(targets); err != nil {
http.Error(w, "encode targets", http.StatusInternalServerError)
}
case "/api/v1/remote-sync/manifest":
var requested remotesync.TargetSet
if err := json.NewDecoder(r.Body).Decode(&requested); err != nil {
http.Error(w, "decode manifest request", http.StatusBadRequest)
return
}
manifest, err := remotesync.BuildManifest(requested)
if err != nil {
http.Error(w, "build manifest", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(manifest); err != nil {
http.Error(w, "encode manifest", http.StatusInternalServerError)
}
case "/api/v1/remote-sync/archive":
archiveRequests.Add(1)
var requested remotesync.ArchiveRequest
if err := json.NewDecoder(r.Body).Decode(&requested); err != nil {
http.Error(w, "decode archive request", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/x-tar")
var err error
if requested.DeltaFiles != nil {
err = remotesync.WriteArchiveFiles(
w, targets.DeltaAllowedRoots(), requested.DeltaFiles,
)
} else {
err = remotesync.WriteArchive(w, requested.TargetSet)
}
if err != nil {
http.Error(w, "write archive", http.StatusInternalServerError)
}
default:
http.NotFound(w, r)
}
}))
t.Cleanup(remote.Close)
host := config.RemoteHost{
Host: "http-box", Transport: config.RemoteTransportHTTP,
URL: remote.URL, Token: "remote-token",
}
now := time.Date(2026, 7, 12, 1, 0, 0, 0, time.UTC)
printer := newRemoteProgressPrinter(&bytes.Buffer{}, func() time.Time {
now = now.Add(time.Millisecond)
return now
})
var output bytes.Buffer
printer.w = &output
didResync, failures, err := runConfiguredLocalAndRemotes(
context.Background(), cfg, database, []config.RemoteHost{host},
true, printer.Print,
)
printer.Finish()
require.NoError(t, err)
assert.True(t, didResync)
assert.Empty(t, failures)
assert.Equal(t, int32(1), archiveRequests.Load())
page, err := database.ListSessions(context.Background(), db.SessionFilter{Limit: 10})
require.NoError(t, err)
assert.Len(t, page.Sessions, 2)
progressOutput := output.String()
labels := []string{
"Downloading session archive from http-box",
"Extracting session archive from http-box",
"Processing sessions from http-box",
"Rebuilding search index",
"Swapping rebuilt database into place",
}
previous := -1
for _, label := range labels {
position := strings.Index(progressOutput, label)
require.Greater(t, position, previous, "progress output: %s", progressOutput)
previous = position
}
assert.Contains(t, progressOutput,
"Swapping rebuilt database into place completed in")
_, failures, err = runConfiguredLocalAndRemotes(
context.Background(), cfg, database, []config.RemoteHost{host},
true, nil,
)
require.NoError(t, err)
assert.Empty(t, failures)
assert.Equal(t, int32(1), archiveRequests.Load(),
"unchanged full rebuild must not request a second archive")
}
func TestDoSyncIncrementalKeepsOrdinaryRemotePath(t *testing.T) {
cfg, database := newDirectSyncFixture(t)
host := config.RemoteHost{
Host: "http-box", Transport: config.RemoteTransportHTTP,
URL: "http://127.0.0.1:1", Token: "token",
}
prepareCalls := 0
originalPrepare := prepareHTTPRebuildCLI
prepareHTTPRebuildCLI = func(
context.Context, []remotesync.HTTPSync,
) (preparedHTTPRebuildCLI, error) {
prepareCalls++
return &fakeCLIPreparedHTTPRebuild{}, nil
}
t.Cleanup(func() { prepareHTTPRebuildCLI = originalPrepare })
activeCalls := 0
restore := stubHTTPRemoteSyncForTest(t, func(
_ context.Context, got config.RemoteHost, full bool,
) (remotesync.SyncStats, error) {
activeCalls++
assert.Equal(t, host, got)
assert.False(t, full)
return remotesync.SyncStats{}, nil
})
t.Cleanup(restore)
didResync, failures, err := runConfiguredLocalAndRemotes(
context.Background(), cfg, database, []config.RemoteHost{host}, false, nil,
)
require.NoError(t, err)
assert.False(t, didResync)
assert.Empty(t, failures)
assert.Equal(t, 0, prepareCalls)
assert.Equal(t, 1, activeCalls)
}
func TestDoSyncPreparationFailureReturnsRemoteFailureOutcome(t *testing.T) {
env := newSyncCLIEnv(t)
t.Setenv("AGENTSVIEW_NO_DAEMON", "1")
require.NoError(t, os.WriteFile(
filepath.Join(env.DataDir, "config.toml"),
[]byte(`[[remote_hosts]]
host = "http-box"
transport = "http"
url = "http://127.0.0.1:1"
token = "remote-token"
`),
0o600,
))
originalPrepare := prepareHTTPRebuildCLI
prepareHTTPRebuildCLI = func(
context.Context, []remotesync.HTTPSync,
) (preparedHTTPRebuildCLI, error) {
return nil, &remotesync.HostError{
Host: "http-box", Operation: "prepare", Err: errors.New("offline"),
}
}
t.Cleanup(func() { prepareHTTPRebuildCLI = originalPrepare })
hadRemoteFailures := doSync(SyncConfig{Full: true})
assert.True(t, hadRemoteFailures)
}
func TestDoSyncContributorFailureReturnsRemoteFailureOutcome(t *testing.T) {
env := newSyncCLIEnv(t)
t.Setenv("AGENTSVIEW_NO_DAEMON", "1")
isolateDirectCLISources(t)
require.NoError(t, os.WriteFile(
filepath.Join(env.DataDir, "config.toml"),
[]byte(`[[remote_hosts]]
host = "http-box"
transport = "http"
url = "http://127.0.0.1:1"
token = "remote-token"
`),
0o600,
))
cause := errors.New("persist remote cache")
prepared := &fakeCLIPreparedHTTPRebuild{contributors: []agentsync.RebuildContributor{{
Name: "http-box",
AfterSync: func(*agentsync.Engine, *db.DB) error { return cause },
}}}
originalPrepare := prepareHTTPRebuildCLI
prepareHTTPRebuildCLI = func(
context.Context, []remotesync.HTTPSync,
) (preparedHTTPRebuildCLI, error) {
return prepared, nil
}
t.Cleanup(func() { prepareHTTPRebuildCLI = originalPrepare })
originalRegistry := httpRemoteCleanupRegistry
httpRemoteCleanupRegistry = new(remotesync.CleanupRegistry)
t.Cleanup(func() { httpRemoteCleanupRegistry = originalRegistry })
hadRemoteFailures := doSync(SyncConfig{Full: true})
assert.True(t, hadRemoteFailures)
assert.Equal(t, 1, prepared.closed)
assert.True(t, prepared.closeReleased)
}
func TestDoSyncAbortedUnifiedRebuildExitsNonZeroWithoutSuccessSummary(
t *testing.T,
) {
dataDir := t.TempDir()
cmd := exec.Command(
os.Args[0],
"-test.run=^TestDoSyncAbortedUnifiedRebuildHelperProcess$",
)
cmd.Env = append(os.Environ(),
"AGENTSVIEW_ABORTED_UNIFIED_HELPER=1",
"AGENTSVIEW_NO_DAEMON=1",
"AGENTSVIEW_DATA_DIR="+dataDir,
)
out, err := cmd.CombinedOutput()
var exitErr *exec.ExitError
require.ErrorAs(t, err, &exitErr)
assert.Equal(t, 1, exitErr.ExitCode())
assert.Contains(t, string(out), "fatal: local sync: "+errUnifiedRebuildAborted.Error())
assert.NotContains(t, string(out), "Sync complete")
}
func TestDoSyncAbortedUnifiedRebuildHelperProcess(t *testing.T) {
if os.Getenv("AGENTSVIEW_ABORTED_UNIFIED_HELPER") != "1" {
return
}
dataDir := os.Getenv("AGENTSVIEW_DATA_DIR")
require.NotEmpty(t, dataDir)
require.NoError(t, os.WriteFile(
filepath.Join(dataDir, "config.toml"),
[]byte(`[[remote_hosts]]
host = "http-box"
transport = "http"
url = "http://127.0.0.1:1"
token = "remote-token"
`),
0o600,
))
runConfiguredLocalAndRemotesCLI = func(
context.Context, config.Config, *db.DB, []config.RemoteHost,
bool, agentsync.ProgressFunc,
) (bool, []remoteHostFailure, error) {
return true, nil, errUnifiedRebuildAborted
}
doSync(SyncConfig{Full: true})
os.Exit(0)
}
func TestDoSyncSingleHostFullStaysOnActiveArchivePath(t *testing.T) {
newSyncCLIEnv(t)
t.Setenv("AGENTSVIEW_NO_DAEMON", "1")
prepareCalls := 0
originalPrepare := prepareHTTPRebuildCLI
prepareHTTPRebuildCLI = func(
context.Context, []remotesync.HTTPSync,
) (preparedHTTPRebuildCLI, error) {
prepareCalls++
return nil, nil
}
t.Cleanup(func() { prepareHTTPRebuildCLI = originalPrepare })
sshCalls := 0
originalSSH := runSSHRemoteSync
runSSHRemoteSync = func(
_ context.Context, _ config.Config, _ *db.DB,
rh config.RemoteHost, full bool,
) (remotesync.SyncStats, error) {
sshCalls++
assert.Equal(t, "one-box", rh.Host)
assert.True(t, full)
return remotesync.SyncStats{}, nil
}
t.Cleanup(func() { runSSHRemoteSync = originalSSH })
hadRemoteFailures := doSync(SyncConfig{Host: "one-box", Full: true})
assert.False(t, hadRemoteFailures)
assert.Equal(t, 0, prepareCalls)
assert.Equal(t, 1, sshCalls)
}
func TestRunRemoteHosts_AttemptsAllAndCollectsFailures(t *testing.T) {
hosts := []config.RemoteHost{
{Host: "alpha"},
{Host: "beta", User: "u", Port: 2222},
{Host: "gamma"},
}
failBeta := errors.New("ssh down")
var attempted []config.RemoteHost
failures, blocked := runRemoteHosts(hosts, true, func(rh config.RemoteHost, full bool) error {
attempted = append(attempted, rh)
assert.True(t, full, "full flag should propagate to syncFn")
if rh.Host == "beta" {
return failBeta
}
return nil
})
require.NoError(t, blocked)
// Every host attempted, in declared order, even after a failure.
require.Equal(t, hosts, attempted)
// Only beta failed; its full RemoteHost (user/port) is preserved.
require.Len(t, failures, 1)
assert.Equal(t, hosts[1], failures[0].Host)
assert.Equal(t, failBeta, failures[0].Err)
}
func TestRunRemoteHosts_AllSucceedReturnsEmpty(t *testing.T) {
hosts := []config.RemoteHost{{Host: "alpha"}, {Host: "beta"}}
failures, blocked := runRemoteHosts(hosts, false, func(config.RemoteHost, bool) error {
return nil
})
require.NoError(t, blocked)
assert.Empty(t, failures)
}
func TestRunRemoteSyncOnceDispatchesHTTP(t *testing.T) {
var called config.RemoteHost
restore := stubHTTPRemoteSyncForTest(t, func(
_ context.Context,
rh config.RemoteHost,
full bool,
) (remotesync.SyncStats, error) {
called = rh
assert.True(t, full)
return remotesync.SyncStats{SessionsSynced: 2}, nil
})
defer restore()
err := runRemoteSyncOnce(config.Config{}, nil, config.RemoteHost{
Host: "devbox", Transport: config.RemoteTransportHTTP,
URL: "http://devbox:8080", Token: "remote-token",
}, true)
require.NoError(t, err)
assert.Equal(t, "http://devbox:8080", called.URL)
}
func TestRunHTTPRemoteSyncRequiresExplicitHTTPToken(t *testing.T) {
called := false
ts := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
called = true
}))
t.Cleanup(ts.Close)
_, err := runHTTPRemoteSync(
context.Background(),
config.Config{AuthToken: "collector-token"},
nil,
config.RemoteHost{
Host: "devbox",
Transport: config.RemoteTransportHTTP,
URL: ts.URL,
},
false,
)
require.Error(t, err)
assert.Contains(t, err.Error(), "token is required")
assert.False(t, called, "collector auth_token must not be sent to remote")
}
func stubHTTPRemoteSyncForTest(
t *testing.T,
fn func(context.Context, config.RemoteHost, bool) (remotesync.SyncStats, error),
) func() {
t.Helper()
orig := runHTTPRemoteSync
runHTTPRemoteSync = func(
ctx context.Context,
_ config.Config,
_ *db.DB,
rh config.RemoteHost,
full bool,
) (remotesync.SyncStats, error) {
return fn(ctx, rh, full)
}
return func() { runHTTPRemoteSync = orig }
}
func TestRunRemoteSyncTransportRetainsFailedHTTPCleanupUntilReleased(t *testing.T) {
originalRegistry := httpRemoteCleanupRegistry
httpRemoteCleanupRegistry = new(remotesync.CleanupRegistry)
t.Cleanup(func() { httpRemoteCleanupRegistry = originalRegistry })
owner := &syncTransportCleanupError{
cause: errors.New("http sync failed"),
results: []error{
errors.New("cleanup still failed"),
nil,
},
}
runs := 0
restore := stubHTTPRemoteSyncForTest(t, func(
context.Context, config.RemoteHost, bool,
) (remotesync.SyncStats, error) {
runs++
if runs == 1 {
return remotesync.SyncStats{}, owner
}
return remotesync.SyncStats{SessionsSynced: 1}, nil
})
t.Cleanup(restore)
rh := config.RemoteHost{Host: "alpha", Transport: config.RemoteTransportHTTP}
_, err := runRemoteSyncTransport(
context.Background(), config.Config{}, nil, rh, false,
)
require.Same(t, owner, err)
assert.Equal(t, 1, owner.retries,
"cleanup must be retried before the transport returns the error")
assert.Equal(t, 1, runs)
stats, err := runRemoteSyncTransport(
context.Background(), config.Config{}, nil, rh, false,
)
require.NoError(t, err)
assert.Equal(t, 1, stats.SessionsSynced)
assert.Equal(t, 2, owner.retries)
assert.Equal(t, 2, runs,
"later HTTP work starts after retained cleanup releases")
}
func TestRunRemoteHostsStopsOnPendingHTTPCleanupWithoutMisattribution(t *testing.T) {
originalRegistry := httpRemoteCleanupRegistry
httpRemoteCleanupRegistry = new(remotesync.CleanupRegistry)
t.Cleanup(func() { httpRemoteCleanupRegistry = originalRegistry })
owner := &syncTransportCleanupError{
cause: errors.New("alpha HTTP sync failed"),
results: []error{
errors.New("cleanup failed after alpha"),
errors.New("cleanup still blocks beta"),
errors.New("cleanup still blocks later call"),
nil,
},
}
var callbacks []string
restore := stubHTTPRemoteSyncForTest(t, func(
_ context.Context, rh config.RemoteHost, _ bool,
) (remotesync.SyncStats, error) {
callbacks = append(callbacks, rh.Host)
if rh.Host == "alpha" {
return remotesync.SyncStats{}, owner
}
return remotesync.SyncStats{SessionsSynced: 1}, nil
})
t.Cleanup(restore)
run := func(rh config.RemoteHost, full bool) error {
_, err := runRemoteSyncTransport(
context.Background(), config.Config{}, nil, rh, full,
)
return err
}
httpHost := func(host string) config.RemoteHost {
return config.RemoteHost{Host: host, Transport: config.RemoteTransportHTTP}
}
failures, blocked := runRemoteHosts([]config.RemoteHost{
httpHost("alpha"), httpHost("beta"), httpHost("gamma"),
}, false, run)
require.Len(t, failures, 1)
assert.Equal(t, "alpha", failures[0].Host.Host)
assert.Same(t, owner, failures[0].Err)
var pending *remotesync.PendingCleanupError
require.ErrorAs(t, blocked, &pending)
assert.ErrorIs(t, blocked, owner)
assert.Equal(t, []string{"alpha"}, callbacks,
"beta's callback is blocked and iteration stops before gamma")
assert.Equal(t, 2, owner.retries)
failures, blocked = runRemoteHosts(
[]config.RemoteHost{httpHost("delta")}, false, run,
)
assert.Empty(t, failures)
require.ErrorAs(t, blocked, &pending)
assert.Equal(t, []string{"alpha"}, callbacks)
assert.Equal(t, 3, owner.retries)
failures, blocked = runRemoteHosts(
[]config.RemoteHost{httpHost("epsilon")}, false, run,
)
assert.Empty(t, failures)
require.NoError(t, blocked)
assert.Equal(t, []string{"alpha", "epsilon"}, callbacks)
assert.Equal(t, 4, owner.retries)
}
type syncTransportCleanupError struct {
cause error
results []error
retries int
}
func (e *syncTransportCleanupError) Error() string { return e.cause.Error() }
func (e *syncTransportCleanupError) Unwrap() error { return e.cause }
func (e *syncTransportCleanupError) RetryCleanup() error {
result := e.results[e.retries]
e.retries++
return result
}
func TestSyncLocalAndRemotes_ResyncForcesRemoteFull(t *testing.T) {
tests := []struct {
name string
cfgFull bool
didResync bool
wantFull bool
}{
{"no full, no resync", false, false, false},
{"automatic resync forces remote full", false, true, true},
{"cli --full", true, false, true},
{"both", true, true, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hosts := []config.RemoteHost{{Host: "alpha"}, {Host: "beta"}}
localCalled := false
var gotFull []bool
failures, blocked := syncLocalAndRemotes(hosts, tt.cfgFull,
func() bool { localCalled = true; return tt.didResync },
func(_ config.RemoteHost, full bool) error {
gotFull = append(gotFull, full)
return nil
})
require.True(t, localCalled, "local sync must run")
require.NoError(t, blocked)
assert.Empty(t, failures)
require.Len(t, gotFull, len(hosts))
for _, full := range gotFull {
assert.Equal(t, tt.wantFull, full)
}
})
}
}
func TestUseDaemonForSync(t *testing.T) {
tests := []struct {
name string
readOnly bool
want bool
}{
{"skips read-only daemon", true, false},
{"uses writable daemon", false, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
use := useDaemonForSync(transport{
Mode: transportHTTP,
URL: "http://127.0.0.1:8080",
ReadOnly: tt.readOnly,
})
assert.Equal(t, tt.want, use)
})
}
}
func TestParseDaemonSyncSSEAllowsLargeDoneEvent(t *testing.T) {
largeWarning := strings.Repeat("x", 70*1024)
want := agentsync.SyncStats{
TotalSessions: 12,
Synced: 3,
Warnings: []string{largeWarning},
}
got, err := parseDaemonSyncSSE(doneSSE(t, want, true))
require.NoError(t, err)
assert.Equal(t, want.TotalSessions, got.TotalSessions)
assert.Equal(t, want.Synced, got.Synced)
require.Len(t, got.Warnings, 1)
assert.Equal(t, largeWarning, got.Warnings[0])
}
func TestParseDaemonSyncSSEFlushesUnterminatedDoneEvent(t *testing.T) {
want := agentsync.SyncStats{
TotalSessions: 12,
Synced: 3,
}
got, err := parseDaemonSyncSSE(doneSSE(t, want, false))
require.NoError(t, err)
assert.Equal(t, want.TotalSessions, got.TotalSessions)
assert.Equal(t, want.Synced, got.Synced)
}
func TestParseDaemonSyncSSEReportsErrorEventPayload(t *testing.T) {
_, err := parseDaemonSyncSSE(strings.NewReader(
"event: error\n" +
"data: remote sync failed\n" +
"data: permission denied\n\n",
))
require.Error(t, err)
assert.Contains(t, err.Error(), "daemon sync error")
assert.Contains(t, err.Error(), "remote sync failed\npermission denied")
}
func TestParseDaemonSyncSSEReportsProgressEvents(t *testing.T) {
want := agentsync.SyncStats{
TotalSessions: 12,
Synced: 3,
}
var progress []agentsync.Progress
got, err := parseDaemonSyncSSE(strings.NewReader(
"event: progress\n"+
"data: {\"phase\":\"rebuilding_search\",\"detail\":\"Rebuilding search index\",\"resync\":true}\n\n"+
sseString(t, doneSSE(t, want, true)),
), func(p agentsync.Progress) {
progress = append(progress, p)
})
require.NoError(t, err)
assert.Equal(t, want.Synced, got.Synced)
require.Len(t, progress, 1)
assert.Equal(t, agentsync.PhaseRebuildingSearch, progress[0].Phase)
assert.Equal(t, "Rebuilding search index", progress[0].Detail)
assert.True(t, progress[0].Resync)
}
func TestPrintSyncProgressClearsShorterOverwrites(t *testing.T) {
out := captureStdout(t, func() {
printSyncProgress(agentsync.Progress{
Detail: "Rebuilding search index",
Hint: "Rebuilding the search index may take a while on large archives.",
})
printSyncProgress(agentsync.Progress{
Detail: "Swapping rebuilt database into place",
})
})
require.GreaterOrEqual(t, strings.Count(out, "\x1b[K"), 2,
"each carriage-return progress line must clear stale text")
assert.Contains(t, out, "\r Swapping rebuilt database into place\x1b[K")
}
func TestResyncProgressPrinterWritesPhaseTimingsOnNewLines(t *testing.T) {
now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
clock := func() time.Time { return now }
var out bytes.Buffer
printer := newResyncProgressPrinter(&out, clock)
printer.Print(agentsync.Progress{
Phase: agentsync.PhasePreparingResync,
Detail: "Preparing full resync",
Resync: true,
})
now = now.Add(150 * time.Millisecond)
printer.Print(agentsync.Progress{
Phase: agentsync.PhaseSyncing,
Detail: "Syncing sessions into rebuilt database",
SessionsTotal: 10,
SessionsDone: 4,
MessagesIndexed: 40,
Resync: true,
})
now = now.Add(2 * time.Second)
printer.Print(agentsync.Progress{
Phase: agentsync.PhaseSyncing,
Detail: "Syncing sessions into rebuilt database",
SessionsTotal: 10,
SessionsDone: 10,
MessagesIndexed: 100,
Resync: true,
})
now = now.Add(350 * time.Millisecond)
printer.Print(agentsync.Progress{
Phase: agentsync.PhaseRebuildingSearch,
Detail: "Rebuilding search index",
Hint: "Rebuilding the search index may take a while on large archives.",
Resync: true,
})
now = now.Add(3 * time.Second)
printer.Print(agentsync.Progress{
Phase: agentsync.PhaseSwappingDatabase,
Detail: "Swapping rebuilt database into place",
Resync: true,
})
got := out.String()
assert.Contains(t, got, " Preparing full resync...\n")
assert.Contains(t, got, " Preparing full resync completed in 150ms\n")
assert.Contains(t, got, "\r Syncing sessions into rebuilt database: 10/10 sessions (100%) · 100 messages\x1b[K")
assert.Contains(t, got, "\n Syncing sessions into rebuilt database completed in 2.35s\n")
assert.Contains(t, got, " Rebuilding search index - Rebuilding the search index may take a while on large archives...\n")
assert.Contains(t, got, " Rebuilding search index completed in 3s\n")
assert.NotContains(t, got, "\r Rebuilding search index",
"non-session resync phases must not be overwritten in place")
}
func TestResyncProgressPrinterRendersDoneProgressBeforeCompletion(t *testing.T) {
now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
clock := func() time.Time { return now }
var out bytes.Buffer
printer := newResyncProgressPrinter(&out, clock)
printer.Print(agentsync.Progress{
Phase: agentsync.PhaseSyncing,
Detail: "Syncing sessions into rebuilt database",
SessionsTotal: 1,
SessionsDone: 1,
MessagesIndexed: 0,
Resync: true,
})
now = now.Add(time.Second)
printer.Print(agentsync.Progress{
Phase: agentsync.PhaseDone,
SessionsTotal: 1,
SessionsDone: 1,
MessagesIndexed: 42,
Resync: true,
})
got := out.String()
assert.Contains(t, got,
"\r Syncing sessions into rebuilt database: 1/1 sessions (100%) · 42 messages\x1b[K")
assert.Contains(t, got,
"\n Syncing sessions into rebuilt database completed in 1s\n")
}
func TestRemoteProgressPrinterWritesTimedStepLines(t *testing.T) {
now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
clock := func() time.Time { return now }
var out bytes.Buffer
printer := newRemoteProgressPrinter(&out, clock)
printer.Print(agentsync.Progress{
Detail: "Resolving agent directories on devbox",
})
now = now.Add(150 * time.Millisecond)
printer.Print(agentsync.Progress{
Detail: "Downloading session data from devbox (3 agents)",
})
now = now.Add(2 * time.Second)
printer.Print(agentsync.Progress{
Phase: agentsync.PhaseSyncing,
Detail: "Processing sessions from devbox",
SessionsTotal: 10,
SessionsDone: 4,
MessagesIndexed: 40,
})
now = now.Add(350 * time.Millisecond)
printer.Print(agentsync.Progress{
Phase: agentsync.PhaseSyncing,
Detail: "Processing sessions from devbox",
SessionsTotal: 10,
SessionsDone: 10,
MessagesIndexed: 100,
})
now = now.Add(3 * time.Second)
printer.Print(agentsync.Progress{
Detail: "Synced 10 sessions from devbox (1 unchanged)",
})
printer.Finish()
got := out.String()
assert.Contains(t, got, " Resolving agent directories on devbox...\n")
assert.Contains(t, got, " Resolving agent directories on devbox completed in 150ms\n")
assert.Contains(t, got, " Downloading session data from devbox (3 agents)...\n")
assert.Contains(t, got, " Downloading session data from devbox (3 agents) completed in 2s\n")
assert.Contains(t, got, "\r Processing sessions from devbox: 10/10 sessions (100%) · 100 messages\x1b[K")
assert.Contains(t, got, "\n Processing sessions from devbox completed in 3.35s\n")
assert.Contains(t, got, " Synced 10 sessions from devbox (1 unchanged)\n")
assert.True(t, strings.HasSuffix(got, "\n"), "remote progress should finish on a newline")
}
func TestRemoteProgressPrinterRendersByteProgressInPlace(t *testing.T) {
now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
clock := func() time.Time { return now }
var out bytes.Buffer
printer := newRemoteProgressPrinter(&out, clock)
printer.Print(agentsync.Progress{
Detail: "Downloading session archive from devbox",
BytesDone: 1 << 20,
BytesTotal: 4 << 20,
})
now = now.Add(150 * time.Millisecond)
printer.Print(agentsync.Progress{
Detail: "Downloading session archive from devbox",
BytesDone: 4 << 20,
BytesTotal: 4 << 20,
})
printer.Print(agentsync.Progress{
Detail: "Extracting session archive from devbox",
})
now = now.Add(850 * time.Millisecond)
printer.Finish()
got := out.String()
assert.Contains(t, got,
"\r Downloading session archive from devbox: 1.0 MB/4.0 MB (25%)\x1b[K")
assert.Contains(t, got,
"\r Downloading session archive from devbox: 4.0 MB/4.0 MB (100%)\x1b[K")
assert.Contains(t, got,
"\n Downloading session archive from devbox completed in 150ms\n")
assert.Contains(t, got, " Extracting session archive from devbox...\n")
assert.Contains(t, got,
" Extracting session archive from devbox completed in 850ms\n")
}
func TestRemoteProgressPrinterRendersLocalSyncProgressWithoutDetail(t *testing.T) {
now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
clock := func() time.Time { return now }
var out bytes.Buffer
printer := newRemoteProgressPrinter(&out, clock)
printer.Print(agentsync.Progress{
Phase: agentsync.PhaseSyncing,
SessionsTotal: 10,
SessionsDone: 4,
MessagesIndexed: 40,
})
now = now.Add(250 * time.Millisecond)
printer.Print(agentsync.Progress{
Phase: agentsync.PhaseDone,
SessionsTotal: 10,
SessionsDone: 10,
MessagesIndexed: 100,
})
printer.Print(agentsync.Progress{
Detail: "Resolving agent directories on devbox",
})
got := out.String()
assert.Contains(t, got, "\r Syncing local sessions: 4/10 sessions (40%) · 40 messages\x1b[K")
assert.Contains(t, got, "\r Syncing local sessions: 10/10 sessions (100%) · 100 messages\x1b[K")
assert.Contains(t, got, "\n Syncing local sessions completed in 250ms\n")
assert.Contains(t, got, " Resolving agent directories on devbox...\n")
}
func TestRemoteProgressPrinterKeepsResyncLabelOnDoneProgress(t *testing.T) {
now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
clock := func() time.Time { return now }
var out bytes.Buffer
printer := newRemoteProgressPrinter(&out, clock)
printer.Print(agentsync.Progress{
Phase: agentsync.PhaseSyncing,
Detail: "Syncing sessions into rebuilt database",
SessionsTotal: 10,
SessionsDone: 4,
MessagesIndexed: 40,
Resync: true,
})
now = now.Add(250 * time.Millisecond)
printer.Print(agentsync.Progress{
Phase: agentsync.PhaseDone,
SessionsTotal: 10,
SessionsDone: 10,
MessagesIndexed: 100,
Resync: true,
})
got := out.String()
assert.Contains(t, got, "\r Syncing sessions into rebuilt database: 10/10 sessions (100%) · 100 messages\x1b[K")
assert.NotContains(t, got, "\r Syncing local sessions: 10/10 sessions (100%) · 100 messages\x1b[K")
assert.Contains(t, got, "\n Syncing sessions into rebuilt database completed in 250ms\n")
}
func TestRunLocalSyncUsesCallerContextForResync(t *testing.T) {
dataDir := t.TempDir()
dbPath := filepath.Join(dataDir, "sessions.db")
database, err := db.Open(dbPath)
require.NoError(t, err)
require.NoError(t, database.Close())
raw, err := sql.Open("sqlite3", dbPath)
require.NoError(t, err)
_, err = raw.Exec("PRAGMA user_version = 0")
require.NoError(t, err)
require.NoError(t, raw.Close())
database, err = db.Open(dbPath)
require.NoError(t, err)
t.Cleanup(func() { database.Close() })
require.True(t, database.NeedsResync())
ctx, cancel := context.WithCancel(context.Background())
cancel()
var didResync bool
captureStdout(t, func() {
didResync = runLocalSync(ctx, config.Config{
DataDir: dataDir,
DBPath: dbPath,
}, database, false)
})
assert.True(t, didResync)
assert.True(t, database.NeedsResync())
}
func TestDoSyncUsesDaemonRouteWhenWritableDaemonRunning(t *testing.T) {
env := newSyncCLIEnv(t)
var syncCalled bool
ts := syncRouteTestServer(t, func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/api/v1/sync", r.URL.Path)
require.Equal(t, http.MethodPost, r.Method)
syncCalled = true
writeDoneSSE(t, w, agentsync.SyncStats{Synced: 7})
})
registerSyncRouteTestRuntime(t, env.DataDir, ts.URL)
hadFailures := doSync(SyncConfig{})
require.False(t, hadFailures)
assert.True(t, syncCalled)
env.assertNoLocalDB(t)
}
func TestDoSyncFullUsesDaemonResyncRoute(t *testing.T) {
env := newSyncCLIEnv(t)
var resyncCalled bool
ts := syncRouteTestServer(t, func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/api/v1/resync", r.URL.Path)
require.Equal(t, http.MethodPost, r.Method)
resyncCalled = true
w.Header().Set("Content-Type", "text/event-stream")
_, err := io.WriteString(w,
"event: progress\n"+
"data: {\"phase\":\"rebuilding_search\",\"detail\":\"Rebuilding search index\",\"resync\":true}\n\n",
)
require.NoError(t, err)
writeDoneSSE(t, w, agentsync.SyncStats{Synced: 9})
})
registerSyncRouteTestRuntime(t, env.DataDir, ts.URL)
var hadFailures bool
out := captureStdout(t, func() {
hadFailures = doSync(SyncConfig{Full: true})
})
require.False(t, hadFailures)
assert.True(t, resyncCalled)
assert.Contains(t, out, "Rebuilding search index")
env.assertNoLocalDB(t)
}
func TestDoSyncPrintsStatusBeforeWaitingForDaemonStartup(t *testing.T) {
env := newSyncCLIEnv(t)
ts := syncRouteTestServer(t, func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/api/v1/resync", r.URL.Path)
writeDoneSSE(t, w, agentsync.SyncStats{})
})
endpoint := serverEndpoint(t, ts)
startupEntered := make(chan struct{})
releaseStartup := make(chan struct{})
t.Cleanup(func() {
select {
case <-releaseStartup:
default:
close(releaseStartup)
}
})
stubStartBackgroundServeForTransport(t, func(
context.Context, *config.Config, time.Duration,
) (*DaemonRuntime, error) {
close(startupEntered)
<-releaseStartup
return &DaemonRuntime{Host: endpoint.Host, Port: endpoint.Port}, nil
})
stdout := filepath.Join(t.TempDir(), "stdout")
outFile, err := os.Create(stdout)
require.NoError(t, err)
oldStdout := os.Stdout
os.Stdout = outFile
t.Cleanup(func() {
os.Stdout = oldStdout
_ = outFile.Close()
})
done := make(chan bool, 1)
go func() {
done <- doSync(SyncConfig{Full: true})
}()
<-startupEntered
require.NoError(t, outFile.Sync())
output, err := os.ReadFile(stdout)
require.NoError(t, err)
assert.Contains(t, string(output), "Preparing full sync...")
close(releaseStartup)
assert.False(t, <-done)
require.NoError(t, outFile.Close())
os.Stdout = oldStdout
env.assertNoLocalDB(t)
}
func TestDoSyncFullSkipsRedundantDaemonInitialSync(t *testing.T) {
env := newSyncCLIEnv(t)
ts := syncRouteTestServer(t, func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/api/v1/resync", r.URL.Path)
writeDoneSSE(t, w, agentsync.SyncStats{})
})
endpoint := serverEndpoint(t, ts)
var skipInitialSync bool
stubStartBackgroundServeForTransport(t, func(
_ context.Context, cfg *config.Config, _ time.Duration,
) (*DaemonRuntime, error) {
skipInitialSync = cfg.SkipInitialSync
return &DaemonRuntime{Host: endpoint.Host, Port: endpoint.Port}, nil
})
hadFailures := doSync(SyncConfig{Full: true})
assert.False(t, hadFailures)
assert.True(t, skipInitialSync)
env.assertNoLocalDB(t)
}
func TestDoSyncSkipsRedundantDaemonInitialSync(t *testing.T) {
env := newSyncCLIEnv(t)
ts := syncRouteTestServer(t, func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/api/v1/sync", r.URL.Path)
writeDoneSSE(t, w, agentsync.SyncStats{})
})
endpoint := serverEndpoint(t, ts)
var skipInitialSync bool
stubStartBackgroundServeForTransport(t, func(
_ context.Context, cfg *config.Config, _ time.Duration,
) (*DaemonRuntime, error) {
skipInitialSync = cfg.SkipInitialSync
return &DaemonRuntime{Host: endpoint.Host, Port: endpoint.Port}, nil
})
hadFailures := doSync(SyncConfig{})
assert.False(t, hadFailures)
assert.True(t, skipInitialSync)
env.assertNoLocalDB(t)
}
func TestDoSyncRemoteHostKeepsDaemonInitialLocalSync(t *testing.T) {
env := newSyncCLIEnv(t)
got, handler := captureRemoteSyncRequest(t)
ts := remoteSyncRouteTestServer(t, handler)
endpoint := serverEndpoint(t, ts)
var skipInitialSync bool
stubStartBackgroundServeForTransport(t, func(
_ context.Context, cfg *config.Config, _ time.Duration,
) (*DaemonRuntime, error) {
skipInitialSync = cfg.SkipInitialSync
return &DaemonRuntime{Host: endpoint.Host, Port: endpoint.Port}, nil
})
hadFailures := doSync(SyncConfig{Host: "host-a.example"})
assert.False(t, hadFailures)
assert.False(t, skipInitialSync,
"remote-only request needs the daemon startup local sync")
assert.False(t, got.IncludeLocal,
"the remote request must not duplicate the startup local sync")
env.assertNoLocalDB(t)
}
func TestRunDaemonSyncTrimsBaseURLTrailingSlash(t *testing.T) {
var syncCalled bool
ts := syncRouteTestServer(t, func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/api/v1/sync", r.URL.Path)
require.Equal(t, strings.TrimSuffix(tsURL(t, r), "/"), r.Header.Get("Origin"))
syncCalled = true
writeDoneSSE(t, w, agentsync.SyncStats{Synced: 7})
})
stats, err := runDaemonSync(
context.Background(),
transport{URL: ts.URL + "/"},
"",
false,
nil,
)
require.NoError(t, err)
assert.True(t, syncCalled)
assert.Equal(t, 7, stats.Synced)
}
func TestDoSyncRemoteHostUsesDaemonRouteWhenWritableDaemonRunning(t *testing.T) {
env := newSyncCLIEnv(t)
got, handler := captureRemoteSyncRequest(t)
ts := remoteSyncRouteTestServer(t, handler)
registerSyncRouteTestRuntime(t, env.DataDir, ts.URL)
hadFailures := doSync(SyncConfig{
Host: "devbox",
User: "alice",
Port: 2222,
Full: true,
})
require.False(t, hadFailures)
assert.False(t, got.IncludeLocal)
assert.True(t, got.Full)
require.Len(t, got.Hosts, 1)
assert.Equal(t, config.RemoteHost{
Host: "devbox",
User: "alice",
Port: 2222,
}, got.Hosts[0])
env.assertNoLocalDB(t)
}
func TestDoSyncRemoteHostPrintsDaemonProgress(t *testing.T) {
env := newSyncCLIEnv(t)
ts := remoteSyncRouteTestServer(t, func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/api/v1/sync/remotes", r.URL.Path)
require.Equal(t, http.MethodPost, r.Method)
w.Header().Set("Content-Type", "text/event-stream")
_, err := io.WriteString(w,
"event: progress\n"+
"data: {\"detail\":\"Resolving agent directories on devbox\"}\n\n"+
"event: done\n"+
"data: {\"failures\":[]}\n\n",
)
require.NoError(t, err)
})
registerSyncRouteTestRuntime(t, env.DataDir, ts.URL)
var hadFailures bool
out := captureStdout(t, func() {
hadFailures = doSync(SyncConfig{Host: "devbox"})
})
require.False(t, hadFailures)
assert.Contains(t, out, "Running sync with remotes via daemon...")
assert.Contains(t, out, "Resolving agent directories on devbox")
assert.True(t, strings.HasSuffix(out, "\n"), "progress output should finish on a newline")
env.assertNoLocalDB(t)
}
func TestRunDaemonRemoteSyncTrimsBaseURLTrailingSlash(t *testing.T) {
ts := remoteSyncRouteTestServer(t, func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/api/v1/sync/remotes", r.URL.Path)
require.Equal(t, strings.TrimSuffix(tsURL(t, r), "/"), r.Header.Get("Origin"))
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"failures":[]}`)
})
failures, err := runDaemonRemoteSync(
context.Background(),
transport{URL: ts.URL + "/"},
"",
[]config.RemoteHost{{Host: "devbox"}},
false,
false,
nil,
)
require.NoError(t, err)
assert.Empty(t, failures)
}
func TestRunDaemonRemoteSyncReportsProgressEvents(t *testing.T) {
ts := remoteSyncRouteTestServer(t, func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/api/v1/sync/remotes", r.URL.Path)
assert.Contains(t, r.Header.Get("Accept"), "text/event-stream")
w.Header().Set("Content-Type", "text/event-stream")
_, err := io.WriteString(w,
"event: progress\n"+
"data: {\"phase\":\"rebuilding_search\",\"detail\":\"Rebuilding search index\",\"resync\":true}\n\n"+
"event: done\n"+
"data: {\"failures\":[]}\n\n",
)
require.NoError(t, err)
})
var progress []agentsync.Progress
failures, err := runDaemonRemoteSync(
context.Background(),
transport{URL: ts.URL},
"",
[]config.RemoteHost{{Host: "devbox"}},
false,
true,
func(p agentsync.Progress) {
progress = append(progress, p)
},
)
require.NoError(t, err)
assert.Empty(t, failures)
require.Len(t, progress, 1)
assert.Equal(t, agentsync.PhaseRebuildingSearch, progress[0].Phase)
}
func TestRunDaemonRemoteSyncJSONReturnsTopLevelErrorWithEarlierFailures(t *testing.T) {
const blocked = "HTTP remote sync failed: pending cleanup still owns resources"
ts := remoteSyncRouteTestServer(t, func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, err := io.WriteString(w, `{
"failures":[{"host":{"host":"alpha"},"error":"alpha failed"}],
"error":"`+blocked+`"
}`)
require.NoError(t, err)
})
failures, err := runDaemonRemoteSync(
context.Background(), transport{URL: ts.URL}, "",
[]config.RemoteHost{{Host: "alpha"}, {Host: "beta"}},
false, false, nil,
)
require.EqualError(t, err, blocked)
require.Len(t, failures, 1)
assert.Equal(t, "alpha", failures[0].Host.Host)
assert.EqualError(t, failures[0].Err, "alpha failed")
}
func TestRunDaemonRemoteSyncJSONRejectsAbortedUnifiedRebuild(t *testing.T) {
ts := remoteSyncRouteTestServer(t, func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, err := io.WriteString(w, `{
"local_stats":{"aborted":true},
"failures":[],
"error":"`+agentsync.ErrUnifiedRebuildAborted.Error()+`"
}`)
require.NoError(t, err)
})
failures, err := runDaemonRemoteSync(
context.Background(), transport{URL: ts.URL}, "",
[]config.RemoteHost{{Host: "alpha"}}, true, true, nil,
)
require.ErrorIs(t, err, agentsync.ErrUnifiedRebuildAborted)
assert.Empty(t, failures)
}
func TestRunDaemonRemoteSyncSSEReturnsTopLevelErrorWithEarlierFailures(t *testing.T) {
const blocked = "HTTP remote sync failed: pending cleanup still owns resources"
ts := remoteSyncRouteTestServer(t, func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, err := io.WriteString(w,
"event: done\n"+
`data: {"failures":[{"host":{"host":"alpha"},"error":"alpha failed"}],"error":"`+blocked+`"}`+
"\n\n",
)
require.NoError(t, err)
})
failures, err := runDaemonRemoteSync(
context.Background(), transport{URL: ts.URL}, "",
[]config.RemoteHost{{Host: "alpha"}, {Host: "beta"}},
false, false, nil,
)
require.EqualError(t, err, blocked)
require.Len(t, failures, 1)
assert.Equal(t, "alpha", failures[0].Host.Host)
assert.EqualError(t, failures[0].Err, "alpha failed")
}
func TestRunDaemonRemoteSyncSSERejectsAbortedUnifiedRebuild(t *testing.T) {
ts := remoteSyncRouteTestServer(t, func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, err := io.WriteString(w,
"event: done\n"+
`data: {"local_stats":{"aborted":true},"failures":[],"error":"`+
agentsync.ErrUnifiedRebuildAborted.Error()+`"}`+
"\n\n",
)
require.NoError(t, err)
})
failures, err := runDaemonRemoteSync(
context.Background(), transport{URL: ts.URL}, "",
[]config.RemoteHost{{Host: "alpha"}}, true, true, nil,
)
require.ErrorIs(t, err, agentsync.ErrUnifiedRebuildAborted)
assert.Empty(t, failures)
}
func TestDoSyncDaemonAbortedUnifiedRebuildExitsNonZero(t *testing.T) {
dataDir := t.TempDir()
cmd := exec.Command(
os.Args[0],
"-test.run=^TestDoSyncDaemonAbortedUnifiedRebuildHelperProcess$",
)
cmd.Env = append(os.Environ(),
"AGENTSVIEW_DAEMON_ABORTED_UNIFIED_HELPER=1",
"AGENTSVIEW_DATA_DIR="+dataDir,
)
out, err := cmd.CombinedOutput()
var exitErr *exec.ExitError
require.ErrorAs(t, err, &exitErr)
assert.Equal(t, 1, exitErr.ExitCode())
assert.Contains(t, string(out),
"fatal: daemon remote sync: "+agentsync.ErrUnifiedRebuildAborted.Error())
assert.NotContains(t, string(out), "Sync complete")
}
func TestDoSyncDaemonAbortedUnifiedRebuildHelperProcess(t *testing.T) {
if os.Getenv("AGENTSVIEW_DAEMON_ABORTED_UNIFIED_HELPER") != "1" {
return
}
dataDir := os.Getenv("AGENTSVIEW_DATA_DIR")
require.NotEmpty(t, dataDir)
require.NoError(t, os.WriteFile(
filepath.Join(dataDir, "config.toml"),
[]byte(`[[remote_hosts]]
host = "alpha"
transport = "http"
url = "http://127.0.0.1:1"
token = "remote-token"
`),
0o600,
))
ts := remoteSyncRouteTestServer(t, func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, err := io.WriteString(w,
"event: done\n"+
`data: {"local_stats":{"aborted":true},"failures":[],"error":"`+
agentsync.ErrUnifiedRebuildAborted.Error()+`"}`+
"\n\n",
)
require.NoError(t, err)
})
registerSyncRouteTestRuntime(t, dataDir, ts.URL)
doSync(SyncConfig{Full: true})
os.Exit(0)
}
func TestDoSyncConfiguredRemoteHostsUsesDaemonRouteWithLocalSync(
t *testing.T,
) {
env := newSyncCLIEnv(t)
require.NoError(t, os.WriteFile(
filepath.Join(env.DataDir, "config.toml"),
[]byte(`[[remote_hosts]]
host = "alpha"
user = "robot"
`),
0o600,
))
got, handler := captureRemoteSyncRequest(t)
ts := remoteSyncRouteTestServer(t, handler)
registerSyncRouteTestRuntime(t, env.DataDir, ts.URL)
hadFailures := doSync(SyncConfig{})
require.False(t, hadFailures)
assert.True(t, got.IncludeLocal)
require.Len(t, got.Hosts, 1)
assert.Equal(t, "alpha", got.Hosts[0].Host)
assert.Equal(t, "robot", got.Hosts[0].User)
env.assertNoLocalDB(t)
}
// syncCLIEnv is a daemon-backed CLI test environment: an isolated data dir
// exported via AGENTSVIEW_DATA_DIR with the global log writer restored on
// cleanup.
type syncCLIEnv struct {
DataDir string
DBPath string
}
func newSyncCLIEnv(t *testing.T) syncCLIEnv {
t.Helper()
dataDir := testDataDir(t)
restoreTestLogOutput(t)
return syncCLIEnv{
DataDir: dataDir,
DBPath: filepath.Join(dataDir, "sessions.db"),
}
}
// assertNoLocalDB verifies the CLI deferred to the daemon instead of opening a
// local SQLite archive.
func (e syncCLIEnv) assertNoLocalDB(t *testing.T) {
t.Helper()
assert.NoFileExists(t, e.DBPath)
}
// remoteSyncRequest mirrors the JSON body the CLI POSTs to the daemon's
// /api/v1/sync/remotes route.
type remoteSyncRequest struct {
Full bool `json:"full"`
IncludeLocal bool `json:"include_local"`
Hosts []config.RemoteHost `json:"hosts"`
}
// captureRemoteSyncRequest returns a handler that records the decoded remote
// sync request into the returned struct and replies with no failures.
func captureRemoteSyncRequest(t *testing.T) (*remoteSyncRequest, http.HandlerFunc) {
t.Helper()
got := &remoteSyncRequest{}
return got, func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
require.NoError(t, json.NewDecoder(r.Body).Decode(got))
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"failures":[]}`)
}
}
// doneSSE renders stats as a daemon sync "done" SSE event. When terminated is
// false the trailing blank line is omitted to exercise flush-on-EOF parsing.
func doneSSE(t *testing.T, stats agentsync.SyncStats, terminated bool) io.Reader {
t.Helper()
payload, err := json.Marshal(stats)
require.NoError(t, err)
suffix := "\n\n"
if !terminated {
suffix = "\n"
}
return strings.NewReader("event: done\ndata: " + string(payload) + suffix)
}
func sseString(t *testing.T, r io.Reader) string {
t.Helper()
data, err := io.ReadAll(r)
require.NoError(t, err)
return string(data)
}
// writeDoneSSE writes a terminated daemon sync "done" SSE event to w.
func writeDoneSSE(t *testing.T, w io.Writer, stats agentsync.SyncStats) {
t.Helper()
_, err := io.Copy(w, doneSSE(t, stats, true))
require.NoError(t, err)
}
// daemonRouteTestServer starts an httptest server that answers daemon ping
// probes and dispatches the given routes by exact path.
func daemonRouteTestServer(
t *testing.T,
routes map[string]http.HandlerFunc,
) *httptest.Server {
t.Helper()
ping := daemon.NewPingHandler(daemon.PingHandlerOptions{
Service: daemonService,
Version: "test",
})
ts := httptest.NewServer(http.HandlerFunc(func(
w http.ResponseWriter,
r *http.Request,
) {
if r.URL.Path == "/api/ping" {
ping.ServeHTTP(w, r)
return
}
if h, ok := routes[r.URL.Path]; ok {
h(w, r)
return
}
http.NotFound(w, r)
}))
t.Cleanup(ts.Close)
return ts
}
func syncRouteTestServer(
t *testing.T,
syncHandler http.HandlerFunc,
) *httptest.Server {
t.Helper()
return daemonRouteTestServer(t, map[string]http.HandlerFunc{
"/api/v1/sync": syncHandler,
"/api/v1/resync": syncHandler,
})
}
func remoteSyncRouteTestServer(
t *testing.T,
remoteHandler http.HandlerFunc,
) *httptest.Server {
t.Helper()
return daemonRouteTestServer(t, map[string]http.HandlerFunc{
"/api/v1/sync/remotes": remoteHandler,
})
}
func registerSyncRouteTestRuntime(
t *testing.T,
dataDir string,
rawURL string,
) {
registerTestRuntime(t, dataDir, rawURL, false)
}
func registerTestRuntime(
t *testing.T,
dataDir string,
rawURL string,
readOnly bool,
) {
t.Helper()
u, err := url.Parse(rawURL)
require.NoError(t, err)
host, portText, err := net.SplitHostPort(u.Host)
require.NoError(t, err)
port, err := strconv.Atoi(portText)
require.NoError(t, err)
_, err = WriteDaemonRuntime(dataDir, host, port, "test", readOnly)
require.NoError(t, err)
}
func tsURL(t *testing.T, r *http.Request) string {
t.Helper()
return "http://" + r.Host
}
func TestRemoteFailureDisplaySanitizesHTTPErrors(t *testing.T) {
tests := []struct {
name string
failure remoteHostFailure
want string
wantAbsent []string
}{
{
name: "http status failure uses sanitized summary",
failure: remoteHostFailure{
Host: config.RemoteHost{
Host: "devbox",
Transport: config.RemoteTransportHTTP,
},
Err: &remotesync.StatusError{
Code: 401,
Status: "401 Unauthorized",
Detail: "bearer secret-token-123 rejected",
},
},
want: "HTTP remote sync failed: remote daemon rejected " +
"the sync token (401 Unauthorized); the token for " +
"this host in [[remote_hosts]] must match the remote " +
"daemon's auth_token",
wantAbsent: []string{"secret-token-123"},
},
{
name: "http transport collapses unknown raw errors",
failure: remoteHostFailure{
Host: config.RemoteHost{
Host: "devbox",
Transport: config.RemoteTransportHTTP,
},
Err: errors.New(
`Get "http://devbox.tailnet.ts.net:8080": token=abc rejected`,
),
},
want: "HTTP remote sync failed",
wantAbsent: []string{"tailnet.ts.net", "token=abc"},
},
{
name: "ssh transport keeps the raw error",
failure: remoteHostFailure{
Host: config.RemoteHost{Host: "buildbox"},
Err: errors.New("ssh: permission denied"),
},
want: "ssh: permission denied",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := remoteFailureDisplay(tt.failure)
assert.Equal(t, tt.want, got)
for _, absent := range tt.wantAbsent {
assert.NotContains(t, got, absent)
}
})
}
}
func TestRunHTTPRemoteSyncReachesMirrorPath(t *testing.T) {
manifestRequests := 0
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/remote-sync/targets":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{}`))
case "/api/v1/remote-sync/manifest":
manifestRequests++
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"files":[]}`))
default:
http.NotFound(w, r)
}
}))
t.Cleanup(ts.Close)
database := dbtest.OpenTestDB(t)
_, err := runHTTPRemoteSync(
context.Background(),
config.Config{DataDir: t.TempDir()},
database,
config.RemoteHost{
Host: "devbox",
Transport: config.RemoteTransportHTTP,
URL: ts.URL,
Token: "remote-token",
},
false,
)
require.NoError(t, err)
assert.Equal(t, 1, manifestRequests,
"configured DataDir must route HTTP sync through the manifest/mirror path")
}