package main import ( "os" "path/filepath" "strings" "testing" "time" ) // ── repoID ────────────────────────────────────────────────────── func TestRepoID(t *testing.T) { id1 := repoID("/tmp/test-repo") id2 := repoID("/tmp/test-repo") if id1 != id2 { t.Errorf("repoID not deterministic: %s != %s", id1, id2) } if len(id1) != 12 { t.Errorf("repoID wrong length: got %d, want 12", len(id1)) } } func TestRepoIDDifferentPaths(t *testing.T) { id1 := repoID("/tmp/repo-a") id2 := repoID("/tmp/repo-b") if id1 == id2 { t.Errorf("different paths produced same repoID: %s", id1) } } func TestRepoIDEmpty(t *testing.T) { id := repoID("") if id == "" { t.Error("repoID returned empty string for empty input") } if len(id) != 12 { t.Errorf("repoID wrong length for empty input: got %d, want 12", len(id)) } } // ── socketPath ────────────────────────────────────────────────── func TestSocketPath(t *testing.T) { path := socketPath("/tmp/test-repo") if path == "" { t.Error("socketPath returned empty string") } id := repoID("/tmp/test-repo") if !strings.Contains(path, id) { t.Errorf("socketPath %q does not contain repoID %q", path, id) } if !strings.HasPrefix(path, "/tmp/") { t.Errorf("socketPath %q does not start with /tmp/", path) } if !strings.HasSuffix(path, ".sock") { t.Errorf("socketPath %q does not end with .sock", path) } } // ── Session lifecycle ─────────────────────────────────────────── func newTestDaemon() *Daemon { return &Daemon{ sessions: make(map[string]*Session), done: make(chan struct{}), } } func TestSessionStart(t *testing.T) { d := newTestDaemon() resp := d.sessionStart(Event{ Event: "session-start", SessionID: "test123", RepoRoot: "/tmp/test", PID: 12345, }) if !resp.OK { t.Fatal("sessionStart returned not OK") } if len(d.sessions) != 1 { t.Fatalf("expected 1 session, got %d", len(d.sessions)) } sess := d.sessions["test123"] if sess.ID != "test123" { t.Errorf("session ID = %q, want %q", sess.ID, "test123") } if sess.RepoRoot != "/tmp/test" { t.Errorf("session RepoRoot = %q, want %q", sess.RepoRoot, "/tmp/test") } if sess.PID != 12345 { t.Errorf("session PID = %d, want %d", sess.PID, 12345) } if sess.Dirty { t.Error("new session should not be dirty") } if len(sess.Files) != 0 { t.Errorf("new session should have 0 files, got %d", len(sess.Files)) } } func TestSessionStartResets(t *testing.T) { d := newTestDaemon() d.sessionStart(Event{SessionID: "s1", RepoRoot: "/tmp/a", PID: 100}) d.postEdit(Event{SessionID: "s1", File: "/tmp/a/foo.ex", RepoRoot: "/tmp/a"}) if d.sessions["s1"].Timer != nil { d.sessions["s1"].Timer.Stop() } // Re-start same session — should reset d.sessionStart(Event{SessionID: "s1", RepoRoot: "/tmp/b", PID: 200}) sess := d.sessions["s1"] if sess.RepoRoot != "/tmp/b" { t.Errorf("expected RepoRoot /tmp/b after reset, got %s", sess.RepoRoot) } if sess.PID != 200 { t.Errorf("expected PID 200 after reset, got %d", sess.PID) } if len(sess.Files) != 0 { t.Errorf("expected 0 files after reset, got %d", len(sess.Files)) } } // ── Session inventory on start ────────────────────────────────── func TestSessionStartReturnsOtherSessions(t *testing.T) { d := newTestDaemon() d.sessionStart(Event{SessionID: "s1", RepoRoot: "/tmp/r", PID: 100}) d.postEdit(Event{SessionID: "s1", File: "/tmp/r/a.ex", RepoRoot: "/tmp/r"}) if d.sessions["s1"].Timer != nil { d.sessions["s1"].Timer.Stop() } resp := d.sessionStart(Event{SessionID: "s2", RepoRoot: "/tmp/r", PID: 200}) if !resp.OK { t.Fatal("sessionStart returned not OK") } if len(resp.Sessions) != 1 { t.Fatalf("expected 1 other session, got %d", len(resp.Sessions)) } if resp.Sessions[0].ID != "s1" { t.Errorf("expected other session id=s1, got %s", resp.Sessions[0].ID) } if len(resp.Sessions[0].Files) != 1 { t.Errorf("expected 1 file in s1, got %d", len(resp.Sessions[0].Files)) } } func TestSessionStartNoOtherSessions(t *testing.T) { d := newTestDaemon() resp := d.sessionStart(Event{SessionID: "s1", RepoRoot: "/tmp/r", PID: 100}) if len(resp.Sessions) != 0 { t.Errorf("expected 0 other sessions for first start, got %d", len(resp.Sessions)) } } // ── postEdit ──────────────────────────────────────────────────── func TestPostEdit(t *testing.T) { d := newTestDaemon() d.sessionStart(Event{SessionID: "test123", RepoRoot: "/tmp/test"}) resp := d.postEdit(Event{ Event: "post-edit", SessionID: "test123", File: "/tmp/test/foo.ex", RepoRoot: "/tmp/test", }) if !resp.OK { t.Fatal("postEdit returned not OK") } sess := d.sessions["test123"] if len(sess.Files) != 1 { t.Fatalf("expected 1 file, got %d", len(sess.Files)) } if !sess.Files["foo.ex"] { t.Error("expected foo.ex to be tracked (relative path)") } if !sess.Dirty { t.Error("expected session to be dirty after post-edit") } if sess.Timer == nil { t.Error("expected debounce timer to be set") } sess.Timer.Stop() } func TestPostEditAutoCreatesSession(t *testing.T) { d := newTestDaemon() resp := d.postEdit(Event{ Event: "post-edit", SessionID: "auto123", File: "/tmp/test/bar.ex", RepoRoot: "/tmp/test", }) if !resp.OK { t.Fatal("postEdit returned not OK") } sess, exists := d.sessions["auto123"] if !exists { t.Fatal("expected session to be auto-created") } if !sess.Files["bar.ex"] { t.Error("expected bar.ex to be tracked") } if sess.Timer != nil { sess.Timer.Stop() } } func TestPostEditDeduplicatesFiles(t *testing.T) { d := newTestDaemon() d.sessionStart(Event{SessionID: "dedup", RepoRoot: "/tmp/test"}) for i := 0; i < 5; i++ { d.postEdit(Event{SessionID: "dedup", File: "/tmp/test/same.ex", RepoRoot: "/tmp/test"}) } if len(d.sessions["dedup"].Files) != 1 { t.Errorf("expected 1 file after dedup, got %d", len(d.sessions["dedup"].Files)) } if d.sessions["dedup"].Timer != nil { d.sessions["dedup"].Timer.Stop() } } func TestPostEditMultipleFiles(t *testing.T) { d := newTestDaemon() d.sessionStart(Event{SessionID: "multi", RepoRoot: "/tmp/test"}) files := []string{"/tmp/test/a.ex", "/tmp/test/b.ex", "/tmp/test/c.ex"} for _, f := range files { d.postEdit(Event{SessionID: "multi", File: f, RepoRoot: "/tmp/test"}) } sess := d.sessions["multi"] if len(sess.Files) != 3 { t.Errorf("expected 3 files, got %d", len(sess.Files)) } for _, name := range []string{"a.ex", "b.ex", "c.ex"} { if !sess.Files[name] { t.Errorf("expected %s to be tracked", name) } } if sess.Timer != nil { sess.Timer.Stop() } } func TestPostEditRelativePath(t *testing.T) { d := newTestDaemon() d.sessionStart(Event{SessionID: "rel", RepoRoot: "/tmp/test"}) // File outside repo root should be kept as-is d.postEdit(Event{SessionID: "rel", File: "/other/path/file.ex", RepoRoot: "/tmp/test"}) sess := d.sessions["rel"] if !sess.Files["/other/path/file.ex"] { t.Error("file outside repo root should be stored as absolute path") } if sess.Timer != nil { sess.Timer.Stop() } } // ── Cross-session conflict detection ──────────────────────────── func TestPostEditDetectsConflict(t *testing.T) { d := newTestDaemon() d.sessionStart(Event{SessionID: "s1", RepoRoot: "/tmp/r", PID: 100}) d.postEdit(Event{SessionID: "s1", File: "/tmp/r/shared.ex", RepoRoot: "/tmp/r"}) if d.sessions["s1"].Timer != nil { d.sessions["s1"].Timer.Stop() } d.sessionStart(Event{SessionID: "s2", RepoRoot: "/tmp/r", PID: 200}) resp := d.postEdit(Event{SessionID: "s2", File: "/tmp/r/shared.ex", RepoRoot: "/tmp/r"}) if !resp.OK { t.Fatal("postEdit should succeed even with conflict") } if len(resp.Conflicts) != 1 { t.Fatalf("expected 1 conflict, got %d", len(resp.Conflicts)) } if resp.Conflicts[0].SessionID != "s1" { t.Errorf("conflict session should be s1, got %s", resp.Conflicts[0].SessionID) } if resp.Conflicts[0].PID != 100 { t.Errorf("conflict PID should be 100, got %d", resp.Conflicts[0].PID) } if resp.Conflicts[0].File != "shared.ex" { t.Errorf("conflict file should be shared.ex, got %s", resp.Conflicts[0].File) } if d.sessions["s2"].Timer != nil { d.sessions["s2"].Timer.Stop() } } func TestPostEditNoConflictDifferentFiles(t *testing.T) { d := newTestDaemon() d.sessionStart(Event{SessionID: "s1", RepoRoot: "/tmp/r", PID: 100}) d.postEdit(Event{SessionID: "s1", File: "/tmp/r/a.ex", RepoRoot: "/tmp/r"}) if d.sessions["s1"].Timer != nil { d.sessions["s1"].Timer.Stop() } d.sessionStart(Event{SessionID: "s2", RepoRoot: "/tmp/r", PID: 200}) resp := d.postEdit(Event{SessionID: "s2", File: "/tmp/r/b.ex", RepoRoot: "/tmp/r"}) if len(resp.Conflicts) != 0 { t.Errorf("expected 0 conflicts for different files, got %d", len(resp.Conflicts)) } if d.sessions["s2"].Timer != nil { d.sessions["s2"].Timer.Stop() } } // ── sessionEnd ────────────────────────────────────────────────── func TestSessionEndUnknownSession(t *testing.T) { d := newTestDaemon() ok := d.sessionEnd(Event{SessionID: "nonexistent"}) if ok { t.Error("sessionEnd should return false for unknown session") } } func TestSessionEndRemovesSession(t *testing.T) { d := newTestDaemon() d.sessionStart(Event{SessionID: "s1", RepoRoot: "/tmp/test"}) // End the session (commitSession will be a no-op since nothing is dirty) d.sessionEnd(Event{SessionID: "s1"}) if _, exists := d.sessions["s1"]; exists { t.Error("expected session to be removed after session-end") } } // ── handleEvent dispatch ──────────────────────────────────────── func TestHandleEventUnknown(t *testing.T) { d := newTestDaemon() resp := d.handleEvent(Event{Event: "bogus"}) if resp.OK { t.Error("handleEvent should return not OK for unknown event type") } } func TestHandleEventStatus(t *testing.T) { d := newTestDaemon() resp := d.handleEvent(Event{Event: "status"}) if !resp.OK { t.Error("handleEvent should return OK for status event") } } // ── Debounce timer ────────────────────────────────────────────── func TestDebounceTimerResetsOnSubsequentEdits(t *testing.T) { d := newTestDaemon() d.sessionStart(Event{SessionID: "debounce", RepoRoot: "/tmp/test"}) d.postEdit(Event{SessionID: "debounce", File: "/tmp/test/a.ex", RepoRoot: "/tmp/test"}) timer1 := d.sessions["debounce"].Timer d.postEdit(Event{SessionID: "debounce", File: "/tmp/test/b.ex", RepoRoot: "/tmp/test"}) timer2 := d.sessions["debounce"].Timer // Timer should have been replaced if timer1 == timer2 { t.Error("expected debounce timer to be replaced on subsequent edit") } timer2.Stop() } func TestDebounceTimerSetsDirtyFlag(t *testing.T) { d := newTestDaemon() d.sessionStart(Event{SessionID: "dirty", RepoRoot: "/tmp/test"}) if d.sessions["dirty"].Dirty { t.Error("session should not be dirty before any edit") } d.postEdit(Event{SessionID: "dirty", File: "/tmp/test/x.ex", RepoRoot: "/tmp/test"}) if !d.sessions["dirty"].Dirty { t.Error("session should be dirty after post-edit") } d.sessions["dirty"].Timer.Stop() } func TestLastEditAtUpdated(t *testing.T) { d := newTestDaemon() d.sessionStart(Event{SessionID: "ts", RepoRoot: "/tmp/test"}) before := time.Now() d.postEdit(Event{SessionID: "ts", File: "/tmp/test/f.ex", RepoRoot: "/tmp/test"}) after := time.Now() sess := d.sessions["ts"] if sess.LastEditAt.Before(before) || sess.LastEditAt.After(after) { t.Errorf("LastEditAt %v not between %v and %v", sess.LastEditAt, before, after) } sess.Timer.Stop() } // ── Multiple sessions ─────────────────────────────────────────── func TestMultipleSessions(t *testing.T) { d := newTestDaemon() d.sessionStart(Event{SessionID: "s1", RepoRoot: "/tmp/repo1", PID: 100}) d.sessionStart(Event{SessionID: "s2", RepoRoot: "/tmp/repo2", PID: 200}) if len(d.sessions) != 2 { t.Fatalf("expected 2 sessions, got %d", len(d.sessions)) } d.postEdit(Event{SessionID: "s1", File: "/tmp/repo1/a.ex", RepoRoot: "/tmp/repo1"}) d.postEdit(Event{SessionID: "s2", File: "/tmp/repo2/b.ex", RepoRoot: "/tmp/repo2"}) if !d.sessions["s1"].Files["a.ex"] { t.Error("s1 should track a.ex") } if !d.sessions["s2"].Files["b.ex"] { t.Error("s2 should track b.ex") } // Files should not leak between sessions if d.sessions["s1"].Files["b.ex"] { t.Error("s1 should not have s2's file") } for _, s := range d.sessions { if s.Timer != nil { s.Timer.Stop() } } } // ── Debounce env var ──────────────────────────────────────────── func TestDebounceIntervalDefault(t *testing.T) { os.Unsetenv("JJ_HOOK_DEBOUNCE_SEC") d := debounceInterval() if d != defaultDebounce { t.Errorf("expected default debounce %v, got %v", defaultDebounce, d) } } func TestDebounceIntervalEnvVar(t *testing.T) { os.Setenv("JJ_HOOK_DEBOUNCE_SEC", "10") defer os.Unsetenv("JJ_HOOK_DEBOUNCE_SEC") d := debounceInterval() if d != 10*time.Second { t.Errorf("expected 10s debounce, got %v", d) } } func TestDebounceIntervalCapped(t *testing.T) { os.Setenv("JJ_HOOK_DEBOUNCE_SEC", "999") defer os.Unsetenv("JJ_HOOK_DEBOUNCE_SEC") d := debounceInterval() if d != maxDebounce { t.Errorf("expected max debounce %v, got %v", maxDebounce, d) } } func TestDebounceIntervalInvalid(t *testing.T) { os.Setenv("JJ_HOOK_DEBOUNCE_SEC", "not-a-number") defer os.Unsetenv("JJ_HOOK_DEBOUNCE_SEC") d := debounceInterval() if d != defaultDebounce { t.Errorf("expected default on invalid env, got %v", d) } } // ── Stale session env var ─────────────────────────────────────── func TestStaleSessionAgeDefault(t *testing.T) { os.Unsetenv("JJ_HOOK_STALE_MIN") age := staleSessionAge() if age != defaultStaleSessionAge { t.Errorf("expected default stale age %v, got %v", defaultStaleSessionAge, age) } } func TestStaleSessionAgeEnvVar(t *testing.T) { os.Setenv("JJ_HOOK_STALE_MIN", "15") defer os.Unsetenv("JJ_HOOK_STALE_MIN") age := staleSessionAge() if age != 15*time.Minute { t.Errorf("expected 15min stale age, got %v", age) } } func TestStaleSessionAgeInvalid(t *testing.T) { os.Setenv("JJ_HOOK_STALE_MIN", "abc") defer os.Unsetenv("JJ_HOOK_STALE_MIN") age := staleSessionAge() if age != defaultStaleSessionAge { t.Errorf("expected default on invalid env, got %v", age) } } // ── Legacy cleanup ────────────────────────────────────────────── func TestCleanLegacyTrackingFiles(t *testing.T) { d := newTestDaemon() repoRoot := "/tmp/test-legacy-cleanup" rid := repoID(repoRoot) // Create some fake legacy tracking files trackingPath := filepath.Join("/tmp", "jj-claude-"+rid+"-deadbeef-files") pidPath := filepath.Join("/tmp", "jj-claude-"+rid+"-deadbeef-pid") os.WriteFile(trackingPath, []byte("some/file.ex\n"), 0644) os.WriteFile(pidPath, []byte("99999999\n"), 0644) // dead PID // Should clean them up d.cleanLegacyTrackingFiles(repoRoot) if _, err := os.Stat(trackingPath); err == nil { t.Error("expected tracking file to be removed") } if _, err := os.Stat(pidPath); err == nil { t.Error("expected pid file to be removed") } } func TestCleanLegacyTrackingFilesNoop(t *testing.T) { d := newTestDaemon() // Should not panic when no files match d.cleanLegacyTrackingFiles("/tmp/nonexistent-repo-for-test") }