feat: v0.2 tests for manifest capture, diff, ignore rules, and session log
11 new tests covering manifest capture/exclusion, roundtrip, diff logic, ignore rules (task.yaml, sessions.log, .ctask/), notes updated detection, session log formatting, append-only behavior, short session detection. Fix cross-platform ignore rule for logs/sessions.log. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFormatDuration(t *testing.T) {
|
||||
tests := []struct {
|
||||
d time.Duration
|
||||
want string
|
||||
}{
|
||||
{5 * time.Second, "5s"},
|
||||
{65 * time.Second, "1m 5s"},
|
||||
{3661 * time.Second, "1h 1m 1s"},
|
||||
{0, "0s"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := FormatDuration(tt.d)
|
||||
if got != tt.want {
|
||||
t.Errorf("FormatDuration(%v) = %q, want %q", tt.d, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatSessionEntry(t *testing.T) {
|
||||
start := time.Date(2026, 4, 5, 14, 30, 22, 0, time.UTC)
|
||||
end := time.Date(2026, 4, 5, 15, 45, 10, 0, time.UTC)
|
||||
|
||||
info := &SessionInfo{
|
||||
Agent: "claude",
|
||||
Mode: "local",
|
||||
StartTime: start,
|
||||
EndTime: end,
|
||||
Diff: &ManifestDiff{
|
||||
Added: []string{"output/migration-plan.md", "output/schema.sql"},
|
||||
Modified: []string{"notes.md", "CLAUDE.md"},
|
||||
Deleted: nil,
|
||||
},
|
||||
}
|
||||
|
||||
entry := FormatSessionEntry(info)
|
||||
|
||||
// Check required fields
|
||||
checks := []string{
|
||||
"Session 2026-04-05 14:30:22",
|
||||
"Agent: claude",
|
||||
"Mode: local",
|
||||
"Start: 2026-04-05 14:30:22",
|
||||
"End: 2026-04-05 15:45:10",
|
||||
"Duration: 1h 14m 48s",
|
||||
"output/migration-plan.md",
|
||||
"output/schema.sql",
|
||||
"notes.md",
|
||||
"CLAUDE.md",
|
||||
"Notes updated: yes",
|
||||
}
|
||||
for _, check := range checks {
|
||||
if !strings.Contains(entry, check) {
|
||||
t.Errorf("entry missing %q", check)
|
||||
}
|
||||
}
|
||||
|
||||
// Deleted should show (none)
|
||||
if !strings.Contains(entry, "Deleted:\n (none)") {
|
||||
t.Error("expected '(none)' for empty deleted section")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatSessionEntryShortNoChanges(t *testing.T) {
|
||||
start := time.Date(2026, 4, 5, 14, 30, 22, 0, time.UTC)
|
||||
end := start.Add(2 * time.Second)
|
||||
|
||||
info := &SessionInfo{
|
||||
Agent: "claude",
|
||||
Mode: "local",
|
||||
StartTime: start,
|
||||
EndTime: end,
|
||||
Diff: &ManifestDiff{},
|
||||
}
|
||||
|
||||
entry := FormatSessionEntry(info)
|
||||
if !strings.Contains(entry, "No changes detected") {
|
||||
t.Error("short session with no changes should have 'No changes detected' note")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendSessionLog(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
os.MkdirAll(filepath.Join(dir, "logs"), 0755)
|
||||
|
||||
start := time.Date(2026, 4, 5, 14, 30, 22, 0, time.UTC)
|
||||
end := time.Date(2026, 4, 5, 15, 0, 0, 0, time.UTC)
|
||||
|
||||
info := &SessionInfo{
|
||||
Agent: "claude",
|
||||
Mode: "local",
|
||||
StartTime: start,
|
||||
EndTime: end,
|
||||
Diff: &ManifestDiff{
|
||||
Added: []string{"output/result.md"},
|
||||
},
|
||||
}
|
||||
|
||||
if err := AppendSessionLog(dir, info); err != nil {
|
||||
t.Fatalf("AppendSessionLog: %v", err)
|
||||
}
|
||||
|
||||
// Append a second entry
|
||||
info2 := &SessionInfo{
|
||||
Agent: "shell",
|
||||
Mode: "local",
|
||||
StartTime: end,
|
||||
EndTime: end.Add(time.Hour),
|
||||
Diff: &ManifestDiff{},
|
||||
}
|
||||
|
||||
if err := AppendSessionLog(dir, info2); err != nil {
|
||||
t.Fatalf("AppendSessionLog 2: %v", err)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(filepath.Join(dir, "logs", "sessions.log"))
|
||||
content := string(data)
|
||||
|
||||
// Should contain both sessions
|
||||
if strings.Count(content, "── Session") != 2 {
|
||||
t.Errorf("expected 2 session entries, got %d", strings.Count(content, "── Session"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestCleanupOnSuccess(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
os.MkdirAll(filepath.Join(dir, ".ctask"), 0755)
|
||||
os.MkdirAll(filepath.Join(dir, "logs"), 0755)
|
||||
|
||||
mPath := filepath.Join(dir, ".ctask", "manifest-start.json")
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
m := &Manifest{
|
||||
CapturedAt: now,
|
||||
Files: []FileEntry{{Path: "notes.md", Size: 100, Mtime: now}},
|
||||
}
|
||||
WriteManifest(mPath, m)
|
||||
|
||||
// Simulate successful logging
|
||||
info := &SessionInfo{
|
||||
Agent: "claude",
|
||||
Mode: "local",
|
||||
StartTime: now,
|
||||
EndTime: now.Add(time.Minute),
|
||||
Diff: &ManifestDiff{},
|
||||
}
|
||||
|
||||
err := AppendSessionLog(dir, info)
|
||||
if err != nil {
|
||||
t.Fatalf("AppendSessionLog: %v", err)
|
||||
}
|
||||
|
||||
// Clean up manifest (as session.Run does)
|
||||
os.Remove(mPath)
|
||||
|
||||
if _, err := os.Stat(mPath); !os.IsNotExist(err) {
|
||||
t.Error("manifest should be cleaned up after successful logging")
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ func ignoredPath(relPath string) bool {
|
||||
return true
|
||||
}
|
||||
// Ignore sessions.log (ctask's own logging)
|
||||
if relPath == filepath.Join("logs", "sessions.log") {
|
||||
if relPath == "logs/sessions.log" || relPath == "logs\\sessions.log" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCaptureManifest(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Create some files
|
||||
os.WriteFile(filepath.Join(dir, "notes.md"), []byte("hello"), 0644)
|
||||
os.WriteFile(filepath.Join(dir, "task.yaml"), []byte("meta"), 0644)
|
||||
os.MkdirAll(filepath.Join(dir, "output"), 0755)
|
||||
os.WriteFile(filepath.Join(dir, "output", "result.txt"), []byte("result"), 0644)
|
||||
|
||||
m, err := CaptureManifest(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("CaptureManifest: %v", err)
|
||||
}
|
||||
|
||||
if len(m.Files) != 3 {
|
||||
t.Errorf("expected 3 files, got %d", len(m.Files))
|
||||
}
|
||||
|
||||
// Verify paths are forward-slash normalized
|
||||
for _, f := range m.Files {
|
||||
if filepath.IsAbs(f.Path) {
|
||||
t.Errorf("expected relative path, got %q", f.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureManifestExcludesCtaskDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
os.WriteFile(filepath.Join(dir, "notes.md"), []byte("hello"), 0644)
|
||||
os.MkdirAll(filepath.Join(dir, ".ctask"), 0755)
|
||||
os.WriteFile(filepath.Join(dir, ".ctask", "manifest-start.json"), []byte("{}"), 0644)
|
||||
|
||||
m, err := CaptureManifest(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("CaptureManifest: %v", err)
|
||||
}
|
||||
|
||||
for _, f := range m.Files {
|
||||
if f.Path == ".ctask/manifest-start.json" {
|
||||
t.Error(".ctask/ contents should be excluded from manifest")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteAndReadManifest(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, ".ctask", "manifest-start.json")
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
m := &Manifest{
|
||||
CapturedAt: now,
|
||||
Files: []FileEntry{
|
||||
{Path: "notes.md", Size: 100, Mtime: now},
|
||||
},
|
||||
}
|
||||
|
||||
if err := WriteManifest(path, m); err != nil {
|
||||
t.Fatalf("WriteManifest: %v", err)
|
||||
}
|
||||
|
||||
got, err := ReadManifest(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadManifest: %v", err)
|
||||
}
|
||||
|
||||
if len(got.Files) != 1 {
|
||||
t.Fatalf("expected 1 file, got %d", len(got.Files))
|
||||
}
|
||||
if got.Files[0].Path != "notes.md" {
|
||||
t.Errorf("path: got %q", got.Files[0].Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffManifests(t *testing.T) {
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
later := now.Add(time.Minute)
|
||||
|
||||
start := &Manifest{
|
||||
CapturedAt: now,
|
||||
Files: []FileEntry{
|
||||
{Path: "notes.md", Size: 100, Mtime: now},
|
||||
{Path: "CLAUDE.md", Size: 200, Mtime: now},
|
||||
{Path: "old-file.txt", Size: 50, Mtime: now},
|
||||
},
|
||||
}
|
||||
|
||||
end := &Manifest{
|
||||
CapturedAt: later,
|
||||
Files: []FileEntry{
|
||||
{Path: "notes.md", Size: 150, Mtime: later}, // modified (size + mtime)
|
||||
{Path: "CLAUDE.md", Size: 200, Mtime: now}, // unchanged
|
||||
{Path: "output/result.md", Size: 300, Mtime: later}, // added
|
||||
},
|
||||
}
|
||||
|
||||
diff := DiffManifests(start, end)
|
||||
|
||||
if len(diff.Added) != 1 || diff.Added[0] != "output/result.md" {
|
||||
t.Errorf("Added: got %v, want [output/result.md]", diff.Added)
|
||||
}
|
||||
|
||||
if len(diff.Modified) != 1 || diff.Modified[0] != "notes.md" {
|
||||
t.Errorf("Modified: got %v, want [notes.md]", diff.Modified)
|
||||
}
|
||||
|
||||
if len(diff.Deleted) != 1 || diff.Deleted[0] != "old-file.txt" {
|
||||
t.Errorf("Deleted: got %v, want [old-file.txt]", diff.Deleted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffManifestsIgnoreRules(t *testing.T) {
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
later := now.Add(time.Minute)
|
||||
|
||||
start := &Manifest{
|
||||
CapturedAt: now,
|
||||
Files: []FileEntry{
|
||||
{Path: "notes.md", Size: 100, Mtime: now},
|
||||
{Path: "task.yaml", Size: 200, Mtime: now},
|
||||
{Path: "logs/sessions.log", Size: 50, Mtime: now},
|
||||
{Path: ".ctask/manifest-start.json", Size: 300, Mtime: now},
|
||||
},
|
||||
}
|
||||
|
||||
end := &Manifest{
|
||||
CapturedAt: later,
|
||||
Files: []FileEntry{
|
||||
{Path: "notes.md", Size: 100, Mtime: now}, // unchanged
|
||||
{Path: "task.yaml", Size: 250, Mtime: later}, // changed but ignored
|
||||
{Path: "logs/sessions.log", Size: 100, Mtime: later}, // changed but ignored
|
||||
{Path: ".ctask/manifest-start.json", Size: 300, Mtime: now}, // ignored
|
||||
},
|
||||
}
|
||||
|
||||
diff := DiffManifests(start, end)
|
||||
|
||||
if len(diff.Added) != 0 {
|
||||
t.Errorf("Added should be empty (ignore rules), got %v", diff.Added)
|
||||
}
|
||||
if len(diff.Modified) != 0 {
|
||||
t.Errorf("Modified should be empty (ignore rules), got %v", diff.Modified)
|
||||
}
|
||||
if len(diff.Deleted) != 0 {
|
||||
t.Errorf("Deleted should be empty (ignore rules), got %v", diff.Deleted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotesUpdated(t *testing.T) {
|
||||
diff := &ManifestDiff{
|
||||
Modified: []string{"CLAUDE.md", "notes.md"},
|
||||
}
|
||||
if !NotesUpdated(diff) {
|
||||
t.Error("expected NotesUpdated=true when notes.md in Modified")
|
||||
}
|
||||
|
||||
diff2 := &ManifestDiff{
|
||||
Modified: []string{"CLAUDE.md"},
|
||||
}
|
||||
if NotesUpdated(diff2) {
|
||||
t.Error("expected NotesUpdated=false when notes.md not changed")
|
||||
}
|
||||
|
||||
diff3 := &ManifestDiff{
|
||||
Added: []string{"notes.md"},
|
||||
}
|
||||
if !NotesUpdated(diff3) {
|
||||
t.Error("expected NotesUpdated=true when notes.md in Added")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user