Files
gastown/internal/cmd/seance_test.go
Erik LaBianca 14435cacad fix: update test assertions and set BEADS_DIR in EnsureCustomTypes (#853)
* fix: update test assertions and set BEADS_DIR in EnsureCustomTypes

- Update TestBuildAgentStartupCommand to check for 'exec env' instead
  of 'export' (matches current BuildStartupCommand implementation)
- Add 'config' command handling to fake bd script in manager_test.go
- Set BEADS_DIR env var when running bd config in EnsureCustomTypes
  to ensure bd operates on the correct database during agent bead creation
- Apply gofmt formatting

These fixes address pre-existing test failures on main.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: inject mock in TestRoleLabelCheck_NoBeadsDir for Windows CI

The test was failing on Windows CI because bd is not installed,
causing exec.LookPath("bd") to fail and return "beads not installed"
before checking for the .beads directory.

Inject an empty mock beadShower to skip the LookPath check, allowing
the test to properly verify the "No beads database" path.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: regenerate formulas and fix unused parameter lint error

- Regenerate mol-witness-patrol.formula.toml to sync with source
- Mark unused hookName parameter with _ in installHookTo

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(tests): make Windows CI tests pass

- Skip symlink tests on Windows (require elevated privileges)
- Fix GT_ROOT assertion to handle Windows path escaping
- Use platform-appropriate paths in TestNewManager_PathConstruction

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix tests for quoted env and OS paths

* fix(test): add Windows batch scripts to molecule lifecycle tests

The molecule_lifecycle_test.go tests were failing on Windows CI because
they used Unix shell scripts (#!/bin/sh) for mock bd commands, which
don't work on Windows.

This commit adds Windows batch file equivalents for all three tests:
- TestSlingFormulaOnBeadHooksBaseBead
- TestSlingFormulaOnBeadSetsAttachedMoleculeInBaseBead
- TestDoneClosesAttachedMolecule

Uses the same pattern as writeBDStub() from sling_test.go for
cross-platform test mocks.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(test): add Windows batch scripts to more tests

Adds Windows batch script equivalents to tests that use mock bd commands:

molecule_lifecycle_test.go:
- TestSlingFormulaOnBeadHooksBaseBead
- TestSlingFormulaOnBeadSetsAttachedMoleculeInBaseBead
- TestDoneClosesAttachedMolecule

sling_288_test.go:
- TestInstantiateFormulaOnBead
- TestInstantiateFormulaOnBeadSkipCook
- TestCookFormula
- TestFormulaOnBeadPassesVariables

These tests were failing on Windows CI because they used Unix shell
scripts (#!/bin/sh) which don't work on Windows.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(test): skip TestSlingFormulaOnBeadSetsAttachedMoleculeInBaseBead on Windows

The test's Windows batch script JSON output causes
storeAttachedMoleculeInBead to fail silently when parsing the bd show
response. This is a pre-existing limitation - the test was failing on
Windows before the batch scripts were added (shell scripts don't work
on Windows at all).

Skip this test on Windows until the underlying JSON parsing issue is
resolved.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore: re-trigger CI after GitHub Internal Server Error

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 16:43:21 -08:00

376 lines
11 KiB
Go

package cmd
import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"testing"
"github.com/steveyegge/gastown/internal/config"
)
// setupSeanceTestEnv creates a test environment with multiple accounts and sessions.
func setupSeanceTestEnv(t *testing.T) (townRoot, fakeHome string, cleanup func()) {
t.Helper()
// Create fake home directory
fakeHome = t.TempDir()
// Create town root
townRoot = t.TempDir()
// Create mayor directory structure
mayorDir := filepath.Join(townRoot, "mayor")
if err := os.MkdirAll(mayorDir, 0755); err != nil {
t.Fatalf("mkdir mayor: %v", err)
}
// Create two account config directories
account1Dir := filepath.Join(fakeHome, "claude-config-account1")
account2Dir := filepath.Join(fakeHome, "claude-config-account2")
if err := os.MkdirAll(account1Dir, 0755); err != nil {
t.Fatalf("mkdir account1: %v", err)
}
if err := os.MkdirAll(account2Dir, 0755); err != nil {
t.Fatalf("mkdir account2: %v", err)
}
// Create accounts.json pointing to both accounts
accountsCfg := &config.AccountsConfig{
Version: 1,
Default: "account1",
Accounts: map[string]config.Account{
"account1": {Email: "test1@example.com", ConfigDir: account1Dir},
"account2": {Email: "test2@example.com", ConfigDir: account2Dir},
},
}
accountsPath := filepath.Join(mayorDir, "accounts.json")
if err := config.SaveAccountsConfig(accountsPath, accountsCfg); err != nil {
t.Fatalf("save accounts.json: %v", err)
}
// Create ~/.claude symlink pointing to account1 (current account)
claudeDir := filepath.Join(fakeHome, ".claude")
if err := os.Symlink(account1Dir, claudeDir); err != nil {
t.Fatalf("symlink .claude: %v", err)
}
// Set up HOME env var
oldHome := os.Getenv("HOME")
os.Setenv("HOME", fakeHome)
cleanup = func() {
os.Setenv("HOME", oldHome)
}
return townRoot, fakeHome, cleanup
}
// createTestSession creates a mock session file and index entry.
func createTestSession(t *testing.T, configDir, projectName, sessionID string) {
t.Helper()
projectDir := filepath.Join(configDir, "projects", projectName)
if err := os.MkdirAll(projectDir, 0755); err != nil {
t.Fatalf("mkdir project: %v", err)
}
// Create session file
sessionFile := filepath.Join(projectDir, sessionID+".jsonl")
if err := os.WriteFile(sessionFile, []byte(`{"type":"test"}`), 0600); err != nil {
t.Fatalf("write session file: %v", err)
}
// Create or update sessions-index.json
indexPath := filepath.Join(projectDir, "sessions-index.json")
var index sessionsIndex
if data, err := os.ReadFile(indexPath); err == nil {
_ = json.Unmarshal(data, &index)
} else {
index.Version = 1
}
// Add session entry
entry := map[string]interface{}{
"sessionId": sessionID,
"name": "Test Session",
"lastAccessed": "2026-01-22T00:00:00Z",
}
entryJSON, _ := json.Marshal(entry)
index.Entries = append(index.Entries, entryJSON)
indexData, _ := json.MarshalIndent(index, "", " ")
if err := os.WriteFile(indexPath, indexData, 0600); err != nil {
t.Fatalf("write sessions-index.json: %v", err)
}
}
func TestFindSessionLocation(t *testing.T) {
t.Run("finds session in account1", func(t *testing.T) {
townRoot, fakeHome, cleanup := setupSeanceTestEnv(t)
defer cleanup()
account1Dir := filepath.Join(fakeHome, "claude-config-account1")
createTestSession(t, account1Dir, "test-project", "session-abc123")
loc := findSessionLocation(townRoot, "session-abc123")
if loc == nil {
t.Fatal("expected to find session, got nil")
}
if loc.configDir != account1Dir {
t.Errorf("expected configDir %s, got %s", account1Dir, loc.configDir)
}
if loc.projectDir != "test-project" {
t.Errorf("expected projectDir test-project, got %s", loc.projectDir)
}
})
t.Run("finds session in account2", func(t *testing.T) {
townRoot, fakeHome, cleanup := setupSeanceTestEnv(t)
defer cleanup()
account2Dir := filepath.Join(fakeHome, "claude-config-account2")
createTestSession(t, account2Dir, "other-project", "session-xyz789")
loc := findSessionLocation(townRoot, "session-xyz789")
if loc == nil {
t.Fatal("expected to find session, got nil")
}
if loc.configDir != account2Dir {
t.Errorf("expected configDir %s, got %s", account2Dir, loc.configDir)
}
if loc.projectDir != "other-project" {
t.Errorf("expected projectDir other-project, got %s", loc.projectDir)
}
})
t.Run("returns nil for nonexistent session", func(t *testing.T) {
townRoot, _, cleanup := setupSeanceTestEnv(t)
defer cleanup()
loc := findSessionLocation(townRoot, "session-notfound")
if loc != nil {
t.Errorf("expected nil for nonexistent session, got %+v", loc)
}
})
t.Run("returns nil for empty townRoot", func(t *testing.T) {
loc := findSessionLocation("", "session-abc")
if loc != nil {
t.Errorf("expected nil for empty townRoot, got %+v", loc)
}
})
}
func TestSymlinkSessionToCurrentAccount(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink tests require elevated privileges on Windows")
}
t.Run("creates symlink for session in other account", func(t *testing.T) {
townRoot, fakeHome, cleanup := setupSeanceTestEnv(t)
defer cleanup()
// Create session in account2 (not the current account)
account2Dir := filepath.Join(fakeHome, "claude-config-account2")
createTestSession(t, account2Dir, "cross-project", "session-cross123")
// Call symlinkSessionToCurrentAccount
cleanupFn, err := symlinkSessionToCurrentAccount(townRoot, "session-cross123")
if err != nil {
t.Fatalf("symlinkSessionToCurrentAccount failed: %v", err)
}
if cleanupFn == nil {
t.Fatal("expected cleanup function, got nil")
}
// Verify symlink was created in current account (account1)
account1Dir := filepath.Join(fakeHome, "claude-config-account1")
symlinkPath := filepath.Join(account1Dir, "projects", "cross-project", "session-cross123.jsonl")
info, err := os.Lstat(symlinkPath)
if err != nil {
t.Fatalf("symlink not found: %v", err)
}
if info.Mode()&os.ModeSymlink == 0 {
t.Error("expected symlink, got regular file")
}
// Verify sessions-index.json was updated
indexPath := filepath.Join(account1Dir, "projects", "cross-project", "sessions-index.json")
data, err := os.ReadFile(indexPath)
if err != nil {
t.Fatalf("reading index: %v", err)
}
var index sessionsIndex
if err := json.Unmarshal(data, &index); err != nil {
t.Fatalf("parsing index: %v", err)
}
found := false
for _, entry := range index.Entries {
var e sessionsIndexEntry
if json.Unmarshal(entry, &e) == nil && e.SessionID == "session-cross123" {
found = true
break
}
}
if !found {
t.Error("session not found in target index")
}
// Test cleanup
cleanupFn()
// Verify symlink was removed
if _, err := os.Lstat(symlinkPath); !os.IsNotExist(err) {
t.Error("symlink should have been removed after cleanup")
}
// Verify session was removed from index
data, _ = os.ReadFile(indexPath)
_ = json.Unmarshal(data, &index)
for _, entry := range index.Entries {
var e sessionsIndexEntry
if json.Unmarshal(entry, &e) == nil && e.SessionID == "session-cross123" {
t.Error("session should have been removed from index after cleanup")
}
}
})
t.Run("returns nil cleanup for session in current account", func(t *testing.T) {
townRoot, fakeHome, cleanup := setupSeanceTestEnv(t)
defer cleanup()
// Create session in account1 (the current account)
account1Dir := filepath.Join(fakeHome, "claude-config-account1")
createTestSession(t, account1Dir, "local-project", "session-local456")
cleanupFn, err := symlinkSessionToCurrentAccount(townRoot, "session-local456")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cleanupFn != nil {
t.Error("expected nil cleanup for session in current account")
}
})
t.Run("returns error for nonexistent session", func(t *testing.T) {
townRoot, _, cleanup := setupSeanceTestEnv(t)
defer cleanup()
_, err := symlinkSessionToCurrentAccount(townRoot, "session-notfound")
if err == nil {
t.Error("expected error for nonexistent session")
}
})
}
func TestCleanupOrphanedSessionSymlinks(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink tests require elevated privileges on Windows")
}
t.Run("removes orphaned symlinks", func(t *testing.T) {
_, fakeHome, cleanup := setupSeanceTestEnv(t)
defer cleanup()
account1Dir := filepath.Join(fakeHome, "claude-config-account1")
projectDir := filepath.Join(account1Dir, "projects", "orphan-project")
if err := os.MkdirAll(projectDir, 0755); err != nil {
t.Fatalf("mkdir project: %v", err)
}
// Create an orphaned symlink (target doesn't exist)
orphanSymlink := filepath.Join(projectDir, "orphan-session.jsonl")
nonexistentTarget := filepath.Join(fakeHome, "nonexistent", "session.jsonl")
if err := os.Symlink(nonexistentTarget, orphanSymlink); err != nil {
t.Fatalf("create orphan symlink: %v", err)
}
// Create a sessions-index.json with the orphaned entry
index := sessionsIndex{
Version: 1,
Entries: []json.RawMessage{
json.RawMessage(`{"sessionId":"orphan-session","name":"Orphan"}`),
},
}
indexPath := filepath.Join(projectDir, "sessions-index.json")
data, _ := json.MarshalIndent(index, "", " ")
if err := os.WriteFile(indexPath, data, 0600); err != nil {
t.Fatalf("write index: %v", err)
}
// Run cleanup
cleanupOrphanedSessionSymlinks()
// Verify orphan symlink was removed
if _, err := os.Lstat(orphanSymlink); !os.IsNotExist(err) {
t.Error("orphaned symlink should have been removed")
}
// Verify entry was removed from index
data, _ = os.ReadFile(indexPath)
var updatedIndex sessionsIndex
_ = json.Unmarshal(data, &updatedIndex)
if len(updatedIndex.Entries) != 0 {
t.Errorf("expected 0 entries after cleanup, got %d", len(updatedIndex.Entries))
}
})
t.Run("preserves valid symlinks", func(t *testing.T) {
_, fakeHome, cleanup := setupSeanceTestEnv(t)
defer cleanup()
account1Dir := filepath.Join(fakeHome, "claude-config-account1")
account2Dir := filepath.Join(fakeHome, "claude-config-account2")
// Create a real session in account2
createTestSession(t, account2Dir, "valid-project", "valid-session")
// Create project dir in account1
projectDir := filepath.Join(account1Dir, "projects", "valid-project")
if err := os.MkdirAll(projectDir, 0755); err != nil {
t.Fatalf("mkdir project: %v", err)
}
// Create a valid symlink pointing to the real session
validSymlink := filepath.Join(projectDir, "valid-session.jsonl")
realTarget := filepath.Join(account2Dir, "projects", "valid-project", "valid-session.jsonl")
if err := os.Symlink(realTarget, validSymlink); err != nil {
t.Fatalf("create valid symlink: %v", err)
}
// Create index with valid entry
index := sessionsIndex{
Version: 1,
Entries: []json.RawMessage{
json.RawMessage(`{"sessionId":"valid-session","name":"Valid"}`),
},
}
indexPath := filepath.Join(projectDir, "sessions-index.json")
data, _ := json.MarshalIndent(index, "", " ")
if err := os.WriteFile(indexPath, data, 0600); err != nil {
t.Fatalf("write index: %v", err)
}
// Run cleanup
cleanupOrphanedSessionSymlinks()
// Verify valid symlink was preserved
if _, err := os.Lstat(validSymlink); err != nil {
t.Error("valid symlink should have been preserved")
}
// Verify entry was preserved in index
data, _ = os.ReadFile(indexPath)
var updatedIndex sessionsIndex
_ = json.Unmarshal(data, &updatedIndex)
if len(updatedIndex.Entries) != 1 {
t.Errorf("expected 1 entry preserved, got %d", len(updatedIndex.Entries))
}
})
}