| 1 | package jobs |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "io" |
| 6 | "testing" |
| 7 | "time" |
| 8 | |
| 9 | "reasonix/internal/event" |
| 10 | ) |
| 11 | |
| 12 | type typedNilJobSink struct{} |
| 13 | |
| 14 | func (*typedNilJobSink) Emit(event.Event) {} |
| 15 | |
| 16 | func TestNewManagerTreatsTypedNilSinkAsDiscard(t *testing.T) { |
| 17 | var sink *typedNilJobSink |
| 18 | m := NewManager(sink) |
| 19 | defer m.Close() |
| 20 | |
| 21 | j := m.Start("bash", "typed nil sink", func(context.Context, io.Writer) (string, error) { |
| 22 | return "done", nil |
| 23 | }) |
| 24 | res := m.Wait(context.Background(), []string{j.ID}, 1000) |
| 25 | if len(res) != 1 || res[0].Status != Done { |
| 26 | t.Fatalf("job result = %+v, want one done job", res) |
| 27 | } |
| 28 | } |
| 29 | |
| 30 | // --- Wait with timeout --- |
| 31 | |
| 32 | func TestWaitTimeout(t *testing.T) { |
| 33 | m := NewManager(event.Discard) |
| 34 | defer m.Close() |
| 35 | |
| 36 | j := m.Start("bash", "", func(ctx context.Context, _ io.Writer) (string, error) { |
| 37 | <-ctx.Done() |
| 38 | return "", ctx.Err() |
| 39 | }) |
| 40 | // Wait with a very short timeout — the job won't finish in time. |
| 41 | res := m.Wait(context.Background(), []string{j.ID}, 1) |
| 42 | if len(res) != 1 { |
| 43 | t.Fatalf("want 1 result, got %d", len(res)) |
| 44 | } |
| 45 | // Should still be running (timeout expired before completion). |
| 46 | if res[0].Status != Running { |
| 47 | t.Errorf("status = %q, want running", res[0].Status) |
| 48 | } |
| 49 | m.Kill(j.ID) |
| 50 | } |
| 51 | |
| 52 | // --- Wait with empty ids waits for all running --- |
| 53 | |
| 54 | func TestWaitAllRunning(t *testing.T) { |
| 55 | m := NewManager(event.Discard) |
| 56 | defer m.Close() |
| 57 | |
| 58 | // Jobs block until cancelled so they are still running when Wait resolves the |
| 59 | // "all running" set — instant-returning jobs could finish first and be missed, |
| 60 | // which is exactly the resolution this test must observe deterministically. A |
| 61 | // short timeout returns the still-running snapshot. |
| 62 | j1 := m.Start("bash", "", func(ctx context.Context, _ io.Writer) (string, error) { |
| 63 | <-ctx.Done() |
| 64 | return "", ctx.Err() |
| 65 | }) |
| 66 | j2 := m.Start("bash", "", func(ctx context.Context, _ io.Writer) (string, error) { |
| 67 | <-ctx.Done() |
| 68 | return "", ctx.Err() |
| 69 | }) |
| 70 | res := m.Wait(context.Background(), nil, 1) |
| 71 | if len(res) != 2 { |
| 72 | t.Fatalf("want 2 results, got %d", len(res)) |
| 73 | } |
| 74 | ids := map[string]bool{res[0].ID: true, res[1].ID: true} |
| 75 | if !ids[j1.ID] || !ids[j2.ID] { |
| 76 | t.Errorf("results missing expected ids: %v", ids) |
| 77 | } |
| 78 | m.Kill(j1.ID) |
| 79 | m.Kill(j2.ID) |
| 80 | } |
| 81 | |
| 82 | // --- Output with unknown id --- |
| 83 | |
| 84 | func TestOutputUnknownID(t *testing.T) { |
| 85 | m := NewManager(event.Discard) |
| 86 | defer m.Close() |
| 87 | |
| 88 | _, _, ok := m.Output("nonexistent-id") |
| 89 | if ok { |
| 90 | t.Error("Output for unknown id should return ok=false") |
| 91 | } |
| 92 | } |
| 93 | |
| 94 | // --- Kill with unknown id --- |
| 95 | |
| 96 | func TestKillUnknownID(t *testing.T) { |
| 97 | m := NewManager(event.Discard) |
| 98 | defer m.Close() |
| 99 | |
| 100 | if m.Kill("nonexistent-id") { |
| 101 | t.Error("Kill for unknown id should return false") |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | // --- startedText --- |
| 106 | |
| 107 | func TestStartedTextWithLabel(t *testing.T) { |
| 108 | got := startedText("bash", "bash-1", "my-label") |
| 109 | if got != "background bash started: bash-1 (my-label)" { |
| 110 | t.Errorf("startedText = %q", got) |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | func TestStartedTextWithoutLabel(t *testing.T) { |
| 115 | got := startedText("task", "task-1", "") |
| 116 | if got != "background task started: task-1" { |
| 117 | t.Errorf("startedText = %q", got) |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | // --- DrainCompletedNote with multiple jobs --- |
| 122 | |
| 123 | func TestDrainMultiple(t *testing.T) { |
| 124 | m := NewManager(event.Discard) |
| 125 | defer m.Close() |
| 126 | |
| 127 | m.Start("bash", "", func(_ context.Context, _ io.Writer) (string, error) { |
| 128 | return "", nil |
| 129 | }) |
| 130 | m.Start("task", "label", func(_ context.Context, _ io.Writer) (string, error) { |
| 131 | return "answer", nil |
| 132 | }) |
| 133 | m.Wait(context.Background(), nil, 5) |
| 134 | note := m.DrainCompletedNote() |
| 135 | if note == "" { |
| 136 | t.Fatal("drain should not be empty after 2 completions") |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | // --- Close is idempotent --- |
| 141 | |
| 142 | func TestCloseIdempotent(t *testing.T) { |
| 143 | m := NewManager(event.Discard) |
| 144 | m.Start("bash", "", func(_ context.Context, _ io.Writer) (string, error) { |
| 145 | return "", nil |
| 146 | }) |
| 147 | m.Close() |
| 148 | m.Close() // should not panic |
| 149 | } |
| 150 | |
| 151 | // --- Running with no jobs --- |
| 152 | |
| 153 | func TestRunningEmpty(t *testing.T) { |
| 154 | m := NewManager(event.Discard) |
| 155 | defer m.Close() |
| 156 | if r := m.Running(); len(r) != 0 { |
| 157 | t.Errorf("Running() = %d, want 0", len(r)) |
| 158 | } |
| 159 | } |
| 160 | |
| 161 | // --- Job with error sets Failed --- |
| 162 | |
| 163 | func TestJobFailed(t *testing.T) { |
| 164 | m := NewManager(event.Discard) |
| 165 | defer m.Close() |
| 166 | |
| 167 | j := m.Start("bash", "", func(_ context.Context, _ io.Writer) (string, error) { |
| 168 | return "", io.ErrUnexpectedEOF |
| 169 | }) |
| 170 | res := m.Wait(context.Background(), []string{j.ID}, 5) |
| 171 | if len(res) != 1 || res[0].Status != Failed { |
| 172 | t.Fatalf("want Failed, got %+v", res) |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | // --- Job with result and no error sets Done --- |
| 177 | |
| 178 | func TestJobWithResult(t *testing.T) { |
| 179 | m := NewManager(event.Discard) |
| 180 | defer m.Close() |
| 181 | |
| 182 | j := m.Start("task", "", func(_ context.Context, _ io.Writer) (string, error) { |
| 183 | return "final answer", nil |
| 184 | }) |
| 185 | res := m.Wait(context.Background(), []string{j.ID}, 5) |
| 186 | if len(res) != 1 || res[0].Status != Done { |
| 187 | t.Fatalf("want Done, got %+v", res) |
| 188 | } |
| 189 | if res[0].Output != "final answer" { |
| 190 | t.Errorf("output = %q, want \"final answer\"", res[0].Output) |
| 191 | } |
| 192 | } |
| 193 | |
| 194 | // --- Context injection --- |
| 195 | |
| 196 | func TestWithManagerFromContext(t *testing.T) { |
| 197 | m := NewManager(event.Discard) |
| 198 | defer m.Close() |
| 199 | |
| 200 | ctx := WithManager(context.Background(), m) |
| 201 | got, ok := FromContext(ctx) |
| 202 | if !ok || got != m { |
| 203 | t.Error("FromContext should return the manager") |
| 204 | } |
| 205 | } |
| 206 | |
| 207 | func TestFromContextEmpty(t *testing.T) { |
| 208 | _, ok := FromContext(context.Background()) |
| 209 | if ok { |
| 210 | t.Error("plain context should return ok=false") |
| 211 | } |
| 212 | } |
| 213 | |
| 214 | // --- Status constants --- |
| 215 | |
| 216 | func TestStatusConstants(t *testing.T) { |
| 217 | if Running != "running" { |
| 218 | t.Errorf("Running = %q", Running) |
| 219 | } |
| 220 | if Done != "done" { |
| 221 | t.Errorf("Done = %q", Done) |
| 222 | } |
| 223 | if Failed != "failed" { |
| 224 | t.Errorf("Failed = %q", Failed) |
| 225 | } |
| 226 | if Killed != "killed" { |
| 227 | t.Errorf("Killed = %q", Killed) |
| 228 | } |
| 229 | } |
| 230 | |
| 231 | // --- nowMs --- |
| 232 | |
| 233 | func TestNowMs(t *testing.T) { |
| 234 | before := time.Now().UnixMilli() |
| 235 | got := nowMs() |
| 236 | after := time.Now().UnixMilli() |
| 237 | if got < before || got > after { |
| 238 | t.Errorf("nowMs() = %d, not in [%d, %d]", got, before, after) |
| 239 | } |
| 240 | } |
| 241 |