Wire symlinks.EnsureForAccount into executeSwitch, called immediately after the ~/.claude flip. Guarantees the three shared-state links (session-env, file-history, projects) exist on the target account home even for freshly-provisioned accounts, preventing silent transcript duplication and undo-history divergence on first resume. Best-effort: errors are logged as WARN but never abort the swap. If we returned here the daemon would be left inconsistent (symlink flipped, SetActiveAccount never called). Operator sees the warning in logs and resolves divergent links manually. Tests: - TestFlipReconcilesSharedSymlinksOnTargetHome: empty target home gets all three links pointing at canonical targets after the flip. - TestFlipEnsureSymlinksFailureDoesNotAbortSwap: a planted divergent link triggers the symlinks-package error; the swap completes anyway and the active account is updated. Hermetic: added AccountSwitcher.sharedSymlinks override so tests scope the reconcile inside t.TempDir() and never touch /home/ubuntu/.claude-*-shared. Existing tests migrated to this pattern and hardcoded /tmp/claude-*-xxxx paths replaced with tmpdirs. Phase 1 / Chantier A — task A3.
352 lines
12 KiB
Go
352 lines
12 KiB
Go
package switcher
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"forge.secuaas.ovh/olivier/claude-failover/internal/config"
|
|
"forge.secuaas.ovh/olivier/claude-failover/internal/quota"
|
|
"forge.secuaas.ovh/olivier/claude-failover/internal/state"
|
|
"forge.secuaas.ovh/olivier/claude-failover/internal/symlinks"
|
|
)
|
|
|
|
// tmpShared returns a SharedSymlink list whose targets live entirely under
|
|
// tmpDir, so switcher tests never touch /home/ubuntu/.claude-*-shared.
|
|
func tmpShared(tmpDir string) []symlinks.SharedSymlink {
|
|
return []symlinks.SharedSymlink{
|
|
{Target: filepath.Join(tmpDir, "session-env-shared"), Name: "session-env"},
|
|
{Target: filepath.Join(tmpDir, "file-history-shared"), Name: "file-history"},
|
|
{Target: filepath.Join(tmpDir, "projects-shared"), Name: "projects"},
|
|
}
|
|
}
|
|
|
|
// mockTmux for switcher tests.
|
|
type mockTmux struct {
|
|
sessions map[string]bool
|
|
paneOutput map[string]string
|
|
killCalls []string
|
|
createCalls []string
|
|
sendKeyCalls []string
|
|
}
|
|
|
|
func newMockTmux() *mockTmux {
|
|
return &mockTmux{
|
|
sessions: make(map[string]bool),
|
|
paneOutput: make(map[string]string),
|
|
}
|
|
}
|
|
|
|
func (m *mockTmux) HasSession(name string) bool { return m.sessions[name] }
|
|
func (m *mockTmux) CreateSession(name, _ string) error {
|
|
m.sessions[name] = true
|
|
m.createCalls = append(m.createCalls, name)
|
|
return nil
|
|
}
|
|
func (m *mockTmux) KillSession(name string) error {
|
|
delete(m.sessions, name)
|
|
m.killCalls = append(m.killCalls, name)
|
|
return nil
|
|
}
|
|
func (m *mockTmux) SendKeys(session, keys string) error {
|
|
m.sendKeyCalls = append(m.sendKeyCalls, session+":"+keys)
|
|
return nil
|
|
}
|
|
func (m *mockTmux) SendEnter(session string) error {
|
|
m.sendKeyCalls = append(m.sendKeyCalls, session+":<ENTER>")
|
|
return nil
|
|
}
|
|
func (m *mockTmux) CapturePaneTail(session string, _ int) (string, error) {
|
|
return m.paneOutput[session], nil
|
|
}
|
|
|
|
// TestFindTargetAccount returns the first account that differs from current.
|
|
func TestFindTargetAccount(t *testing.T) {
|
|
tc := newMockTmux()
|
|
s := state.New("")
|
|
cfg := &config.Config{
|
|
Accounts: []config.AccountConfig{
|
|
{Name: "compte1", Priority: 1},
|
|
{Name: "compte2", Priority: 2},
|
|
},
|
|
}
|
|
a := New(tc, s, cfg, make(chan quota.SwitchRequest), nil)
|
|
|
|
target := a.findTargetAccount("compte1")
|
|
if target == nil || target.Name != "compte2" {
|
|
t.Errorf("expected compte2, got %v", target)
|
|
}
|
|
}
|
|
|
|
// TestFindTargetAccountSingleAccount returns nil when only one account exists.
|
|
func TestFindTargetAccountSingleAccount(t *testing.T) {
|
|
tc := newMockTmux()
|
|
s := state.New("")
|
|
cfg := &config.Config{
|
|
Accounts: []config.AccountConfig{{Name: "solo"}},
|
|
}
|
|
a := New(tc, s, cfg, make(chan quota.SwitchRequest), nil)
|
|
|
|
if got := a.findTargetAccount("solo"); got != nil {
|
|
t.Errorf("expected nil for single account, got %v", got)
|
|
}
|
|
}
|
|
|
|
// TestExtractResumeUUID parses UUID from pane output.
|
|
func TestExtractResumeUUID(t *testing.T) {
|
|
input := "$ claude --resume a1b2c3d4-e5f6-7890-abcd-ef1234567890 --model sonnet"
|
|
got := extractResumeUUID(input)
|
|
want := "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
|
|
if got != want {
|
|
t.Errorf("expected %q, got %q", want, got)
|
|
}
|
|
}
|
|
|
|
// TestExtractResumeUUIDMissing returns empty string when no UUID present.
|
|
func TestExtractResumeUUIDMissing(t *testing.T) {
|
|
if got := extractResumeUUID("no uuid here"); got != "" {
|
|
t.Errorf("expected empty, got %q", got)
|
|
}
|
|
}
|
|
|
|
// TestTimeUntilReset parses minute and hour formats correctly.
|
|
func TestTimeUntilReset(t *testing.T) {
|
|
cases := []struct {
|
|
input string
|
|
want time.Duration
|
|
}{
|
|
{"in 45 minutes", 45 * time.Minute},
|
|
{"in 2 hours", 2 * time.Hour},
|
|
{"in 1 hour", 1 * time.Hour},
|
|
{"", 2 * time.Hour},
|
|
{"8pm", 2 * time.Hour}, // fallback for unrecognised formats
|
|
}
|
|
for _, c := range cases {
|
|
if got := timeUntilReset(c.input); got != c.want {
|
|
t.Errorf("timeUntilReset(%q) = %v, want %v", c.input, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestKillAndRecreatePoolSessions verifies that executeSwitch restarts sessions.
|
|
func TestKillAndRecreatePoolSessions(t *testing.T) {
|
|
tc := newMockTmux()
|
|
tc.sessions["ccl-auto-0"] = true
|
|
tc.sessions["ccl-auto-1"] = true
|
|
tc.sessions["dedicated-1"] = true
|
|
|
|
s := state.New("")
|
|
s.SetActiveAccount("compte1")
|
|
|
|
cfg := &config.Config{
|
|
Accounts: []config.AccountConfig{
|
|
{Name: "compte1", Home: t.TempDir()},
|
|
{Name: "compte2", Home: t.TempDir()},
|
|
},
|
|
Pool: config.PoolConfig{
|
|
Dedicated: []config.DedicatedSession{{Name: "dedicated-1", Project: "/tmp"}},
|
|
Autonomous: config.AutonomousConfig{Prefix: "ccl-auto-", Min: 2, Max: 2},
|
|
},
|
|
}
|
|
|
|
a := New(tc, s, cfg, make(chan quota.SwitchRequest), nil)
|
|
// CRITICAL: isolate symlink manipulation in a tmpdir so the test never
|
|
// touches the real ~/.claude (regression: a reboot used to leave Claude
|
|
// Code unusable because the test had repointed ~/.claude to /tmp/...).
|
|
a.homeDir = t.TempDir()
|
|
// Scope shared-symlink targets to a tmpdir so the post-flip ensure
|
|
// pass does not write inside /home/ubuntu/.claude-*-shared.
|
|
a.sharedSymlinks = tmpShared(t.TempDir())
|
|
a.executeSwitch(quota.SwitchRequest{From: "compte1"})
|
|
|
|
// Active account must have changed.
|
|
if got := s.ActiveAccount(); got != "compte2" {
|
|
t.Errorf("expected active account compte2, got %q", got)
|
|
}
|
|
|
|
// All old sessions must have been killed.
|
|
for _, name := range []string{"ccl-auto-0", "ccl-auto-1", "dedicated-1"} {
|
|
found := false
|
|
for _, k := range tc.killCalls {
|
|
if k == name {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected %q to be killed", name)
|
|
}
|
|
}
|
|
|
|
// Min pool sessions must be recreated.
|
|
recreated := map[string]bool{}
|
|
for _, c := range tc.createCalls {
|
|
recreated[c] = true
|
|
}
|
|
if !recreated["ccl-auto-0"] || !recreated["ccl-auto-1"] {
|
|
t.Errorf("expected autonomous sessions recreated; createCalls=%v", tc.createCalls)
|
|
}
|
|
}
|
|
|
|
// TestDedicatedRelaunchAfterSwap verifies that a dedicated session is
|
|
// automatically restarted with `claude --resume <uuid>` on the target
|
|
// account's home after a swap, so interactive user work is preserved.
|
|
func TestDedicatedRelaunchAfterSwap(t *testing.T) {
|
|
tc := newMockTmux()
|
|
tc.sessions["dedicated-1"] = true
|
|
// Pane shows the full resume command — saveDedicatedUUIDs will extract it.
|
|
tc.paneOutput["dedicated-1"] = "claude --resume aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee --dangerously-skip-permissions"
|
|
|
|
s := state.New("")
|
|
s.SetActiveAccount("compte1")
|
|
|
|
home1 := filepath.Join(t.TempDir(), "claude-1-xxxx")
|
|
home2 := filepath.Join(t.TempDir(), "claude-2-xxxx")
|
|
cfg := &config.Config{
|
|
Accounts: []config.AccountConfig{
|
|
{Name: "compte1", Home: home1},
|
|
{Name: "compte2", Home: home2},
|
|
},
|
|
Pool: config.PoolConfig{
|
|
Dedicated: []config.DedicatedSession{{Name: "dedicated-1", Project: "/tmp"}},
|
|
Autonomous: config.AutonomousConfig{Prefix: "ccl-auto-", Min: 0, Max: 0},
|
|
},
|
|
}
|
|
|
|
a := New(tc, s, cfg, make(chan quota.SwitchRequest), nil)
|
|
a.homeDir = t.TempDir()
|
|
a.sharedSymlinks = tmpShared(t.TempDir())
|
|
a.executeSwitch(quota.SwitchRequest{From: "compte1"})
|
|
|
|
// The relaunch must send a resume command on the dedicated session,
|
|
// pointing CLAUDE_CONFIG_DIR at the target account's home.
|
|
var relaunch string
|
|
for _, k := range tc.sendKeyCalls {
|
|
if strings.HasPrefix(k, "dedicated-1:") && strings.Contains(k, "--resume") {
|
|
relaunch = k
|
|
break
|
|
}
|
|
}
|
|
if relaunch == "" {
|
|
t.Fatalf("expected dedicated-1 relaunch send-keys; got %v", tc.sendKeyCalls)
|
|
}
|
|
if !strings.Contains(relaunch, "CLAUDE_CONFIG_DIR="+home2) {
|
|
t.Errorf("relaunch should set CLAUDE_CONFIG_DIR to target home; got %q", relaunch)
|
|
}
|
|
if !strings.Contains(relaunch, "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") {
|
|
t.Errorf("relaunch should include captured UUID; got %q", relaunch)
|
|
}
|
|
}
|
|
|
|
// TestFlipReconcilesSharedSymlinksOnTargetHome verifies A3: after the main
|
|
// ~/.claude flip, the switcher reconciles the three shared-state symlinks
|
|
// (session-env / file-history / projects) on the TARGET account home.
|
|
// Scenario: the target home has NO links yet — a freshly-provisioned account
|
|
// that has never been flipped into. Post-switch, all three links must exist
|
|
// inside the target home and point at the canonical shared targets.
|
|
func TestFlipReconcilesSharedSymlinksOnTargetHome(t *testing.T) {
|
|
tc := newMockTmux()
|
|
|
|
s := state.New("")
|
|
s.SetActiveAccount("compte1")
|
|
|
|
// Target home starts empty: EnsureForAccount will mkdir + create links.
|
|
targetHome := filepath.Join(t.TempDir(), "claude-compte2")
|
|
cfg := &config.Config{
|
|
Accounts: []config.AccountConfig{
|
|
{Name: "compte1", Home: filepath.Join(t.TempDir(), "claude-compte1")},
|
|
{Name: "compte2", Home: targetHome},
|
|
},
|
|
Pool: config.PoolConfig{
|
|
Autonomous: config.AutonomousConfig{Prefix: "ccl-auto-", Min: 0, Max: 0},
|
|
},
|
|
}
|
|
|
|
a := New(tc, s, cfg, make(chan quota.SwitchRequest), nil)
|
|
a.homeDir = t.TempDir()
|
|
shared := tmpShared(t.TempDir())
|
|
a.sharedSymlinks = shared
|
|
|
|
// Pre-assert: no link exists in targetHome.
|
|
for _, sl := range shared {
|
|
if _, err := os.Lstat(filepath.Join(targetHome, sl.Name)); !os.IsNotExist(err) {
|
|
t.Fatalf("pre-condition: %q should not exist yet (err=%v)", sl.Name, err)
|
|
}
|
|
}
|
|
|
|
a.executeSwitch(quota.SwitchRequest{From: "compte1"})
|
|
|
|
// Post-assert: every required link exists and points at the canonical
|
|
// target under the tmpdir-scoped shared root.
|
|
for _, sl := range shared {
|
|
linkPath := filepath.Join(targetHome, sl.Name)
|
|
info, err := os.Lstat(linkPath)
|
|
if err != nil {
|
|
t.Errorf("expected link at %s after flip: %v", linkPath, err)
|
|
continue
|
|
}
|
|
if info.Mode()&os.ModeSymlink == 0 {
|
|
t.Errorf("%s exists but is not a symlink", linkPath)
|
|
continue
|
|
}
|
|
got, err := os.Readlink(linkPath)
|
|
if err != nil {
|
|
t.Errorf("readlink %s: %v", linkPath, err)
|
|
continue
|
|
}
|
|
if got != sl.Target {
|
|
t.Errorf("link %s points to %q, want %q", linkPath, got, sl.Target)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestFlipEnsureSymlinksFailureDoesNotAbortSwap verifies A3 best-effort:
|
|
// if EnsureForAccount returns an error (here: a divergent pre-existing link
|
|
// that the symlinks package refuses to auto-correct), the flip and the swap
|
|
// MUST still complete. The shared symlink reconcile is post-flip cleanup,
|
|
// not a gate on the failover itself — aborting here would leave the daemon
|
|
// in an inconsistent state (symlink flipped but active account not updated).
|
|
func TestFlipEnsureSymlinksFailureDoesNotAbortSwap(t *testing.T) {
|
|
tc := newMockTmux()
|
|
|
|
s := state.New("")
|
|
s.SetActiveAccount("compte1")
|
|
|
|
targetHome := filepath.Join(t.TempDir(), "claude-compte2")
|
|
if err := os.MkdirAll(targetHome, 0700); err != nil {
|
|
t.Fatalf("mkdir target home: %v", err)
|
|
}
|
|
// Plant a divergent link at <targetHome>/session-env. The symlinks
|
|
// package refuses to auto-correct this (data-loss safeguard) and will
|
|
// return an error — which the switcher must swallow with a WARN log.
|
|
bogus := filepath.Join(t.TempDir(), "somewhere-else")
|
|
if err := os.MkdirAll(bogus, 0700); err != nil {
|
|
t.Fatalf("mkdir bogus: %v", err)
|
|
}
|
|
if err := os.Symlink(bogus, filepath.Join(targetHome, "session-env")); err != nil {
|
|
t.Fatalf("plant divergent link: %v", err)
|
|
}
|
|
|
|
cfg := &config.Config{
|
|
Accounts: []config.AccountConfig{
|
|
{Name: "compte1", Home: filepath.Join(t.TempDir(), "claude-compte1")},
|
|
{Name: "compte2", Home: targetHome},
|
|
},
|
|
Pool: config.PoolConfig{
|
|
Autonomous: config.AutonomousConfig{Prefix: "ccl-auto-", Min: 0, Max: 0},
|
|
},
|
|
}
|
|
|
|
a := New(tc, s, cfg, make(chan quota.SwitchRequest), nil)
|
|
a.homeDir = t.TempDir()
|
|
a.sharedSymlinks = tmpShared(t.TempDir())
|
|
|
|
a.executeSwitch(quota.SwitchRequest{From: "compte1"})
|
|
|
|
// The swap must have completed despite the divergent-link error.
|
|
if got := s.ActiveAccount(); got != "compte2" {
|
|
t.Errorf("swap should complete even when ensure fails; active=%q want compte2", got)
|
|
}
|
|
}
|