main
main_test.go
Eric Bower
·
2026-08-30
1package main
2
3import (
4 "bufio"
5 "bytes"
6 "context"
7 "encoding/json"
8 "errors"
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.
25func TestE2E_RunnerWithZMXSessions(t *testing.T) {
26 if testing.Short() {
27 t.Skip("skip integration test")
28 }
29 zmxPath, err := exec.LookPath("zmx")
30 if err != nil {
31 t.Skip("zmx not found, skipping integration test")
32 }
33 zmxDir := filepath.Dir(zmxPath)
34
35 // 1. Create workspace with pico.sh that spawns zmx sessions
36 workspaceDir := t.TempDir()
37 picoSh := fmt.Sprintf(`#!/usr/bin/env bash
38set -e
39export PATH="%s:$PATH"
40zmx run step1 echo "hello from step1"
41zmx run step2 echo "hello from step2"
42`, zmxDir)
43 if err := os.WriteFile(filepath.Join(workspaceDir, "pico.sh"), []byte(picoSh), 0755); err != nil {
44 t.Fatalf("write pico.sh: %v", err)
45 }
46
47 // 2. Create config
48 artifactDir := t.TempDir()
49 ctx, cancel := context.WithCancel(context.Background())
50 testJobID := fmt.Sprintf("j-%d", time.Now().UnixNano()%100000000)
51 t.Cleanup(func() {
52 _ = exec.Command("zmx", "kill", "-f", fmt.Sprintf("local.test-repo.%s", testJobID)).Run()
53 })
54 event := Event{
55 Type: "local",
56 Name: "test-repo",
57 JobID: testJobID,
58 Workspace: workspaceDir,
59 }
60 eventJSON, _ := json.Marshal(event)
61 statusBuf := new(bytes.Buffer)
62 cfg := &Cfg{
63 Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
64 Ctx: ctx,
65 Cancel: cancel,
66 ArtifactDir: artifactDir,
67 EventSource: io.NopCloser(bytes.NewReader(append(eventJSON, '\n'))),
68 MonitorInterval: 200 * time.Millisecond,
69 NewWorkspace: defaultWorkspaceFactory,
70 StatusOutput: statusBuf,
71 IncludeRunning: true,
72 SessionPrefix: "local.",
73 }
74
75 // 3. Run the runner (fire-and-forget, exits quickly)
76 runnerDone := make(chan error, 1)
77 go func() {
78 runnerDone <- RunRunner(cfg)
79 }()
80
81 select {
82 case err := <-runnerDone:
83 if err != nil {
84 t.Fatalf("runner: %v", err)
85 }
86 case <-time.After(30 * time.Second):
87 t.Fatal("timeout waiting for runner to complete")
88 }
89
90 // Verify that RunRunner does NOT create published.json (monitor's job)
91 sentinel := filepath.Join(artifactDir, "test-repo", testJobID, "artifacts", "published.json")
92 if _, err := os.Stat(sentinel); err == nil {
93 t.Fatalf("expected published.json NOT to exist after RunRunner, but it exists at %s", sentinel)
94 }
95
96 // 4. Run the monitor and poll for incremental artifacts
97 monitorDone := make(chan error, 1)
98 go func() {
99 monitorDone <- runMonitor(cfg)
100 }()
101
102 // Track what we've seen during the run
103 seenIndexHTML := false
104 seenIndexTXT := false
105 sessionArtifactShorts := make(map[string]bool)
106 var finalPayload *StatusPayload
107
108 for {
109 select {
110 case err := <-monitorDone:
111 t.Fatalf("monitor exited unexpectedly: %v", err)
112 case <-time.After(30 * time.Second):
113 t.Fatal("timeout waiting for job to complete")
114 default:
115 }
116
117 // Read all statuses and artifacts seen so far
118 data := statusBuf.Bytes()
119 if len(data) > 0 {
120 lines := scanLines(data)
121 for _, line := range lines {
122 var p StatusPayload
123 if err := json.Unmarshal([]byte(line), &p); err != nil {
124 continue
125 }
126
127 // Ignore statuses from unrelated jobs (e.g., leftover sessions from previous tests)
128 if p.Name != "test-repo" || p.JobID != testJobID {
129 continue
130 }
131
132 // Check index files exist (generated at every tick)
133 indexHTMLPath := filepath.Join(artifactDir, p.Name, p.JobID, "index.html")
134 indexTXTPath := filepath.Join(artifactDir, p.Name, p.JobID, "index.txt")
135 if _, err := os.Stat(indexHTMLPath); err == nil {
136 seenIndexHTML = true
137 t.Logf("index.html exists (status=%s)", p.Status)
138 }
139 if _, err := os.Stat(indexTXTPath); err == nil {
140 seenIndexTXT = true
141 }
142
143 // Track per-session artifacts
144 for _, s := range p.Sessions {
145 htmlPath := filepath.Join(artifactDir, p.Name, p.JobID, s.Short+".html")
146 txtPath := filepath.Join(artifactDir, p.Name, p.JobID, s.Short+".txt")
147 if _, err := os.Stat(htmlPath); err == nil {
148 sessionArtifactShorts[s.Short+"_html"] = true
149 }
150 if _, err := os.Stat(txtPath); err == nil {
151 sessionArtifactShorts[s.Short+"_txt"] = true
152 }
153 }
154
155 // Check if we've reached a final state
156 if p.Status == "success" || p.Status == "failed" {
157 cancel() // stop the monitor
158 finalPayload = &p
159 break
160 }
161 }
162 }
163
164 if finalPayload != nil {
165 break
166 }
167
168 // Assert progress: if we see session artifacts, we should see index files too
169 if len(sessionArtifactShorts) > 0 && (!seenIndexHTML || !seenIndexTXT) {
170 t.Error("index files should exist once any session artifact exists")
171 }
172
173 time.Sleep(cfg.MonitorInterval / 2) // poll at twice the interval rate
174 }
175
176 // Wait for monitor to exit gracefully
177 select {
178 case <-monitorDone:
179 case <-time.After(5 * time.Second):
180 t.Log("warning: monitor did not exit gracefully")
181 }
182
183 // 5. Assert we saw index files during the run
184 if !seenIndexHTML {
185 t.Error("never saw index.html during monitoring")
186 }
187 if !seenIndexTXT {
188 t.Error("never saw index.txt during monitoring")
189 }
190
191 // 6. Assert final payload has correct data
192 if finalPayload == nil {
193 t.Fatal("no final payload")
194 }
195 // Filter to only sessions with our job prefix to avoid picking up unrelated local.* sessions
196 expectedPrefix := fmt.Sprintf("local.%s.%s.", finalPayload.Name, finalPayload.JobID)
197 sessions := make([]SessionInfo, 0, len(finalPayload.Sessions))
198 for _, s := range finalPayload.Sessions {
199 if strings.HasPrefix(s.Name, expectedPrefix) {
200 sessions = append(sessions, s)
201 }
202 }
203 finalPayload.Sessions = sessions
204
205 if finalPayload.Name != "test-repo" {
206 t.Errorf("expected name test-repo, got %q", finalPayload.Name)
207 }
208 if finalPayload.Status != "success" {
209 t.Errorf("expected status success, got %q", finalPayload.Status)
210 }
211 if finalPayload.ExitCode == nil || *finalPayload.ExitCode != 0 {
212 t.Errorf("expected exit code 0, got %v", finalPayload.ExitCode)
213 }
214 if len(finalPayload.Sessions) < 2 {
215 t.Errorf("expected at least 2 sessions, got %d", len(finalPayload.Sessions))
216 }
217
218 // 7. Assert sessions have correct names
219 sessionNames := make(map[string]bool)
220 for _, s := range finalPayload.Sessions {
221 sessionNames[s.Short] = true
222 t.Logf("session: name=%s short=%s exit_code=%s ended=%s", s.Name, s.Short, s.ExitCode, s.Ended)
223 }
224 // Sessions from the test's pico.sh: step1 and step2
225 if !sessionNames["step1"] {
226 t.Error("expected session 'step1'")
227 }
228 if !sessionNames["step2"] {
229 t.Error("expected session 'step2'")
230 }
231
232 // 8. Assert HTML artifacts were staged for each session
233 for _, s := range finalPayload.Sessions {
234 artifactPath := filepath.Join(cfg.ArtifactDir, finalPayload.Name, finalPayload.JobID, s.Short+".html")
235 data, err := os.ReadFile(artifactPath)
236 if err != nil {
237 t.Errorf("read artifact %s: %v", artifactPath, err)
238 continue
239 }
240 if len(data) == 0 {
241 t.Errorf("artifact %s is empty", artifactPath)
242 }
243 if !bytes.Contains(data, []byte("<div")) {
244 t.Errorf("artifact %s does not contain HTML content", artifactPath)
245 }
246 }
247
248 // Cleanup any leftover zmx sessions for this job
249 if finalPayload != nil {
250 _ = exec.Command("zmx", "kill", "-f", fmt.Sprintf("local.test-repo.%s", finalPayload.JobID)).Run()
251 }
252}
253
254func scanLines(data []byte) []string {
255 var lines []string
256 scanner := bufio.NewScanner(bytes.NewReader(data))
257 for scanner.Scan() {
258 lines = append(lines, scanner.Text())
259 }
260 return lines
261}
262
263// TestE2E_DuplicateCancellation verifies that starting a new job for the same
264// repo cancels any existing running job for that repo.
265func TestE2E_DuplicateCancellation(t *testing.T) {
266 if testing.Short() {
267 t.Skip("skip integration test")
268 }
269 if _, err := exec.LookPath("zmx"); err != nil {
270 t.Skip("zmx not found, skipping integration test")
271 }
272
273 // 1. Create workspace with a slow pico.sh so the first job stays running
274 workspaceDir := t.TempDir()
275 picoSh := `#!/usr/bin/env bash
276set -e
277zmx run slow-step sleep 30
278`
279 if err := os.WriteFile(filepath.Join(workspaceDir, "pico.sh"), []byte(picoSh), 0755); err != nil {
280 t.Fatalf("write pico.sh: %v", err)
281 }
282
283 // 2. Create a fast pico.sh for the second job
284 workspaceDir2 := t.TempDir()
285 picoSh2 := `#!/usr/bin/env bash
286set -e
287zmx run fast-step echo "done"
288`
289 if err := os.WriteFile(filepath.Join(workspaceDir2, "pico.sh"), []byte(picoSh2), 0755); err != nil {
290 t.Fatalf("write pico.sh: %v", err)
291 }
292
293 artifactDir := t.TempDir()
294 ctx, cancel := context.WithCancel(context.Background())
295 defer cancel()
296 t.Cleanup(func() {
297 _ = exec.Command("zmx", "kill", "-f", "local.dup-repo").Run()
298 })
299
300 makeCfg := func(eventSource io.ReadCloser) *Cfg {
301 return &Cfg{
302 Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
303 Ctx: ctx,
304 Cancel: cancel,
305 ArtifactDir: artifactDir,
306 EventSource: eventSource,
307 MonitorInterval: 200 * time.Millisecond,
308 NewWorkspace: defaultWorkspaceFactory,
309 }
310 }
311
312 // 3. Start first job (slow)
313 event1 := Event{Type: "local", Name: "dup-repo", Workspace: workspaceDir}
314 event1JSON, _ := json.Marshal(event1)
315 cfg1 := makeCfg(io.NopCloser(bytes.NewReader(append(event1JSON, '\n'))))
316
317 go func() {
318 _ = RunRunner(cfg1)
319 }()
320
321 // Wait for the first job's runner session to appear
322 if !waitForSessionPrefix(t, "local.dup-repo.", 10*time.Second) {
323 t.Fatal("first job's runner session never appeared")
324 }
325
326 // Record the first job's runner session name
327 firstRunner := findRunnerSession(t, "dup-repo")
328 if firstRunner == "" {
329 t.Fatal("could not find first job's runner session")
330 }
331 t.Logf("first job runner: %s", firstRunner)
332
333 // 4. Start second job (same repo name, should cancel the first)
334 event2 := Event{Type: "local", Name: "dup-repo", Workspace: workspaceDir2}
335 event2JSON, _ := json.Marshal(event2)
336 cfg2 := makeCfg(io.NopCloser(bytes.NewReader(append(event2JSON, '\n'))))
337
338 runner2Done := make(chan error, 1)
339 go func() {
340 runner2Done <- RunRunner(cfg2)
341 }()
342
343 // Wait for second runner to complete
344 select {
345 case err := <-runner2Done:
346 if err != nil {
347 t.Fatalf("second runner: %v", err)
348 }
349 case <-time.After(30 * time.Second):
350 t.Fatal("timeout waiting for second runner")
351 }
352
353 // 5. Verify the first job's runner session was killed
354 // Give zmx kill time to propagate
355 time.Sleep(1 * time.Second)
356
357 listOutput, _ := exec.Command("zmx", "list").CombinedOutput()
358 sessions := parseZMXList(string(listOutput))
359 for _, s := range sessions {
360 if s.Name == firstRunner {
361 if s.Ended == "" {
362 t.Errorf("first job's runner session %s should have been killed (ended is empty)", firstRunner)
363 } else {
364 t.Logf("first job's runner session %s was killed (ended=%s)", firstRunner, s.Ended)
365 }
366 }
367 }
368
369 // Cleanup
370 _ = exec.Command("zmx", "kill", "-f", "local.dup-repo").Run()
371}
372
373// waitForSessionPrefix returns true if a session with the given prefix appears within the timeout.
374func waitForSessionPrefix(t *testing.T, prefix string, timeout time.Duration) bool {
375 deadline := time.Now().Add(timeout)
376 for time.Now().Before(deadline) {
377 listOutput, err := exec.Command("zmx", "list").CombinedOutput()
378 if err == nil {
379 sessions := parseZMXList(string(listOutput))
380 for _, s := range sessions {
381 if strings.HasPrefix(s.Name, prefix) {
382 return true
383 }
384 }
385 }
386 time.Sleep(200 * time.Millisecond)
387 }
388 return false
389}
390
391// findRunnerSession finds the runner session for a given repo name.
392func findRunnerSession(t *testing.T, name string) string {
393 listOutput, err := exec.Command("zmx", "list").CombinedOutput()
394 if err != nil {
395 t.Fatalf("zmx list: %v", err)
396 }
397 sessions := parseZMXList(string(listOutput))
398 for _, s := range sessions {
399 if (strings.HasPrefix(s.Name, "local."+name+".") || strings.HasPrefix(s.Name, "ci."+name+".")) && strings.HasSuffix(s.Name, ".runner") {
400 return s.Name
401 }
402 }
403 return ""
404}
405
406func TestGenerateJobID(t *testing.T) {
407 // Same inputs should produce same hash
408 id1 := jobIDFor("myrepo", "/workspace", 1000)
409 id2 := jobIDFor("myrepo", "/workspace", 1000)
410 if id1 != id2 {
411 t.Errorf("expected same ID for same inputs, got %q and %q", id1, id2)
412 }
413
414 // Different name should produce different hash
415 id3 := jobIDFor("otherrepo", "/workspace", 1000)
416 if id1 == id3 {
417 t.Errorf("expected different IDs for different names, got %q", id1)
418 }
419
420 // Different timestamp should produce different hash
421 id4 := jobIDFor("myrepo", "/workspace", 2000)
422 if id1 == id4 {
423 t.Errorf("expected different IDs for different timestamps, got %q", id1)
424 }
425
426 // ID should be 8 hex chars
427 if len(id1) != 8 {
428 t.Errorf("expected 8 char ID, got %d chars: %q", len(id1), id1)
429 }
430
431 // generateJobID (with real time) should also produce valid IDs
432 id := generateJobID("myrepo", "/workspace")
433 if len(id) != 8 {
434 t.Errorf("generateJobID expected 8 char ID, got %d chars: %q", len(id), id)
435 }
436}
437
438func TestValidateJobID(t *testing.T) {
439 tests := []struct {
440 id string
441 want bool
442 }{
443 {"a3f2b8c1", true}, // 8-char hex (commit short SHA)
444 {"abc-123", true}, // alphanumeric + hyphen
445 {"PR42", true}, // uppercase + digits
446 {"a", true}, // single char
447 {"abcdefghij123456", true}, // 16 chars (max)
448 {"", false}, // empty
449 {"abcdefghij1234567", false}, // 17 chars (too long)
450 {"abc.def", false}, // contains dot (breaks parseJobPrefix)
451 {"abc/def", false}, // contains slash
452 {"abc def", false}, // contains space
453 {"abc_def", false}, // contains underscore
454 }
455
456 for _, tt := range tests {
457 got := validateJobID(tt.id)
458 if got != tt.want {
459 t.Errorf("validateJobID(%q) = %v, want %v", tt.id, got, tt.want)
460 }
461 }
462}
463
464func TestResolveJobID(t *testing.T) {
465 // Valid producer ID is used as-is
466 id := resolveJobID("myrepo", "/workspace", "a3f2b8c1")
467 if id != "a3f2b8c1" {
468 t.Errorf("resolveJobID with valid ID = %q, want %q", id, "a3f2b8c1")
469 }
470
471 // Empty producer ID falls back to generated
472 id = resolveJobID("myrepo", "/workspace", "")
473 if id == "" || len(id) != 8 {
474 t.Errorf("resolveJobID with empty ID = %q, want 8-char generated ID", id)
475 }
476
477 // Invalid producer ID (contains dot) falls back to generated
478 id = resolveJobID("myrepo", "/workspace", "1.0.0")
479 if id == "1.0.0" || len(id) != 8 {
480 t.Errorf("resolveJobID with invalid ID = %q, want 8-char generated ID", id)
481 }
482
483 // Invalid producer ID (too long) falls back to generated
484 id = resolveJobID("myrepo", "/workspace", "this_is_way_too_long_for_a_job_id")
485 if len(id) != 8 {
486 t.Errorf("resolveJobID with too-long ID = %q, want 8-char generated ID", id)
487 }
488}
489
490func TestAllCompleted(t *testing.T) {
491 tests := []struct {
492 name string
493 sessions []SessionInfo
494 want bool
495 }{
496 {
497 name: "empty sessions",
498 sessions: []SessionInfo{},
499 want: true,
500 },
501 {
502 name: "all completed",
503 sessions: []SessionInfo{
504 {Name: "a", Ended: "123"},
505 {Name: "b", Ended: "456"},
506 },
507 want: true,
508 },
509 {
510 name: "one not completed",
511 sessions: []SessionInfo{
512 {Name: "a", Ended: "123"},
513 {Name: "b", Ended: ""},
514 },
515 want: false,
516 },
517 }
518
519 for _, tt := range tests {
520 t.Run(tt.name, func(t *testing.T) {
521 got := allCompleted(tt.sessions)
522 if got != tt.want {
523 t.Errorf("allCompleted() = %v, want %v", got, tt.want)
524 }
525 })
526 }
527}
528
529func TestIsJobComplete(t *testing.T) {
530 tests := []struct {
531 name string
532 sessions []SessionInfo
533 want bool
534 }{
535 {
536 name: "all completed with runner",
537 sessions: []SessionInfo{
538 {Name: "ci.repo.abc.runner", Ended: "100"},
539 {Name: "ci.repo.abc.step.fmt", Ended: "90"},
540 },
541 want: true,
542 },
543 {
544 name: "runner active, step completed",
545 sessions: []SessionInfo{
546 {Name: "ci.repo.abc.runner", Ended: ""},
547 {Name: "ci.repo.abc.step.fmt", Ended: "90"},
548 },
549 want: false,
550 },
551 {
552 name: "no runner session present",
553 sessions: []SessionInfo{
554 {Name: "ci.repo.abc.step.fmt", Ended: "90"},
555 },
556 want: false,
557 },
558 {
559 name: "runner completed, step active",
560 sessions: []SessionInfo{
561 {Name: "ci.repo.abc.runner", Ended: "100"},
562 {Name: "ci.repo.abc.step.fmt", Ended: ""},
563 },
564 want: false,
565 },
566 {
567 name: "empty sessions",
568 sessions: []SessionInfo{},
569 want: false,
570 },
571 }
572
573 for _, tt := range tests {
574 t.Run(tt.name, func(t *testing.T) {
575 got := isJobComplete(tt.sessions)
576 if got != tt.want {
577 t.Errorf("isJobComplete() = %v, want %v", got, tt.want)
578 }
579 })
580 }
581}
582
583func TestParseZMXList(t *testing.T) {
584 output := `name=ci-lint pid=1064464 clients=0 created=1777519944 start_dir=/home/erock/dev/pico ended=1777519986 exit_code=0
585 name=ci-tests pid=1064472 clients=0 created=1777519944 start_dir=/home/erock/dev/pico ended=1777519958 exit_code=2
586→ name=d.build.1 pid=549652 clients=0 created=1777513430 start_dir=/home/erock`
587
588 sessions := parseZMXList(output)
589 if len(sessions) != 3 {
590 t.Fatalf("expected 3 sessions, got %d", len(sessions))
591 }
592
593 if sessions[0].Name != "ci-lint" {
594 t.Errorf("expected first session name ci-lint, got %q", sessions[0].Name)
595 }
596 if sessions[0].Ended != "1777519986" {
597 t.Errorf("expected ended 1777519986, got %q", sessions[0].Ended)
598 }
599 if sessions[0].ExitCode != "0" {
600 t.Errorf("expected exit_code 0, got %q", sessions[0].ExitCode)
601 }
602
603 if sessions[2].Name != "d.build.1" {
604 t.Errorf("expected third session name d.build.1, got %q", sessions[2].Name)
605 }
606 if sessions[2].Ended != "" {
607 t.Errorf("expected empty ended for active session, got %q", sessions[2].Ended)
608 }
609}
610
611func TestGetDomain(t *testing.T) {
612 tests := []struct {
613 eventType string
614 want string
615 }{
616 {"git.push", "ci"},
617 {"git.tag", "ci"},
618 {"push", "ci"},
619 {"manual", "ci"},
620 {"ci", "ci"},
621 {"", "ci"},
622 {"local", "local"},
623 }
624
625 for _, tt := range tests {
626 got := getDomain(tt.eventType)
627 if got != tt.want {
628 t.Errorf("getDomain(%q) = %q, want %q", tt.eventType, got, tt.want)
629 }
630 }
631}
632
633func TestExtractJobID(t *testing.T) {
634 tests := []struct {
635 runnerName string
636 want string
637 }{
638 {"ci.myrepo.abc123.runner", "abc123"},
639 {"ci.test-repo.006d0847.runner", "006d0847"},
640 {"ci.my_org.project.abc123.runner", "project.abc123"}, // name with underscore
641 }
642
643 for _, tt := range tests {
644 got := extractJobID(tt.runnerName)
645 if got != tt.want {
646 t.Errorf("extractJobID(%q) = %q, want %q", tt.runnerName, got, tt.want)
647 }
648 }
649}
650
651func TestExtractJobPrefix(t *testing.T) {
652 tests := []struct {
653 sessionName string
654 want string
655 }{
656 {"ci.myrepo.abc123.lint", "ci.myrepo.abc123."},
657 {"ci.myrepo.abc123.runner", "ci.myrepo.abc123."},
658 {"ci.myrepo.abc123.tests", "ci.myrepo.abc123."},
659 {"ci.name.jobID.step.substep", "ci.name.jobID."},
660 {"ci.a.b", ""}, // too few parts
661 }
662
663 for _, tt := range tests {
664 got := extractJobPrefix(tt.sessionName)
665 if got != tt.want {
666 t.Errorf("extractJobPrefix(%q) = %q, want %q", tt.sessionName, got, tt.want)
667 }
668 }
669}
670
671func TestFindRunningJobs(t *testing.T) {
672 output := `name=ci.myrepo.abc123.runner pid=100 clients=0 created=1777519944 start_dir=/home/erock
673 name=ci.myrepo.abc123.lint pid=101 clients=0 created=1777519944 start_dir=/home/erock
674 name=ci.myrepo.abc123.tests pid=102 clients=0 created=1777519944 start_dir=/home/erock ended=1777519986 exit_code=0
675 name=ci.myrepo.def456.runner pid=103 clients=0 created=1777519944 start_dir=/home/erock ended=1777519986 exit_code=0
676 name=ci.other.abc123.runner pid=104 clients=0 created=1777519944 start_dir=/home/erock
677→ name=d.build.1 pid=549652 clients=0 created=1777513430 start_dir=/home/erock`
678
679 runners, sessions := findRunningJobsFromOutput(output, "myrepo")
680 if len(runners) != 1 {
681 t.Fatalf("expected 1 running job, got %d: %v", len(runners), runners)
682 }
683 if runners[0] != "ci.myrepo.abc123.runner" {
684 t.Errorf("expected runner ci.myrepo.abc123.runner, got %q", runners[0])
685 }
686 if len(sessions) != 6 {
687 t.Errorf("expected 6 total sessions, got %d", len(sessions))
688 }
689}
690
691func findRunningJobsFromOutput(output, name string) ([]string, []SessionInfo) {
692 sessions := parseZMXList(output)
693 var runners []string
694 for _, s := range sessions {
695 if strings.HasPrefix(s.Name, "ci."+name+".") && strings.HasSuffix(s.Name, ".runner") && s.Ended == "" {
696 runners = append(runners, s.Name)
697 }
698 }
699 return runners, sessions
700}
701
702func TestFindSessionsForGC(t *testing.T) {
703 now := time.Unix(1786289700, 0)
704 twoHoursAgo := fmt.Sprintf("%d", now.Add(-2*time.Hour).Unix())
705 fourHoursAgo := fmt.Sprintf("%d", now.Add(-4*time.Hour).Unix())
706
707 sessions := []SessionInfo{
708 {Name: "ci.repo.active.runner", Created: twoHoursAgo, Ended: ""},
709 {Name: "ci.repo.active.step.fmt", Created: twoHoursAgo, Ended: twoHoursAgo},
710 {Name: "ci.repo.recent.runner", Created: twoHoursAgo, Ended: twoHoursAgo},
711 {Name: "ci.repo.recent.step.lint", Created: twoHoursAgo, Ended: twoHoursAgo},
712 {Name: "ci.repo.oldfinished.runner", Created: fourHoursAgo, Ended: fourHoursAgo},
713 {Name: "ci.repo.oldfinished.step.lint", Created: fourHoursAgo, Ended: fourHoursAgo},
714 {Name: "ci.repo.expired.runner", Created: fourHoursAgo, Ended: ""},
715 {Name: "other.session", Created: fourHoursAgo, Ended: ""},
716 }
717
718 toKill := findSessionsForGC(sessions, []string{"ci.", "local."}, now)
719 if len(toKill) != 3 {
720 t.Fatalf("expected 3 sessions for GC, got %d: %v", len(toKill), toKill)
721 }
722 expected := map[string]bool{
723 "ci.repo.oldfinished.runner": true,
724 "ci.repo.oldfinished.step.lint": true,
725 "ci.repo.expired.runner": true,
726 }
727 for _, k := range toKill {
728 if !expected[k] {
729 t.Errorf("unexpected session in GC kill list: %s", k)
730 }
731 }
732}
733
734func TestKillSessionsEmpty(t *testing.T) {
735 // Should not error with empty list
736 if err := killSessions(nil); err != nil {
737 t.Errorf("killSessions(nil) = %v, want nil", err)
738 }
739 if err := killSessions([]string{}); err != nil {
740 t.Errorf("killSessions([]) = %v, want nil", err)
741 }
742}
743
744func TestResolveJobExitCode(t *testing.T) {
745 tests := []struct {
746 name string
747 sessions []SessionInfo
748 wantCode int
749 wantStatus string
750 }{
751 {
752 name: "all success",
753 sessions: []SessionInfo{
754 {Name: "ci.repo.abc.runner", ExitCode: "0", Ended: "1"},
755 {Name: "ci.repo.abc.step1", ExitCode: "0", Ended: "1"},
756 },
757 wantCode: 0,
758 wantStatus: "success",
759 },
760 {
761 name: "runner failed",
762 sessions: []SessionInfo{
763 {Name: "ci.repo.abc.runner", ExitCode: "1", Ended: "1"},
764 {Name: "ci.repo.abc.step1", ExitCode: "0", Ended: "1"},
765 },
766 wantCode: 1,
767 wantStatus: "failed",
768 },
769 {
770 name: "child failed, runner says 0 (defensive)",
771 sessions: []SessionInfo{
772 {Name: "ci.repo.abc.runner", ExitCode: "0", Ended: "1"},
773 {Name: "ci.repo.abc.step1", ExitCode: "0", Ended: "1"},
774 {Name: "ci.repo.abc.step2", ExitCode: "2", Ended: "1"},
775 },
776 wantCode: 2,
777 wantStatus: "failed",
778 },
779 {
780 name: "worst child exit code wins",
781 sessions: []SessionInfo{
782 {Name: "ci.repo.abc.runner", ExitCode: "0", Ended: "1"},
783 {Name: "ci.repo.abc.step1", ExitCode: "1", Ended: "1"},
784 {Name: "ci.repo.abc.step2", ExitCode: "3", Ended: "1"},
785 },
786 wantCode: 3,
787 wantStatus: "failed",
788 },
789 {
790 name: "no runner session",
791 sessions: []SessionInfo{
792 {Name: "ci.repo.abc.step1", ExitCode: "0", Ended: "1"},
793 },
794 wantCode: 0,
795 wantStatus: "success",
796 },
797 {
798 name: "sessions not yet ended (no exit code)",
799 sessions: []SessionInfo{
800 {Name: "ci.repo.abc.runner", ExitCode: "", Ended: ""},
801 {Name: "ci.repo.abc.step1", ExitCode: "", Ended: ""},
802 },
803 wantCode: 0,
804 wantStatus: "success",
805 },
806 }
807
808 for _, tt := range tests {
809 t.Run(tt.name, func(t *testing.T) {
810 code, status := resolveJobExitCode(tt.sessions)
811 if code != tt.wantCode {
812 t.Errorf("exit code = %d, want %d", code, tt.wantCode)
813 }
814 if status != tt.wantStatus {
815 t.Errorf("status = %q, want %q", status, tt.wantStatus)
816 }
817 })
818 }
819}
820
821func TestFindManifestInDir(t *testing.T) {
822 t.Run("finds pici.sh when present", func(t *testing.T) {
823 tempDir := t.TempDir()
824 if err := os.WriteFile(filepath.Join(tempDir, "pici.sh"), []byte("#!/bin/bash\n"), 0755); err != nil {
825 t.Fatal(err)
826 }
827 path, err := findManifestInDir(tempDir)
828 if err != nil {
829 t.Fatalf("unexpected error: %v", err)
830 }
831 if filepath.Base(path) != "pici.sh" {
832 t.Fatalf("expected pici.sh, got %s", filepath.Base(path))
833 }
834 })
835
836 t.Run("falls back to pico.sh when pici.sh is missing", func(t *testing.T) {
837 tempDir := t.TempDir()
838 if err := os.WriteFile(filepath.Join(tempDir, "pico.sh"), []byte("#!/bin/bash\n"), 0755); err != nil {
839 t.Fatal(err)
840 }
841 path, err := findManifestInDir(tempDir)
842 if err != nil {
843 t.Fatalf("unexpected error: %v", err)
844 }
845 if filepath.Base(path) != "pico.sh" {
846 t.Fatalf("expected pico.sh, got %s", filepath.Base(path))
847 }
848 })
849
850 t.Run("prefers pici.sh over pico.sh when both exist", func(t *testing.T) {
851 tempDir := t.TempDir()
852 if err := os.WriteFile(filepath.Join(tempDir, "pici.sh"), []byte("#!/bin/bash\n"), 0755); err != nil {
853 t.Fatal(err)
854 }
855 if err := os.WriteFile(filepath.Join(tempDir, "pico.sh"), []byte("#!/bin/bash\n"), 0755); err != nil {
856 t.Fatal(err)
857 }
858 path, err := findManifestInDir(tempDir)
859 if err != nil {
860 t.Fatalf("unexpected error: %v", err)
861 }
862 if filepath.Base(path) != "pici.sh" {
863 t.Fatalf("expected pici.sh to take precedence, got %s", filepath.Base(path))
864 }
865 })
866
867 t.Run("returns error when neither exists", func(t *testing.T) {
868 tempDir := t.TempDir()
869 _, err := findManifestInDir(tempDir)
870 if err == nil {
871 t.Fatal("expected error when neither pici.sh nor pico.sh exists")
872 }
873 })
874}
875
876func TestRunLocal_MissingManifest(t *testing.T) {
877 tempDir := t.TempDir()
878 origWd, _ := os.Getwd()
879 defer func() { _ = os.Chdir(origWd) }()
880 _ = os.Chdir(tempDir)
881
882 cfg := &Cfg{
883 ArtifactDir: t.TempDir(),
884 }
885 err := runLocal(cfg, "")
886 if err == nil {
887 t.Fatal("expected error when runner script is missing")
888 }
889}
890
891func TestRunLocal_PiciSh_EnvOverrides(t *testing.T) {
892 tempDir := t.TempDir()
893 origWd, _ := os.Getwd()
894 defer func() { _ = os.Chdir(origWd) }()
895 _ = os.Chdir(tempDir)
896
897 piciContent := `#!/usr/bin/env bash
898echo "hello from pici"
899`
900 if err := os.WriteFile("pici.sh", []byte(piciContent), 0755); err != nil {
901 t.Fatal(err)
902 }
903
904 cfg := &Cfg{
905 ArtifactDir: t.TempDir(),
906 EnvVars: envList{"PICI_REPO=custom-repo", "CUSTOM_VAR=hello_pici"},
907 }
908
909 _ = runLocal(cfg, "")
910
911 if os.Getenv("CUSTOM_VAR") != "hello_pici" {
912 t.Errorf("expected CUSTOM_VAR to be hello_pici, got %q", os.Getenv("CUSTOM_VAR"))
913 }
914}
915
916func TestRunLocal_EnvOverrides(t *testing.T) {
917 tempDir := t.TempDir()
918 origWd, _ := os.Getwd()
919 defer func() { _ = os.Chdir(origWd) }()
920 _ = os.Chdir(tempDir)
921
922 picoContent := `#!/usr/bin/env bash
923echo "hello from pico"
924`
925 if err := os.WriteFile("pico.sh", []byte(picoContent), 0755); err != nil {
926 t.Fatal(err)
927 }
928
929 cfg := &Cfg{
930 ArtifactDir: t.TempDir(),
931 EnvVars: envList{"PICI_REPO=custom-repo", "CUSTOM_VAR=hello"},
932 }
933
934 _ = runLocal(cfg, "")
935
936 if os.Getenv("CUSTOM_VAR") != "hello" {
937 t.Errorf("expected CUSTOM_VAR to be hello, got %q", os.Getenv("CUSTOM_VAR"))
938 }
939}
940
941func TestRunLocal_CustomPiciEvent_UsesLocalPrefix(t *testing.T) {
942 if _, err := exec.LookPath("zmx"); err != nil {
943 t.Skip("zmx not found, skipping test")
944 }
945
946 tempDir := t.TempDir()
947 workDir := filepath.Join(tempDir, "event-repo")
948 if err := os.MkdirAll(workDir, 0755); err != nil {
949 t.Fatal(err)
950 }
951 origWd, _ := os.Getwd()
952 defer func() { _ = os.Chdir(origWd) }()
953 _ = os.Chdir(workDir)
954
955 picoContent := `#!/usr/bin/env bash
956set -euo pipefail
957echo "PICI_EVENT=$PICI_EVENT" > event_out.txt
958echo "ZMX_SESSION_PREFIX=$ZMX_SESSION_PREFIX" >> event_out.txt
959`
960 if err := os.WriteFile("pico.sh", []byte(picoContent), 0755); err != nil {
961 t.Fatal(err)
962 }
963
964 artifactDir := t.TempDir()
965 cfg := &Cfg{
966 ArtifactDir: artifactDir,
967 MonitorInterval: 100 * time.Millisecond,
968 InPlace: true,
969 EnvVars: envList{"PICI_EVENT=git.tag"},
970 }
971
972 err := runLocal(cfg, "")
973 if err != nil {
974 t.Fatalf("expected runLocal to succeed, got %v", err)
975 }
976
977 data, err := os.ReadFile(filepath.Join(workDir, "event_out.txt"))
978 if err != nil {
979 t.Fatalf("failed to read event_out.txt: %v", err)
980 }
981 content := string(data)
982 if !strings.Contains(content, "PICI_EVENT=git.tag") {
983 t.Errorf("expected PICI_EVENT=git.tag in pico.sh environment, got: %s", content)
984 }
985 if !strings.Contains(content, "ZMX_SESSION_PREFIX=\"local.event-repo.") && !strings.Contains(content, "ZMX_SESSION_PREFIX=local.event-repo.") {
986 t.Errorf("expected ZMX_SESSION_PREFIX to have local. prefix, got: %s", content)
987 }
988}
989
990func TestDetectGitCommit(t *testing.T) {
991 cwd, err := os.Getwd()
992 if err != nil {
993 t.Fatal(err)
994 }
995 commit := detectGitCommit(cwd)
996 if commit == "" {
997 t.Log("git commit sha not detected (not a git repository or git unavailable)")
998 } else {
999 t.Logf("detected git commit sha: %s", commit)
1000 }
1001}
1002
1003func TestWaitAndReport_Cancellation(t *testing.T) {
1004 if testing.Short() {
1005 t.Skip("skip integration test")
1006 }
1007 if _, err := exec.LookPath("zmx"); err != nil {
1008 t.Skip("zmx not found, skipping integration test")
1009 }
1010
1011 ctx, cancel := context.WithCancel(context.Background())
1012 cfg := &Cfg{
1013 Ctx: ctx,
1014 Cancel: cancel,
1015 MonitorInterval: 100 * time.Millisecond,
1016 }
1017
1018 jobID := fmt.Sprintf("canceltest-%d", time.Now().UnixNano())
1019 prefix := "local.testrepo." + jobID + "."
1020 runnerSession := prefix + "runner"
1021
1022 t.Cleanup(func() {
1023 _ = exec.Command("zmx", "kill", "-f", runnerSession).Run()
1024 })
1025
1026 cmd := exec.Command("zmx", "run", runnerSession, "-d", "sleep", "30")
1027 if err := cmd.Run(); err != nil {
1028 t.Fatalf("failed to start zmx session: %v", err)
1029 }
1030
1031 go func() {
1032 time.Sleep(200 * time.Millisecond)
1033 cancel()
1034 }()
1035
1036 err := waitAndReport(cfg, nil, "testrepo", jobID, "local", "local")
1037 if err == nil {
1038 t.Error("expected error when waitAndReport is cancelled, got nil")
1039 }
1040
1041 time.Sleep(200 * time.Millisecond)
1042 listOutput, _ := exec.Command("zmx", "list").CombinedOutput()
1043 sessions := parseZMXList(string(listOutput))
1044 for _, s := range sessions {
1045 if s.Name == runnerSession {
1046 if s.Ended == "" {
1047 t.Errorf("session %s should have been killed on cancellation", runnerSession)
1048 }
1049 }
1050 }
1051}
1052
1053func TestWorkspaceRsync_ExcludesGitAndJJ(t *testing.T) {
1054 if _, err := exec.LookPath("rsync"); err != nil {
1055 t.Skip("rsync not found, skipping test")
1056 }
1057
1058 srcDir := t.TempDir()
1059 if err := os.WriteFile(filepath.Join(srcDir, "hello.txt"), []byte("world"), 0644); err != nil {
1060 t.Fatalf("failed to write hello.txt: %v", err)
1061 }
1062 if err := os.WriteFile(filepath.Join(srcDir, ".gitignore"), []byte("ignored.txt\n"), 0644); err != nil {
1063 t.Fatalf("failed to write .gitignore: %v", err)
1064 }
1065 if err := os.WriteFile(filepath.Join(srcDir, "ignored.txt"), []byte("secret"), 0644); err != nil {
1066 t.Fatalf("failed to write ignored.txt: %v", err)
1067 }
1068 if err := os.MkdirAll(filepath.Join(srcDir, ".git"), 0755); err != nil {
1069 t.Fatalf("failed to mkdir .git: %v", err)
1070 }
1071 if err := os.WriteFile(filepath.Join(srcDir, ".git", "HEAD"), []byte("ref: refs/heads/main"), 0644); err != nil {
1072 t.Fatalf("failed to write .git/HEAD: %v", err)
1073 }
1074 if err := os.MkdirAll(filepath.Join(srcDir, ".jj"), 0755); err != nil {
1075 t.Fatalf("failed to mkdir .jj: %v", err)
1076 }
1077 if err := os.WriteFile(filepath.Join(srcDir, ".jj", "repo"), []byte("jj data"), 0644); err != nil {
1078 t.Fatalf("failed to write .jj/repo: %v", err)
1079 }
1080
1081 wk := &WorkspaceRsync{
1082 Cfg: &Cfg{},
1083 Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
1084 Source: srcDir,
1085 }
1086 t.Cleanup(func() {
1087 _ = wk.Cleanup()
1088 })
1089
1090 if err := wk.Setup(); err != nil {
1091 t.Fatalf("WorkspaceRsync.Setup failed: %v", err)
1092 }
1093
1094 destDir := wk.GetDir()
1095 if _, err := os.Stat(filepath.Join(destDir, "hello.txt")); os.IsNotExist(err) {
1096 t.Error("expected hello.txt to exist in dest workspace")
1097 }
1098 if _, err := os.Stat(filepath.Join(destDir, "ignored.txt")); !os.IsNotExist(err) {
1099 t.Error("expected ignored.txt to be excluded by .gitignore from dest workspace")
1100 }
1101 if _, err := os.Stat(filepath.Join(destDir, ".git")); !os.IsNotExist(err) {
1102 t.Error("expected .git to be excluded from dest workspace")
1103 }
1104 if _, err := os.Stat(filepath.Join(destDir, ".jj")); !os.IsNotExist(err) {
1105 t.Error("expected .jj to be excluded from dest workspace")
1106 }
1107
1108 checksum := wk.Checksum()
1109 if !strings.HasPrefix(checksum, "sha256:") {
1110 t.Errorf("expected checksum to start with 'sha256:', got %q", checksum)
1111 }
1112}
1113
1114func TestRunnerDoesNotCreatePublishedSentinel(t *testing.T) {
1115 if _, err := exec.LookPath("zmx"); err != nil {
1116 t.Skip("zmx not found, skipping test")
1117 }
1118
1119 tmpDir := t.TempDir()
1120 artifactDir := filepath.Join(tmpDir, "artifacts")
1121 wkDir := filepath.Join(tmpDir, "workspace")
1122
1123 if err := os.MkdirAll(wkDir, 0755); err != nil {
1124 t.Fatalf("failed to create workspace dir: %v", err)
1125 }
1126
1127 picoScript := `#!/usr/bin/env bash
1128set -euo pipefail
1129zmx run step1 -d echo "step1"
1130zmx wait "*"
1131`
1132 if err := os.WriteFile(filepath.Join(wkDir, "pico.sh"), []byte(picoScript), 0755); err != nil {
1133 t.Fatalf("failed to write pico.sh: %v", err)
1134 }
1135
1136 jobID := "j-no-published-test"
1137 event := Event{
1138 Type: "local",
1139 Name: "no-published-repo",
1140 JobID: jobID,
1141 Workspace: wkDir,
1142 }
1143 eventJSON, _ := json.Marshal(event)
1144
1145 cfg := &Cfg{
1146 Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
1147 ArtifactDir: artifactDir,
1148 EventSource: io.NopCloser(bytes.NewReader(append(eventJSON, '\n'))),
1149 NewWorkspace: func(cfg *Cfg, logger *slog.Logger, source string) Workspace { return &mockLocalWorkspace{dir: source} },
1150 SessionPrefix: "test.",
1151 }
1152
1153 if err := RunRunner(cfg); err != nil {
1154 t.Fatalf("RunRunner failed: %v", err)
1155 }
1156
1157 sentinel := filepath.Join(artifactDir, "no-published-repo", jobID, "artifacts", "published.json")
1158 if _, err := os.Stat(sentinel); err == nil {
1159 t.Errorf("expected published.json NOT to exist after RunRunner, but it exists at %s", sentinel)
1160 }
1161}
1162
1163type mockLocalWorkspace struct {
1164 dir string
1165}
1166
1167func (w *mockLocalWorkspace) Setup() error { return nil }
1168func (w *mockLocalWorkspace) Cleanup() error { return nil }
1169func (w *mockLocalWorkspace) GetDir() string { return w.dir }
1170func (w *mockLocalWorkspace) Checksum() string { return "" }
1171
1172func TestJobEngine_ExportsArtifactsDir(t *testing.T) {
1173 tmpDir := t.TempDir()
1174 eng := &JobEngine{
1175 Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
1176 Cfg: &Cfg{
1177 ArtifactDir: tmpDir,
1178 },
1179 Ev: &Event{
1180 Name: "myrepo",
1181 Type: "local",
1182 },
1183 JobID: "job123",
1184 }
1185
1186 expectedArtifactsDir := filepath.Join(tmpDir, "myrepo", "job123", "artifacts")
1187
1188 // Verify that artifacts directory path matches expected layout
1189 artifactsDir := filepath.Join(eng.Cfg.ArtifactDir, eng.Ev.Name, eng.JobID, "artifacts")
1190 if artifactsDir != expectedArtifactsDir {
1191 t.Errorf("expected artifactsDir %s, got %s", expectedArtifactsDir, artifactsDir)
1192 }
1193}
1194
1195func TestGenerateJobIndex_WalksSubdirectories(t *testing.T) {
1196 tmpDir := t.TempDir()
1197 repoName := "testrepo"
1198 jobID := "j999"
1199 artifactsDir := filepath.Join(tmpDir, repoName, jobID, "artifacts")
1200
1201 if err := os.MkdirAll(filepath.Join(artifactsDir, "build"), 0755); err != nil {
1202 t.Fatalf("mkdir failed: %v", err)
1203 }
1204 if err := os.WriteFile(filepath.Join(artifactsDir, "build", "output.json"), []byte("{}"), 0644); err != nil {
1205 t.Fatalf("write file failed: %v", err)
1206 }
1207
1208 htmlContent, txtContent := generateJobIndex(tmpDir, repoName, jobID, nil)
1209 if !strings.Contains(htmlContent, "build/output.json") {
1210 t.Errorf("html index missing nested artifact build/output.json: %s", htmlContent)
1211 }
1212 if !strings.Contains(txtContent, "build/output.json") {
1213 t.Errorf("txt index missing nested artifact build/output.json: %s", txtContent)
1214 }
1215}
1216
1217func TestWaitAndReport_ExitCodeFailure(t *testing.T) {
1218 if _, err := exec.LookPath("zmx"); err != nil {
1219 t.Skip("zmx not found, skipping test")
1220 }
1221
1222 cfg := &Cfg{
1223 MonitorInterval: 100 * time.Millisecond,
1224 ArtifactDir: t.TempDir(),
1225 }
1226
1227 jobID := fmt.Sprintf("failtest-%d", time.Now().UnixNano())
1228 prefix := "local.testrepo." + jobID + "."
1229 runnerSession := prefix + "runner"
1230
1231 t.Cleanup(func() {
1232 _ = exec.Command("zmx", "kill", "-f", runnerSession).Run()
1233 })
1234
1235 // Start a zmx session that exits with code 42
1236 cmd := exec.Command("zmx", "run", runnerSession, "-d", "bash", "-c", "exit 42")
1237 if err := cmd.Run(); err != nil {
1238 t.Fatalf("failed to start zmx session: %v", err)
1239 }
1240
1241 err := waitAndReport(cfg, nil, "testrepo", jobID, "local", "local")
1242 if err == nil {
1243 t.Fatal("expected error from waitAndReport for failed job, got nil")
1244 }
1245
1246 var jobFailedErr *JobFailedError
1247 if !errors.As(err, &jobFailedErr) {
1248 t.Fatalf("expected error of type *JobFailedError, got %T: %v", err, err)
1249 }
1250 if jobFailedErr.ExitCode != 42 {
1251 t.Errorf("expected exit code 42, got %d", jobFailedErr.ExitCode)
1252 }
1253}
1254
1255func TestWaitAndReport_Success(t *testing.T) {
1256 if _, err := exec.LookPath("zmx"); err != nil {
1257 t.Skip("zmx not found, skipping test")
1258 }
1259
1260 cfg := &Cfg{
1261 MonitorInterval: 100 * time.Millisecond,
1262 ArtifactDir: t.TempDir(),
1263 }
1264
1265 jobID := fmt.Sprintf("successtest-%d", time.Now().UnixNano())
1266 prefix := "local.testrepo." + jobID + "."
1267 runnerSession := prefix + "runner"
1268
1269 t.Cleanup(func() {
1270 _ = exec.Command("zmx", "kill", "-f", runnerSession).Run()
1271 })
1272
1273 // Start a zmx session that exits with code 0
1274 cmd := exec.Command("zmx", "run", runnerSession, "-d", "bash", "-c", "exit 0")
1275 if err := cmd.Run(); err != nil {
1276 t.Fatalf("failed to start zmx session: %v", err)
1277 }
1278
1279 err := waitAndReport(cfg, nil, "testrepo", jobID, "local", "local")
1280 if err != nil {
1281 t.Fatalf("expected nil error for successful job, got %v", err)
1282 }
1283}
1284
1285func TestRunLocal_ExitCodeFailure(t *testing.T) {
1286 if _, err := exec.LookPath("zmx"); err != nil {
1287 t.Skip("zmx not found, skipping test")
1288 }
1289
1290 tempDir := t.TempDir()
1291 origWd, _ := os.Getwd()
1292 defer func() { _ = os.Chdir(origWd) }()
1293 _ = os.Chdir(tempDir)
1294
1295 picoContent := `#!/usr/bin/env bash
1296exit 7
1297`
1298 if err := os.WriteFile("pico.sh", []byte(picoContent), 0755); err != nil {
1299 t.Fatal(err)
1300 }
1301
1302 artifactDir := t.TempDir()
1303 cfg := &Cfg{
1304 ArtifactDir: artifactDir,
1305 MonitorInterval: 100 * time.Millisecond,
1306 }
1307
1308 err := runLocal(cfg, "")
1309 if err == nil {
1310 t.Fatal("expected error from runLocal when pico.sh exits non-zero, got nil")
1311 }
1312
1313 var jobFailedErr *JobFailedError
1314 if !errors.As(err, &jobFailedErr) {
1315 t.Fatalf("expected error of type *JobFailedError, got %T: %v", err, err)
1316 }
1317 if jobFailedErr.ExitCode != 7 {
1318 t.Errorf("expected exit code 7, got %d", jobFailedErr.ExitCode)
1319 }
1320}
1321
1322func TestRunRunner_Wait_ExitCodeFailure(t *testing.T) {
1323 if _, err := exec.LookPath("zmx"); err != nil {
1324 t.Skip("zmx not found, skipping test")
1325 }
1326
1327 workspaceDir := t.TempDir()
1328 picoContent := `#!/usr/bin/env bash
1329exit 13
1330`
1331 if err := os.WriteFile(filepath.Join(workspaceDir, "pico.sh"), []byte(picoContent), 0755); err != nil {
1332 t.Fatal(err)
1333 }
1334
1335 eventJSON := fmt.Sprintf(`{"type":"local","name":"runner-fail-repo","workspace":%q}`, workspaceDir)
1336
1337 artifactDir := t.TempDir()
1338 cfg := &Cfg{
1339 ArtifactDir: artifactDir,
1340 MonitorInterval: 100 * time.Millisecond,
1341 Wait: true,
1342 Event: eventJSON,
1343 Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
1344 NewWorkspace: defaultWorkspaceFactory,
1345 }
1346
1347 err := RunRunner(cfg)
1348 if err == nil {
1349 t.Fatal("expected error from RunRunner --wait on failed job, got nil")
1350 }
1351
1352 var jobFailedErr *JobFailedError
1353 if !errors.As(err, &jobFailedErr) {
1354 t.Fatalf("expected wrapped *JobFailedError, got %T: %v", err, err)
1355 }
1356 if jobFailedErr.ExitCode != 13 {
1357 t.Errorf("expected exit code 13, got %d", jobFailedErr.ExitCode)
1358 }
1359}
1360
1361func TestWorkspaceInPlace(t *testing.T) {
1362 tempDir := t.TempDir()
1363 testFile := filepath.Join(tempDir, "test.txt")
1364 if err := os.WriteFile(testFile, []byte("hello world"), 0644); err != nil {
1365 t.Fatal(err)
1366 }
1367
1368 cfg := &Cfg{
1369 InPlace: true,
1370 }
1371 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
1372 wk := defaultWorkspaceFactory(cfg, logger, tempDir)
1373
1374 if _, ok := wk.(*WorkspaceInPlace); !ok {
1375 t.Fatalf("expected WorkspaceInPlace, got %T", wk)
1376 }
1377
1378 if err := wk.Setup(); err != nil {
1379 t.Fatalf("Setup failed: %v", err)
1380 }
1381
1382 if wk.GetDir() != tempDir {
1383 t.Errorf("expected GetDir() == %q, got %q", tempDir, wk.GetDir())
1384 }
1385
1386 if wk.Checksum() == "" {
1387 t.Errorf("expected non-empty checksum")
1388 }
1389
1390 if err := wk.Cleanup(); err != nil {
1391 t.Fatalf("Cleanup failed: %v", err)
1392 }
1393
1394 if _, err := os.Stat(testFile); os.IsNotExist(err) {
1395 t.Fatalf("expected test file to still exist after Cleanup() on in-place workspace")
1396 }
1397}
1398
1399func TestRunLocal_InPlace(t *testing.T) {
1400 if _, err := exec.LookPath("zmx"); err != nil {
1401 t.Skip("zmx not found, skipping test")
1402 }
1403
1404 tempDir := t.TempDir()
1405 workDir := filepath.Join(tempDir, "inplace-repo")
1406 if err := os.MkdirAll(workDir, 0755); err != nil {
1407 t.Fatal(err)
1408 }
1409 origWd, _ := os.Getwd()
1410 defer func() { _ = os.Chdir(origWd) }()
1411 _ = os.Chdir(workDir)
1412
1413 picoContent := `#!/usr/bin/env bash
1414set -e
1415echo "inplace marker" > marker.txt
1416`
1417 if err := os.WriteFile("pico.sh", []byte(picoContent), 0755); err != nil {
1418 t.Fatal(err)
1419 }
1420
1421 artifactDir := t.TempDir()
1422 cfg := &Cfg{
1423 ArtifactDir: artifactDir,
1424 MonitorInterval: 100 * time.Millisecond,
1425 InPlace: true,
1426 }
1427
1428 err := runLocal(cfg, "")
1429 if err != nil {
1430 t.Fatalf("expected runLocal in-place to succeed, got %v", err)
1431 }
1432
1433 markerPath := filepath.Join(workDir, "marker.txt")
1434 data, err := os.ReadFile(markerPath)
1435 if err != nil {
1436 t.Fatalf("expected marker.txt to be created in working directory: %v", err)
1437 }
1438 if !strings.Contains(string(data), "inplace marker") {
1439 t.Errorf("unexpected marker.txt content: %q", string(data))
1440 }
1441}
1442
1443func TestRunLocal_SummaryFile(t *testing.T) {
1444 if _, err := exec.LookPath("zmx"); err != nil {
1445 t.Skip("zmx not found, skipping test")
1446 }
1447
1448 tempDir := t.TempDir()
1449 workDir := filepath.Join(tempDir, "summary-repo")
1450 if err := os.MkdirAll(workDir, 0755); err != nil {
1451 t.Fatal(err)
1452 }
1453 origWd, _ := os.Getwd()
1454 defer func() { _ = os.Chdir(origWd) }()
1455 _ = os.Chdir(workDir)
1456
1457 picoContent := `#!/usr/bin/env bash
1458echo "hello from step"
1459`
1460 if err := os.WriteFile("pico.sh", []byte(picoContent), 0755); err != nil {
1461 t.Fatal(err)
1462 }
1463
1464 artifactDir := t.TempDir()
1465 summaryFilePath := filepath.Join(workDir, "summary.md")
1466 cfg := &Cfg{
1467 ArtifactDir: artifactDir,
1468 MonitorInterval: 100 * time.Millisecond,
1469 InPlace: true,
1470 SummaryFile: summaryFilePath,
1471 }
1472
1473 err := runLocal(cfg, "")
1474 if err != nil {
1475 t.Fatalf("expected runLocal to succeed, got %v", err)
1476 }
1477
1478 summaryBytes, err := os.ReadFile(summaryFilePath)
1479 if err != nil {
1480 t.Fatalf("expected summary file %s to exist: %v", summaryFilePath, err)
1481 }
1482
1483 summary := string(summaryBytes)
1484 if !strings.Contains(summary, "### Pici CI Summary:") {
1485 t.Errorf("expected summary to contain header, got:\n%s", summary)
1486 }
1487 if !strings.Contains(summary, "| Session | Status | Exit Code | Duration |") {
1488 t.Errorf("expected summary to contain table header, got:\n%s", summary)
1489 }
1490}
1491
1492func TestWriteSummaryMarkdown_WithFailure(t *testing.T) {
1493 tempFile := filepath.Join(t.TempDir(), "summary.md")
1494 sessions := []SessionInfo{
1495 {Short: "setup", Name: "local.repo.job.setup", ExitCode: "0"},
1496 {Short: "failing-step", Name: "local.repo.job.failing-step", ExitCode: "42"},
1497 }
1498 known := map[string]*sessionState{
1499 "setup": {status: "success", exitCode: "0", duration: "1.2s"},
1500 "failing-step": {status: "failed", exitCode: "42", duration: "3.4s"},
1501 }
1502
1503 err := writeSummaryMarkdown(tempFile, "test-repo", "job-123", sessions, known, false, "")
1504 if err != nil {
1505 t.Fatalf("writeSummaryMarkdown failed: %v", err)
1506 }
1507
1508 data, err := os.ReadFile(tempFile)
1509 if err != nil {
1510 t.Fatalf("read summary file: %v", err)
1511 }
1512 content := string(data)
1513
1514 if !strings.Contains(content, "### Pici CI Summary: `test-repo` (Job `job-123`)") {
1515 t.Errorf("missing header in content:\n%s", content)
1516 }
1517 if !strings.Contains(content, "| `failing-step` | ❌ failed | `42` | 3.4s |") {
1518 t.Errorf("missing table row in content:\n%s", content)
1519 }
1520 if !strings.Contains(content, "### Failed Session Details") {
1521 t.Errorf("missing failed details in content:\n%s", content)
1522 }
1523}
1524
1525func TestWriteSummaryMarkdown_WithDebugActive(t *testing.T) {
1526 tempFile := filepath.Join(t.TempDir(), "summary.md")
1527 sessions := []SessionInfo{
1528 {Short: "failing-step", Name: "local.repo.job.failing-step", ExitCode: "42"},
1529 }
1530 known := map[string]*sessionState{
1531 "failing-step": {status: "failed", exitCode: "42", duration: "1.0s"},
1532 }
1533
1534 err := writeSummaryMarkdown(tempFile, "my-repo", "job-456", sessions, known, true, "ci.my-repo.job-456")
1535 if err != nil {
1536 t.Fatalf("writeSummaryMarkdown failed: %v", err)
1537 }
1538
1539 data, err := os.ReadFile(tempFile)
1540 if err != nil {
1541 t.Fatalf("read summary file: %v", err)
1542 }
1543 content := string(data)
1544
1545 if !strings.Contains(content, "### 🔧 Remote Debug Active") {
1546 t.Errorf("expected debug header in summary markdown, got:\n%s", content)
1547 }
1548 if !strings.Contains(content, "ssh -t pipe.pico.sh pipe ci.my-repo.job-456") {
1549 t.Errorf("expected ssh command in summary markdown, got:\n%s", content)
1550 }
1551}
1552
1553func TestScrubEnvironment(t *testing.T) {
1554 envIn := []string{
1555 "PATH=/usr/bin:/bin",
1556 "USER=runner",
1557 "HOME=/home/runner",
1558 "SHELL=/bin/bash",
1559 "TERM=xterm-256color",
1560 "ZMX_SESSION_PREFIX=ci.repo.123.",
1561 "PICI_JOB=123",
1562 "GITHUB_TOKEN=ghp_secret_token_12345",
1563 "ACTIONS_RUNTIME_TOKEN=actions_token_67890",
1564 "MY_APP_SECRET=super_secret_val",
1565 "API_KEY=abc123xyz",
1566 "DATABASE_PASSWORD=secret_db_pass",
1567 "PICO_SSH_KEY=-----BEGIN OPENSSH PRIVATE KEY-----",
1568 "CUSTOM_SAFE_FLAG=1",
1569 }
1570
1571 cleaned := scrubEnvironment(envIn)
1572 joined := strings.Join(cleaned, "\n")
1573
1574 // Verify safe vars remain
1575 for _, expected := range []string{
1576 "PATH=/usr/bin:/bin",
1577 "USER=runner",
1578 "HOME=/home/runner",
1579 "SHELL=/bin/bash",
1580 "ZMX_SESSION_PREFIX=ci.repo.123.",
1581 "PICI_JOB=123",
1582 "CUSTOM_SAFE_FLAG=1",
1583 } {
1584 if !strings.Contains(joined, expected) {
1585 t.Errorf("expected %q to be preserved in environment, got:\n%s", expected, joined)
1586 }
1587 }
1588
1589 // Verify secrets are scrubbed
1590 for _, forbidden := range []string{
1591 "GITHUB_TOKEN",
1592 "ACTIONS_RUNTIME_TOKEN",
1593 "MY_APP_SECRET",
1594 "API_KEY",
1595 "DATABASE_PASSWORD",
1596 "PICO_SSH_KEY",
1597 } {
1598 if strings.Contains(joined, forbidden) {
1599 t.Errorf("expected %q to be SCRUBBED from environment, but found it in:\n%s", forbidden, joined)
1600 }
1601 }
1602}
1603
1604func TestFindSSHAuthMethod_ExplicitKeyAndCert(t *testing.T) {
1605 tempDir := t.TempDir()
1606 keyPath := filepath.Join(tempDir, "id_ed25519")
1607
1608 // Generate a temporary ED25519 key
1609 cmd := exec.Command("ssh-keygen", "-t", "ed25519", "-N", "", "-f", keyPath)
1610 if err := cmd.Run(); err != nil {
1611 t.Skipf("ssh-keygen not available or failed: %v", err)
1612 }
1613
1614 // Test 1: Explicit key path
1615 auth, err := findSSHAuthMethod(keyPath, "")
1616 if err != nil {
1617 t.Fatalf("findSSHAuthMethod failed: %v", err)
1618 }
1619 if auth == nil {
1620 t.Fatal("expected non-nil auth method")
1621 }
1622
1623 // Test 2: Non-existent cert path explicitly specified returns error
1624 _, err = findSSHAuthMethod(keyPath, filepath.Join(tempDir, "nonexistent-cert.pub"))
1625 if err == nil {
1626 t.Fatal("expected error when non-existent certificate is explicitly specified")
1627 }
1628}