package app import ( "sync" "testing" "time" ) // recorderFire collects timer firings without touching a PTY. Lets the // timer manager run end-to-end logic in unit tests. type recorderFire struct { mu sync.Mutex fires []recordedFire } type recordedFire struct { OwnerID string Body string Label string } func (r *recorderFire) fn(owner *Child, body, label string) { r.mu.Lock() defer r.mu.Unlock() id := "" if owner != nil { id = owner.ID } r.fires = append(r.fires, recordedFire{OwnerID: id, Body: body, Label: label}) } func (r *recorderFire) snapshot() []recordedFire { r.mu.Lock() defer r.mu.Unlock() out := make([]recordedFire, len(r.fires)) copy(out, r.fires) return out } // fakeChild constructs a Child shell suitable for timer-manager tests. // Doesn't open a PTY — fireFn is overridden so InjectAsOrchestrator is // never reached. func fakeChild(id string) *Child { c := newChildEntry(id, id, KindAgent, []string{"echo"}, nil, "", "", "") running := StatusRunning c.status.Store(&running) return c } // addChild bypasses Spawn (no PTY needed) so the manager can find the // child by id and read its IdleState. func addChild(s *Session, c *Child) { s.mu.Lock() s.children[c.ID] = c s.order = append(s.order, c.ID) s.mu.Unlock() } func newTestManager(t *testing.T) (*Session, *timerManager, *recorderFire) { t.Helper() sess := NewSession(t.TempDir(), "test") mgr := newTimerManager(sess) rec := &recorderFire{} mgr.fireFn = rec.fn return sess, mgr, rec } func TestTimerSetDelivers(t *testing.T) { sess, mgr, rec := newTestManager(t) c := fakeChild("p_owner") addChild(sess, c) id, err := mgr.TimerSet("p_owner", "wake up", "test", 0.05) if err != nil { t.Fatalf("TimerSet: %v", err) } if id == "" { t.Fatal("empty timer id") } deadline := time.Now().Add(time.Second) for time.Now().Before(deadline) { if len(rec.snapshot()) > 0 { break } time.Sleep(10 * time.Millisecond) } got := rec.snapshot() if len(got) != 1 { t.Fatalf("got %d fires, want 1", len(got)) } if got[0].Body != "wake up" || got[0].OwnerID != "p_owner" { t.Fatalf("unexpected fire: %+v", got[0]) } } func TestTimerIdleAllAlreadySatisfied(t *testing.T) { sess, mgr, rec := newTestManager(t) owner := fakeChild("p_owner") a := fakeChild("p_a") b := fakeChild("p_b") addChild(sess, owner) addChild(sess, a) addChild(sess, b) idle := StateIdle a.idleState.Store(&idle) b.idleState.Store(&idle) resp, err := mgr.TimerFireWhenIdleAll("p_owner", "all done", "", []string{"p_a", "p_b"}, 0) if err != nil { t.Fatalf("TimerFireWhenIdleAll: %v", err) } if resp.Status != "already_satisfied" { t.Fatalf("status: got %q want already_satisfied", resp.Status) } // fire is dispatched on a goroutine; wait briefly. time.Sleep(50 * time.Millisecond) got := rec.snapshot() if len(got) != 1 || got[0].Body != "all done" { t.Fatalf("fires: %+v", got) } } func TestTimerIdleAnyFiresOnTransition(t *testing.T) { sess, mgr, rec := newTestManager(t) owner := fakeChild("p_owner") a := fakeChild("p_a") addChild(sess, owner) addChild(sess, a) // p_a starts busy. working := StateWorking a.idleState.Store(&working) resp, err := mgr.TimerFireWhenIdleAny("p_owner", "one done", "", []string{"p_a"}, 0) if err != nil { t.Fatalf("TimerFireWhenIdleAny: %v", err) } if resp.Status != "pending" { t.Fatalf("status: got %q want pending", resp.Status) } // Flip a into idle and deliver the state-change event. idle := StateIdle a.idleState.Store(&idle) mgr.onChildStateChanged("p_a", StateIdle) got := rec.snapshot() if len(got) != 1 || got[0].Body != "one done" { t.Fatalf("fires: %+v", got) } } func TestTimerIdleAnyExcludesBaseline(t *testing.T) { sess, mgr, rec := newTestManager(t) owner := fakeChild("p_owner") a := fakeChild("p_a") addChild(sess, owner) addChild(sess, a) idle := StateIdle a.idleState.Store(&idle) resp, err := mgr.TimerFireWhenIdleAny("p_owner", "one done", "", []string{"p_a"}, 0) if err != nil { t.Fatalf("TimerFireWhenIdleAny: %v", err) } if resp.Status != "pending" { t.Fatalf("status: got %q want pending", resp.Status) } // Send a redundant idle transition for p_a; should NOT fire because // p_a was idle at registration. mgr.onChildStateChanged("p_a", StateIdle) if got := rec.snapshot(); len(got) != 0 { t.Fatalf("unexpected fires: %+v", got) } } func TestTimerCancelPauseResume(t *testing.T) { sess, mgr, rec := newTestManager(t) owner := fakeChild("p_owner") addChild(sess, owner) // Cancel before fire. id, _ := mgr.TimerSet("p_owner", "x", "", 0.2) if err := mgr.TimerCancel("p_owner", id); err != nil { t.Fatalf("Cancel: %v", err) } time.Sleep(300 * time.Millisecond) if got := rec.snapshot(); len(got) != 0 { t.Fatalf("cancel didn't stop fire: %+v", got) } // Pause then resume → fire after resume. id2, _ := mgr.TimerSet("p_owner", "y", "", 0.2) time.Sleep(50 * time.Millisecond) if err := mgr.TimerPause("p_owner", id2); err != nil { t.Fatalf("Pause: %v", err) } time.Sleep(300 * time.Millisecond) // would have fired by now if not paused if got := rec.snapshot(); len(got) != 0 { t.Fatalf("paused timer fired: %+v", got) } if err := mgr.TimerResume("p_owner", id2); err != nil { t.Fatalf("Resume: %v", err) } deadline := time.Now().Add(time.Second) for time.Now().Before(deadline) { if len(rec.snapshot()) > 0 { break } time.Sleep(20 * time.Millisecond) } if got := rec.snapshot(); len(got) != 1 || got[0].Body != "y" { t.Fatalf("resume fire: %+v", got) } } func TestTimerOwnershipEnforced(t *testing.T) { sess, mgr, _ := newTestManager(t) a := fakeChild("p_a") b := fakeChild("p_b") addChild(sess, a) addChild(sess, b) id, _ := mgr.TimerSet("p_a", "hi", "", 60) if err := mgr.TimerCancel("p_b", id); err == nil { t.Fatal("expected ownership error from foreign cancel") } if err := mgr.TimerPause("p_b", id); err == nil { t.Fatal("expected ownership error from foreign pause") } }