package cli import ( "bytes" "context" "encoding/json" "os" "path/filepath" "strings" "testing" "time" "git.cloudinit.dev/coreci/orca/internal/model" "git.cloudinit.dev/coreci/orca/internal/store" ) func TestWatchJobs_JSONStreaming(t *testing.T) { dir := t.TempDir() dbPath := filepath.Join(dir, "orca.db") db, err := store.Open(dbPath) if err != nil { t.Fatalf("open db: %v", err) } defer db.Close() repo := store.NewJobRepo(db) bgCtx := context.Background() _ = repo.Insert(bgCtx, &model.Job{ID: "seed-job", Name: "seed", Spec: "t", Status: model.JobStatusPending}) t.Setenv("ORCA_DB", dbPath) jsonOutput = true t.Cleanup(func() { jsonOutput = false }) var buf bytes.Buffer rootCmd.SetOut(&buf) rootCmd.SetErr(&buf) t.Cleanup(func() { rootCmd.SetOut(os.Stdout); rootCmd.SetErr(os.Stderr) }) ctx, cancel := context.WithCancel(bgCtx) done := make(chan error, 1) go func() { done <- watchJobsCtx(rootCmd, ctx) }() // First yield is immediate (G-002); wait for it. time.Sleep(100 * time.Millisecond) _ = repo.Insert(bgCtx, &model.Job{ID: "watch-job", Name: "watch", Spec: "t", Status: model.JobStatusPending}) // Wait for at least one ticker interval (default 1s) to capture the change. time.Sleep(1100 * time.Millisecond) cancel() select { case <-done: case <-time.After(2 * time.Second): t.Fatal("watchJobsCtx did not return within 2s after cancel") } output := buf.String() if !strings.Contains(output, `"event":"init"`) { t.Errorf("expected init event, got: %s", output) } if !strings.Contains(output, "watch-job") { t.Errorf("expected watch-job in output, got: %s", output) } } func TestWatchJobs_TableRefresh(t *testing.T) { dir := t.TempDir() dbPath := filepath.Join(dir, "orca.db") db, err := store.Open(dbPath) if err != nil { t.Fatalf("open db: %v", err) } defer db.Close() repo := store.NewJobRepo(db) bgCtx := context.Background() _ = repo.Insert(bgCtx, &model.Job{ID: "seed-job", Name: "seed", Spec: "t", Status: model.JobStatusPending}) t.Setenv("ORCA_DB", dbPath) jsonOutput = false t.Cleanup(func() { jsonOutput = false }) var buf bytes.Buffer rootCmd.SetOut(&buf) rootCmd.SetErr(&buf) t.Cleanup(func() { rootCmd.SetOut(os.Stdout); rootCmd.SetErr(os.Stderr) }) ctx, cancel := context.WithCancel(bgCtx) done := make(chan error, 1) go func() { done <- watchJobsCtx(rootCmd, ctx) }() time.Sleep(100 * time.Millisecond) _ = repo.Insert(bgCtx, &model.Job{ID: "table-job", Name: "table", Spec: "t", Status: model.JobStatusPending}) time.Sleep(1100 * time.Millisecond) cancel() select { case <-done: case <-time.After(2 * time.Second): t.Fatal("watchJobsCtx did not return within 2s after cancel") } output := buf.String() if !strings.Contains(output, "\033[2J\033[H") { t.Errorf("expected clear-screen escape in table watch output, got: %s", output) } if !strings.Contains(output, "table-job") { t.Errorf("expected table-job in output, got: %s", output) } } func TestWatchNodes_JSONStreaming(t *testing.T) { dir := t.TempDir() dbPath := filepath.Join(dir, "orca.db") db, err := store.Open(dbPath) if err != nil { t.Fatalf("open db: %v", err) } defer db.Close() repo := store.NewNodeRepo(db) bgCtx := context.Background() _ = repo.Insert(bgCtx, &model.Node{ ID: "seed-node", Name: "seed", Address: "addr", State: model.NodeStateReady, JoinedAt: time.Now().UTC(), LastSeen: time.Now().UTC(), }) t.Setenv("ORCA_DB", dbPath) jsonOutput = true t.Cleanup(func() { jsonOutput = false }) var buf bytes.Buffer rootCmd.SetOut(&buf) rootCmd.SetErr(&buf) t.Cleanup(func() { rootCmd.SetOut(os.Stdout); rootCmd.SetErr(os.Stderr) }) ctx, cancel := context.WithCancel(bgCtx) done := make(chan error, 1) go func() { done <- watchNodesCtx(rootCmd, ctx) }() time.Sleep(100 * time.Millisecond) _ = repo.Insert(bgCtx, &model.Node{ ID: "watch-node", Name: "watch", Address: "addr2", State: model.NodeStateReady, JoinedAt: time.Now().UTC(), LastSeen: time.Now().UTC(), }) time.Sleep(1100 * time.Millisecond) cancel() select { case <-done: case <-time.After(2 * time.Second): t.Fatal("watchNodesCtx did not return within 2s after cancel") } output := buf.String() initFound := false watchNodeFound := false for _, line := range strings.Split(output, "\n") { line = strings.TrimSpace(line) if line == "" { continue } var event map[string]any if err := json.Unmarshal([]byte(line), &event); err != nil { continue } if event["event"] == "init" { initFound = true if node, ok := event["node"].(map[string]any); ok { if node["id"] == "watch-node" { watchNodeFound = true } } } } if !initFound { t.Errorf("expected init event in JSON stream, got: %s", output) } if !watchNodeFound { t.Errorf("expected watch-node in JSON stream, got: %s", output) } } func TestWatchNodes_TableRefresh(t *testing.T) { dir := t.TempDir() dbPath := filepath.Join(dir, "orca.db") db, err := store.Open(dbPath) if err != nil { t.Fatalf("open db: %v", err) } defer db.Close() repo := store.NewNodeRepo(db) bgCtx := context.Background() _ = repo.Insert(bgCtx, &model.Node{ ID: "seed-node", Name: "seed", Address: "addr", State: model.NodeStateReady, JoinedAt: time.Now().UTC(), LastSeen: time.Now().UTC(), }) t.Setenv("ORCA_DB", dbPath) jsonOutput = false t.Cleanup(func() { jsonOutput = false }) var buf bytes.Buffer rootCmd.SetOut(&buf) rootCmd.SetErr(&buf) t.Cleanup(func() { rootCmd.SetOut(os.Stdout); rootCmd.SetErr(os.Stderr) }) ctx, cancel := context.WithCancel(bgCtx) done := make(chan error, 1) go func() { done <- watchNodesCtx(rootCmd, ctx) }() time.Sleep(100 * time.Millisecond) _ = repo.Insert(bgCtx, &model.Node{ ID: "table-node", Name: "table", Address: "addr2", State: model.NodeStateReady, JoinedAt: time.Now().UTC(), LastSeen: time.Now().UTC(), }) time.Sleep(1100 * time.Millisecond) cancel() select { case <-done: case <-time.After(2 * time.Second): t.Fatal("watchNodesCtx did not return within 2s after cancel") } output := buf.String() if !strings.Contains(output, "\033[2J\033[H") { t.Errorf("expected clear-screen escape in table watch output, got: %s", output) } if !strings.Contains(output, "table-node") { t.Errorf("expected table-node in output, got: %s", output) } } func TestRenderJobTable(t *testing.T) { jobs := []*model.Job{ {ID: "j1", Name: "alpha", Status: "running", ExitCode: 0}, {ID: "j2", Name: "beta", Status: "done", ExitCode: 0}, } out := renderJobTable(jobs) if !strings.Contains(out, "j1") || !strings.Contains(out, "alpha") { t.Errorf("renderJobTable missing job 1: %s", out) } if !strings.Contains(out, "j2") || !strings.Contains(out, "beta") { t.Errorf("renderJobTable missing job 2: %s", out) } } func TestRenderJobTableEmpty(t *testing.T) { out := renderJobTable(nil) if !strings.Contains(out, "No jobs") { t.Errorf("expected empty message, got: %s", out) } } func TestRenderNodeTable(t *testing.T) { nodes := []*model.Node{ {ID: "n1", Name: "alpha", Address: "localhost:8443", State: "ready"}, } out := renderNodeTable(nodes) if !strings.Contains(out, "n1") || !strings.Contains(out, "alpha") { t.Errorf("renderNodeTable missing node: %s", out) } } func TestRenderNodeTableEmpty(t *testing.T) { out := renderNodeTable(nil) if !strings.Contains(out, "No nodes") { t.Errorf("expected empty message, got: %s", out) } }