Commit 642d214
Eric Bower
·
2026-05-06 23:37:11 -0400 EDT
parent cb4e6af
chore: e2e test
1 files changed,
+640,
-0
+640,
-0
1@@ -0,0 +1,640 @@
2+package main
3+
4+import (
5+ "bufio"
6+ "bytes"
7+ "context"
8+ "encoding/json"
9+ "fmt"
10+ "io"
11+ "log/slog"
12+ "os"
13+ "os/exec"
14+ "path/filepath"
15+ "strings"
16+ "testing"
17+ "time"
18+)
19+
20+// TestE2E_RunnerWithZMXSessions is a full integration test that:
21+// 1. Creates a workspace with pico.sh that spawns zmx sessions
22+// 2. Feeds an event to RunRunner (fire-and-forget)
23+// 3. Runs the monitor to track job completion
24+// 4. Reads the status file and asserts correct status transitions.
25+func TestE2E_RunnerWithZMXSessions(t *testing.T) {
26+ if testing.Short() {
27+ t.Skip("skip integration test")
28+ }
29+ if _, err := exec.LookPath("zmx"); err != nil {
30+ t.Skip("zmx not found, skipping integration test")
31+ }
32+
33+ // 1. Create workspace with pico.sh that spawns zmx sessions
34+ workspaceDir := t.TempDir()
35+ picoSh := `#!/usr/bin/env bash
36+set -e
37+zmx run step1 echo "hello from step1"
38+zmx run step2 echo "hello from step2"
39+`
40+ if err := os.WriteFile(filepath.Join(workspaceDir, "pico.sh"), []byte(picoSh), 0755); err != nil {
41+ t.Fatalf("write pico.sh: %v", err)
42+ }
43+
44+ // 2. Create config
45+ artifactDir := t.TempDir()
46+ ctx, cancel := context.WithCancel(context.Background())
47+ event := Event{
48+ Type: "build",
49+ Name: "test-repo",
50+ Workspace: workspaceDir,
51+ }
52+ eventJSON, _ := json.Marshal(event)
53+ statusBuf := new(bytes.Buffer)
54+ cfg := &Cfg{
55+ Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
56+ Ctx: ctx,
57+ Cancel: cancel,
58+ ArtifactDir: artifactDir,
59+ EventSource: io.NopCloser(bytes.NewReader(append(eventJSON, '\n'))),
60+ MonitorInterval: 200 * time.Millisecond,
61+ NewWorkspace: defaultWorkspaceFactory,
62+ StatusOutput: statusBuf,
63+ StatusFilter: "all",
64+ }
65+
66+ // 3. Run the runner (fire-and-forget, exits quickly)
67+ runnerDone := make(chan error, 1)
68+ go func() {
69+ runnerDone <- RunRunner(cfg)
70+ }()
71+
72+ select {
73+ case err := <-runnerDone:
74+ if err != nil {
75+ t.Fatalf("runner: %v", err)
76+ }
77+ case <-time.After(30 * time.Second):
78+ t.Fatal("timeout waiting for runner to complete")
79+ }
80+
81+ // 4. Run the monitor and poll for incremental artifacts
82+ monitorDone := make(chan error, 1)
83+ go func() {
84+ monitorDone <- runMonitor(cfg)
85+ }()
86+
87+ // Track what we've seen during the run
88+ seenIndexHTML := false
89+ seenIndexTXT := false
90+ sessionArtifactShorts := make(map[string]bool)
91+ var finalPayload *StatusPayload
92+
93+ for {
94+ select {
95+ case err := <-monitorDone:
96+ t.Fatalf("monitor exited unexpectedly: %v", err)
97+ case <-time.After(30 * time.Second):
98+ t.Fatal("timeout waiting for job to complete")
99+ default:
100+ }
101+
102+ // Read all statuses and artifacts seen so far
103+ data := statusBuf.Bytes()
104+ if len(data) > 0 {
105+ lines := scanLines(data)
106+ for _, line := range lines {
107+ var p StatusPayload
108+ if err := json.Unmarshal([]byte(line), &p); err != nil {
109+ continue
110+ }
111+
112+ // Ignore statuses from unrelated jobs (e.g., leftover sessions from previous tests)
113+ if p.Name != "test-repo" {
114+ continue
115+ }
116+
117+ // Check index files exist (generated at every tick)
118+ indexHTMLPath := filepath.Join(artifactDir, p.Name, p.JobID, "index.html")
119+ indexTXTPath := filepath.Join(artifactDir, p.Name, p.JobID, "index.txt")
120+ if _, err := os.Stat(indexHTMLPath); err == nil {
121+ seenIndexHTML = true
122+ t.Logf("index.html exists (status=%s)", p.Status)
123+ }
124+ if _, err := os.Stat(indexTXTPath); err == nil {
125+ seenIndexTXT = true
126+ }
127+
128+ // Track per-session artifacts
129+ for _, s := range p.Sessions {
130+ htmlPath := filepath.Join(artifactDir, p.Name, p.JobID, s.Short+".html")
131+ txtPath := filepath.Join(artifactDir, p.Name, p.JobID, s.Short+".txt")
132+ if _, err := os.Stat(htmlPath); err == nil {
133+ sessionArtifactShorts[s.Short+"_html"] = true
134+ }
135+ if _, err := os.Stat(txtPath); err == nil {
136+ sessionArtifactShorts[s.Short+"_txt"] = true
137+ }
138+ }
139+
140+ // Check if we've reached a final state
141+ if p.Status == "success" || p.Status == "failed" {
142+ cancel() // stop the monitor
143+ finalPayload = &p
144+ break
145+ }
146+ }
147+ }
148+
149+ if finalPayload != nil {
150+ break
151+ }
152+
153+ // Assert progress: if we see session artifacts, we should see index files too
154+ if len(sessionArtifactShorts) > 0 && (!seenIndexHTML || !seenIndexTXT) {
155+ t.Error("index files should exist once any session artifact exists")
156+ }
157+
158+ time.Sleep(cfg.MonitorInterval / 2) // poll at twice the interval rate
159+ }
160+
161+ // Wait for monitor to exit gracefully
162+ select {
163+ case <-monitorDone:
164+ case <-time.After(5 * time.Second):
165+ t.Log("warning: monitor did not exit gracefully")
166+ }
167+
168+ // 5. Assert we saw index files during the run
169+ if !seenIndexHTML {
170+ t.Error("never saw index.html during monitoring")
171+ }
172+ if !seenIndexTXT {
173+ t.Error("never saw index.txt during monitoring")
174+ }
175+
176+ // 6. Assert final payload has correct data
177+ if finalPayload == nil {
178+ t.Fatal("no final payload")
179+ }
180+ // Filter to only sessions with our job prefix to avoid picking up unrelated ci.* sessions
181+ expectedPrefix := fmt.Sprintf("ci.%s.%s.", finalPayload.Name, finalPayload.JobID)
182+ sessions := make([]SessionInfo, 0, len(finalPayload.Sessions))
183+ for _, s := range finalPayload.Sessions {
184+ if strings.HasPrefix(s.Name, expectedPrefix) {
185+ sessions = append(sessions, s)
186+ }
187+ }
188+ finalPayload.Sessions = sessions
189+
190+ if finalPayload.Name != "test-repo" {
191+ t.Errorf("expected name test-repo, got %q", finalPayload.Name)
192+ }
193+ if finalPayload.Status != "success" {
194+ t.Errorf("expected status success, got %q", finalPayload.Status)
195+ }
196+ if finalPayload.ExitCode == nil || *finalPayload.ExitCode != 0 {
197+ t.Errorf("expected exit code 0, got %v", finalPayload.ExitCode)
198+ }
199+ if len(finalPayload.Sessions) < 2 {
200+ t.Errorf("expected at least 2 sessions, got %d", len(finalPayload.Sessions))
201+ }
202+
203+ // 7. Assert sessions have correct names
204+ sessionNames := make(map[string]bool)
205+ for _, s := range finalPayload.Sessions {
206+ sessionNames[s.Short] = true
207+ t.Logf("session: name=%s short=%s exit_code=%s ended=%s", s.Name, s.Short, s.ExitCode, s.Ended)
208+ }
209+ // Sessions from the test's pico.sh: step1 and step2
210+ if !sessionNames["step1"] {
211+ t.Error("expected session 'step1'")
212+ }
213+ if !sessionNames["step2"] {
214+ t.Error("expected session 'step2'")
215+ }
216+
217+ // 8. Assert HTML artifacts were staged for each session
218+ for _, s := range finalPayload.Sessions {
219+ artifactPath := filepath.Join(cfg.ArtifactDir, finalPayload.Name, finalPayload.JobID, s.Short+".html")
220+ data, err := os.ReadFile(artifactPath)
221+ if err != nil {
222+ t.Errorf("read artifact %s: %v", artifactPath, err)
223+ continue
224+ }
225+ if len(data) == 0 {
226+ t.Errorf("artifact %s is empty", artifactPath)
227+ }
228+ if !bytes.Contains(data, []byte("<div")) {
229+ t.Errorf("artifact %s does not contain HTML content", artifactPath)
230+ }
231+ }
232+
233+ // Cleanup any leftover zmx sessions for this job
234+ if finalPayload != nil {
235+ _ = exec.Command("zmx", "kill", "-f", fmt.Sprintf("ci.test-repo.%s", finalPayload.JobID)).Run()
236+ }
237+}
238+
239+func scanLines(data []byte) []string {
240+ var lines []string
241+ scanner := bufio.NewScanner(bytes.NewReader(data))
242+ for scanner.Scan() {
243+ lines = append(lines, scanner.Text())
244+ }
245+ return lines
246+}
247+
248+// TestE2E_DuplicateCancellation verifies that starting a new job for the same
249+// repo cancels any existing running job for that repo.
250+func TestE2E_DuplicateCancellation(t *testing.T) {
251+ if testing.Short() {
252+ t.Skip("skip integration test")
253+ }
254+ if _, err := exec.LookPath("zmx"); err != nil {
255+ t.Skip("zmx not found, skipping integration test")
256+ }
257+
258+ // 1. Create workspace with a slow pico.sh so the first job stays running
259+ workspaceDir := t.TempDir()
260+ picoSh := `#!/usr/bin/env bash
261+set -e
262+zmx run slow-step sleep 30
263+`
264+ if err := os.WriteFile(filepath.Join(workspaceDir, "pico.sh"), []byte(picoSh), 0755); err != nil {
265+ t.Fatalf("write pico.sh: %v", err)
266+ }
267+
268+ // 2. Create a fast pico.sh for the second job
269+ workspaceDir2 := t.TempDir()
270+ picoSh2 := `#!/usr/bin/env bash
271+set -e
272+zmx run fast-step echo "done"
273+`
274+ if err := os.WriteFile(filepath.Join(workspaceDir2, "pico.sh"), []byte(picoSh2), 0755); err != nil {
275+ t.Fatalf("write pico.sh: %v", err)
276+ }
277+
278+ artifactDir := t.TempDir()
279+ ctx, cancel := context.WithCancel(context.Background())
280+ defer cancel()
281+
282+ makeCfg := func(eventSource io.ReadCloser) *Cfg {
283+ return &Cfg{
284+ Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
285+ Ctx: ctx,
286+ Cancel: cancel,
287+ ArtifactDir: artifactDir,
288+ EventSource: eventSource,
289+ MonitorInterval: 200 * time.Millisecond,
290+ NewWorkspace: defaultWorkspaceFactory,
291+ }
292+ }
293+
294+ // 3. Start first job (slow)
295+ event1 := Event{Type: "build", Name: "dup-repo", Workspace: workspaceDir}
296+ event1JSON, _ := json.Marshal(event1)
297+ cfg1 := makeCfg(io.NopCloser(bytes.NewReader(append(event1JSON, '\n'))))
298+
299+ go func() {
300+ _ = RunRunner(cfg1)
301+ }()
302+
303+ // Wait for the first job's runner session to appear
304+ if !waitForSessionPrefix(t, "ci.dup-repo.", 10*time.Second) {
305+ t.Fatal("first job's runner session never appeared")
306+ }
307+
308+ // Record the first job's runner session name
309+ firstRunner := findRunnerSession(t, "dup-repo")
310+ if firstRunner == "" {
311+ t.Fatal("could not find first job's runner session")
312+ }
313+ t.Logf("first job runner: %s", firstRunner)
314+
315+ // 4. Start second job (same repo name, should cancel the first)
316+ event2 := Event{Type: "build", Name: "dup-repo", Workspace: workspaceDir2}
317+ event2JSON, _ := json.Marshal(event2)
318+ cfg2 := makeCfg(io.NopCloser(bytes.NewReader(append(event2JSON, '\n'))))
319+
320+ runner2Done := make(chan error, 1)
321+ go func() {
322+ runner2Done <- RunRunner(cfg2)
323+ }()
324+
325+ // Wait for second runner to complete
326+ select {
327+ case err := <-runner2Done:
328+ if err != nil {
329+ t.Fatalf("second runner: %v", err)
330+ }
331+ case <-time.After(30 * time.Second):
332+ t.Fatal("timeout waiting for second runner")
333+ }
334+
335+ // 5. Verify the first job's runner session was killed
336+ // Give zmx kill time to propagate
337+ time.Sleep(1 * time.Second)
338+
339+ listOutput, _ := exec.Command("zmx", "list").CombinedOutput()
340+ sessions := parseZMXList(string(listOutput))
341+ for _, s := range sessions {
342+ if s.Name == firstRunner {
343+ if s.Ended == "" {
344+ t.Errorf("first job's runner session %s should have been killed (ended is empty)", firstRunner)
345+ } else {
346+ t.Logf("first job's runner session %s was killed (ended=%s)", firstRunner, s.Ended)
347+ }
348+ }
349+ }
350+
351+ // Cleanup
352+ _ = exec.Command("zmx", "kill", "-f", "ci.dup-repo").Run()
353+}
354+
355+// waitForSessionPrefix returns true if a session with the given prefix appears within the timeout.
356+func waitForSessionPrefix(t *testing.T, prefix string, timeout time.Duration) bool {
357+ deadline := time.Now().Add(timeout)
358+ for time.Now().Before(deadline) {
359+ listOutput, err := exec.Command("zmx", "list").CombinedOutput()
360+ if err == nil {
361+ sessions := parseZMXList(string(listOutput))
362+ for _, s := range sessions {
363+ if strings.HasPrefix(s.Name, prefix) {
364+ return true
365+ }
366+ }
367+ }
368+ time.Sleep(200 * time.Millisecond)
369+ }
370+ return false
371+}
372+
373+// findRunnerSession finds the runner session for a given repo name.
374+func findRunnerSession(t *testing.T, name string) string {
375+ listOutput, err := exec.Command("zmx", "list").CombinedOutput()
376+ if err != nil {
377+ t.Fatalf("zmx list: %v", err)
378+ }
379+ sessions := parseZMXList(string(listOutput))
380+ for _, s := range sessions {
381+ if strings.HasPrefix(s.Name, "ci."+name+".") && strings.HasSuffix(s.Name, ".runner") {
382+ return s.Name
383+ }
384+ }
385+ return ""
386+}
387+
388+func TestGenerateJobID(t *testing.T) {
389+ // Same inputs should produce same hash
390+ id1 := jobIDFor("myrepo", "/workspace", 1000)
391+ id2 := jobIDFor("myrepo", "/workspace", 1000)
392+ if id1 != id2 {
393+ t.Errorf("expected same ID for same inputs, got %q and %q", id1, id2)
394+ }
395+
396+ // Different name should produce different hash
397+ id3 := jobIDFor("otherrepo", "/workspace", 1000)
398+ if id1 == id3 {
399+ t.Errorf("expected different IDs for different names, got %q", id1)
400+ }
401+
402+ // Different timestamp should produce different hash
403+ id4 := jobIDFor("myrepo", "/workspace", 2000)
404+ if id1 == id4 {
405+ t.Errorf("expected different IDs for different timestamps, got %q", id1)
406+ }
407+
408+ // ID should be 8 hex chars
409+ if len(id1) != 8 {
410+ t.Errorf("expected 8 char ID, got %d chars: %q", len(id1), id1)
411+ }
412+
413+ // generateJobID (with real time) should also produce valid IDs
414+ id := generateJobID("myrepo", "/workspace")
415+ if len(id) != 8 {
416+ t.Errorf("generateJobID expected 8 char ID, got %d chars: %q", len(id), id)
417+ }
418+}
419+
420+func TestAllCompleted(t *testing.T) {
421+ tests := []struct {
422+ name string
423+ sessions []SessionInfo
424+ want bool
425+ }{
426+ {
427+ name: "empty sessions",
428+ sessions: []SessionInfo{},
429+ want: true,
430+ },
431+ {
432+ name: "all completed",
433+ sessions: []SessionInfo{
434+ {Name: "a", Ended: "123"},
435+ {Name: "b", Ended: "456"},
436+ },
437+ want: true,
438+ },
439+ {
440+ name: "one not completed",
441+ sessions: []SessionInfo{
442+ {Name: "a", Ended: "123"},
443+ {Name: "b", Ended: ""},
444+ },
445+ want: false,
446+ },
447+ }
448+
449+ for _, tt := range tests {
450+ t.Run(tt.name, func(t *testing.T) {
451+ got := allCompleted(tt.sessions)
452+ if got != tt.want {
453+ t.Errorf("allCompleted() = %v, want %v", got, tt.want)
454+ }
455+ })
456+ }
457+}
458+
459+func TestParseZMXList(t *testing.T) {
460+ output := `name=ci-lint pid=1064464 clients=0 created=1777519944 start_dir=/home/erock/dev/pico ended=1777519986 exit_code=0
461+ name=ci-tests pid=1064472 clients=0 created=1777519944 start_dir=/home/erock/dev/pico ended=1777519958 exit_code=2
462+→ name=d.build.1 pid=549652 clients=0 created=1777513430 start_dir=/home/erock`
463+
464+ sessions := parseZMXList(output)
465+ if len(sessions) != 3 {
466+ t.Fatalf("expected 3 sessions, got %d", len(sessions))
467+ }
468+
469+ if sessions[0].Name != "ci-lint" {
470+ t.Errorf("expected first session name ci-lint, got %q", sessions[0].Name)
471+ }
472+ if sessions[0].Ended != "1777519986" {
473+ t.Errorf("expected ended 1777519986, got %q", sessions[0].Ended)
474+ }
475+ if sessions[0].ExitCode != "0" {
476+ t.Errorf("expected exit_code 0, got %q", sessions[0].ExitCode)
477+ }
478+
479+ if sessions[2].Name != "d.build.1" {
480+ t.Errorf("expected third session name d.build.1, got %q", sessions[2].Name)
481+ }
482+ if sessions[2].Ended != "" {
483+ t.Errorf("expected empty ended for active session, got %q", sessions[2].Ended)
484+ }
485+}
486+
487+func TestExtractJobID(t *testing.T) {
488+ tests := []struct {
489+ runnerName string
490+ want string
491+ }{
492+ {"ci.myrepo.abc123.runner", "abc123"},
493+ {"ci.test-repo.006d0847.runner", "006d0847"},
494+ {"ci.my_org.project.abc123.runner", "project.abc123"}, // name with underscore
495+ }
496+
497+ for _, tt := range tests {
498+ got := extractJobID(tt.runnerName)
499+ if got != tt.want {
500+ t.Errorf("extractJobID(%q) = %q, want %q", tt.runnerName, got, tt.want)
501+ }
502+ }
503+}
504+
505+func TestExtractJobPrefix(t *testing.T) {
506+ tests := []struct {
507+ sessionName string
508+ want string
509+ }{
510+ {"ci.myrepo.abc123.lint", "ci.myrepo.abc123."},
511+ {"ci.myrepo.abc123.runner", "ci.myrepo.abc123."},
512+ {"ci.myrepo.abc123.tests", "ci.myrepo.abc123."},
513+ {"ci.name.jobID.step.substep", "ci.name.jobID."},
514+ {"ci.a.b", ""}, // too few parts
515+ }
516+
517+ for _, tt := range tests {
518+ got := extractJobPrefix(tt.sessionName)
519+ if got != tt.want {
520+ t.Errorf("extractJobPrefix(%q) = %q, want %q", tt.sessionName, got, tt.want)
521+ }
522+ }
523+}
524+
525+func TestFindRunningJobs(t *testing.T) {
526+ output := `name=ci.myrepo.abc123.runner pid=100 clients=0 created=1777519944 start_dir=/home/erock
527+ name=ci.myrepo.abc123.lint pid=101 clients=0 created=1777519944 start_dir=/home/erock
528+ name=ci.myrepo.abc123.tests pid=102 clients=0 created=1777519944 start_dir=/home/erock ended=1777519986 exit_code=0
529+ name=ci.myrepo.def456.runner pid=103 clients=0 created=1777519944 start_dir=/home/erock ended=1777519986 exit_code=0
530+ name=ci.other.abc123.runner pid=104 clients=0 created=1777519944 start_dir=/home/erock
531+→ name=d.build.1 pid=549652 clients=0 created=1777513430 start_dir=/home/erock`
532+
533+ runners, sessions := findRunningJobsFromOutput(output, "myrepo")
534+ if len(runners) != 1 {
535+ t.Fatalf("expected 1 running job, got %d: %v", len(runners), runners)
536+ }
537+ if runners[0] != "ci.myrepo.abc123.runner" {
538+ t.Errorf("expected runner ci.myrepo.abc123.runner, got %q", runners[0])
539+ }
540+ if len(sessions) != 6 {
541+ t.Errorf("expected 6 total sessions, got %d", len(sessions))
542+ }
543+}
544+
545+func findRunningJobsFromOutput(output, name string) ([]string, []SessionInfo) {
546+ sessions := parseZMXList(output)
547+ var runners []string
548+ for _, s := range sessions {
549+ if strings.HasPrefix(s.Name, "ci."+name+".") && strings.HasSuffix(s.Name, ".runner") && s.Ended == "" {
550+ runners = append(runners, s.Name)
551+ }
552+ }
553+ return runners, sessions
554+}
555+
556+func TestKillSessionsEmpty(t *testing.T) {
557+ // Should not error with empty list
558+ if err := killSessions(nil); err != nil {
559+ t.Errorf("killSessions(nil) = %v, want nil", err)
560+ }
561+ if err := killSessions([]string{}); err != nil {
562+ t.Errorf("killSessions([]) = %v, want nil", err)
563+ }
564+}
565+
566+func TestResolveJobExitCode(t *testing.T) {
567+ tests := []struct {
568+ name string
569+ sessions []SessionInfo
570+ wantCode int
571+ wantStatus string
572+ }{
573+ {
574+ name: "all success",
575+ sessions: []SessionInfo{
576+ {Name: "ci.repo.abc.runner", ExitCode: "0", Ended: "1"},
577+ {Name: "ci.repo.abc.step1", ExitCode: "0", Ended: "1"},
578+ },
579+ wantCode: 0,
580+ wantStatus: "success",
581+ },
582+ {
583+ name: "runner failed",
584+ sessions: []SessionInfo{
585+ {Name: "ci.repo.abc.runner", ExitCode: "1", Ended: "1"},
586+ {Name: "ci.repo.abc.step1", ExitCode: "0", Ended: "1"},
587+ },
588+ wantCode: 1,
589+ wantStatus: "failed",
590+ },
591+ {
592+ name: "child failed, runner says 0 (defensive)",
593+ sessions: []SessionInfo{
594+ {Name: "ci.repo.abc.runner", ExitCode: "0", Ended: "1"},
595+ {Name: "ci.repo.abc.step1", ExitCode: "0", Ended: "1"},
596+ {Name: "ci.repo.abc.step2", ExitCode: "2", Ended: "1"},
597+ },
598+ wantCode: 2,
599+ wantStatus: "failed",
600+ },
601+ {
602+ name: "worst child exit code wins",
603+ sessions: []SessionInfo{
604+ {Name: "ci.repo.abc.runner", ExitCode: "0", Ended: "1"},
605+ {Name: "ci.repo.abc.step1", ExitCode: "1", Ended: "1"},
606+ {Name: "ci.repo.abc.step2", ExitCode: "3", Ended: "1"},
607+ },
608+ wantCode: 3,
609+ wantStatus: "failed",
610+ },
611+ {
612+ name: "no runner session",
613+ sessions: []SessionInfo{
614+ {Name: "ci.repo.abc.step1", ExitCode: "0", Ended: "1"},
615+ },
616+ wantCode: 0,
617+ wantStatus: "success",
618+ },
619+ {
620+ name: "sessions not yet ended (no exit code)",
621+ sessions: []SessionInfo{
622+ {Name: "ci.repo.abc.runner", ExitCode: "", Ended: ""},
623+ {Name: "ci.repo.abc.step1", ExitCode: "", Ended: ""},
624+ },
625+ wantCode: 0,
626+ wantStatus: "success",
627+ },
628+ }
629+
630+ for _, tt := range tests {
631+ t.Run(tt.name, func(t *testing.T) {
632+ code, status := resolveJobExitCode(tt.sessions)
633+ if code != tt.wantCode {
634+ t.Errorf("exit code = %d, want %d", code, tt.wantCode)
635+ }
636+ if status != tt.wantStatus {
637+ t.Errorf("status = %q, want %q", status, tt.wantStatus)
638+ }
639+ })
640+ }
641+}