package cmd import ( "bytes" "os" "path/filepath" "strings" "testing" "time" "github.com/warrenronsiek/ctask/internal/session" "github.com/warrenronsiek/ctask/internal/workspace" ) // This file swaps process-global os.Stdin, os.Stdout, and env vars. Do not // call t.Parallel() in this file. func makeArchiveWs(t *testing.T, root, category, dirName string) string { t.Helper() dir := filepath.Join(root, category, dirName) os.MkdirAll(dir, 0755) now := time.Now().UTC().Truncate(time.Second) slug := dirName[11:] meta := &workspace.TaskMeta{ ID: "test", Slug: slug, Title: slug, CreatedAt: now, UpdatedAt: now, Status: "active", Category: category, Type: "task", Mode: "local", Agent: workspace.AgentSpec{Type: "claude"}, } workspace.WriteMeta(filepath.Join(dir, "task.yaml"), meta) return dir } // writeTestLease drops a lease file at wsDir/.ctask/session.json with the // given heartbeat age relative to now. func writeTestLease(t *testing.T, wsDir string, heartbeatAge time.Duration) { t.Helper() now := time.Now().UTC() l := &session.Lease{ SessionID: "test-session", PID: 1234, Hostname: "test-host", Username: "tester", Agent: "claude", Mode: "local", StartedAt: now.Add(-heartbeatAge - time.Minute), LastHeartbeatAt: now.Add(-heartbeatAge), Terminal: "test", } if err := session.WriteLease(session.LeasePath(wsDir), l); err != nil { t.Fatalf("WriteLease: %v", err) } } // callArchive invokes runArchive with a captured (non-TTY) stdin and stdout. func callArchive(t *testing.T, root, query, stdinInput string) (string, error) { t.Helper() prevRoot := os.Getenv("CTASK_ROOT") os.Setenv("CTASK_ROOT", root) defer func() { if prevRoot == "" { os.Unsetenv("CTASK_ROOT") } else { os.Setenv("CTASK_ROOT", prevRoot) } }() stdinR, stdinW, _ := os.Pipe() go func() { stdinW.WriteString(stdinInput) stdinW.Close() }() prevStdin := os.Stdin os.Stdin = stdinR defer func() { os.Stdin = prevStdin }() stdoutR, stdoutW, _ := os.Pipe() prevStdout := os.Stdout os.Stdout = stdoutW defer func() { os.Stdout = prevStdout }() err := runArchive(archiveCmd, []string{query}) stdoutW.Close() var buf bytes.Buffer buf.ReadFrom(stdoutR) return buf.String(), err } func TestArchiveWithNoActiveSession(t *testing.T) { root := t.TempDir() makeArchiveWs(t, root, "general", "2026-04-22_no-session") out, err := callArchive(t, root, "no-session", "") if err != nil { t.Fatalf("archive should succeed with no lease, got error: %v", err) } if !strings.Contains(out, "archived") { t.Errorf("expected archived confirmation in output, got: %s", out) } if strings.Contains(out, "active session") { t.Errorf("should not warn when no lease exists: %s", out) } } func TestArchiveWithStaleLeaseProceedsSilently(t *testing.T) { root := t.TempDir() wsDir := makeArchiveWs(t, root, "general", "2026-04-22_stale-session") writeTestLease(t, wsDir, 5*time.Minute) // older than 60s => stale out, err := callArchive(t, root, "stale-session", "") if err != nil { t.Fatalf("archive should succeed with stale lease, got: %v", err) } if strings.Contains(out, "active session") { t.Errorf("stale lease should not trigger active-session warning: %s", out) } } func TestArchiveWithFreshLeaseNonTTYRefuses(t *testing.T) { // Per spec amendment: non-TTY stdin + fresh lease => refuse with error. root := t.TempDir() wsDir := makeArchiveWs(t, root, "general", "2026-04-22_fresh-session") writeTestLease(t, wsDir, 5*time.Second) // well under 60s => fresh out, err := callArchive(t, root, "fresh-session", "") if err == nil { t.Fatal("expected error refusing to archive active session on non-TTY stdin") } if !strings.Contains(out, "active session") { t.Errorf("expected active-session warning in output, got: %s", out) } // Workspace must still be active (not archived). ws, qerr := workspace.ResolveQuery([]string{root}, "fresh-session", true) if qerr != nil || len(ws) == 0 { t.Fatalf("ResolveQuery: %v, len=%d", qerr, len(ws)) } if ws[0].Meta.Status == "archived" { t.Errorf("workspace was archived despite refusal: status=%s", ws[0].Meta.Status) } }