open-code-review/cmd/opencodereview/session_cmd_test.go
kevin 216dcf5135
Some checks failed
CI / test (push) Has been cancelled
CI / cross-compile (arm64, windows) (push) Has been cancelled
CI / windows (push) Has been cancelled
CI / cross-compile (amd64, darwin) (push) Has been cancelled
CI / cross-compile (amd64, windows) (push) Has been cancelled
CI / cross-compile (arm64, darwin) (push) Has been cancelled
CI / cross-compile (arm64, linux) (push) Has been cancelled
CodeQL Advanced / Analyze (go) (push) Has been cancelled
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
fix(session): print an empty items array from session show --json (#1792)
Signed-off-by: kevin9327 <5299031+kevin9327@users.noreply.github.com>
2026-10-30 14:18:11 +08:00

1002 lines
33 KiB
Go

// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 alibaba/open-code-review Contributors
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/alibaba/open-code-review/internal/model"
"github.com/alibaba/open-code-review/internal/session"
"github.com/spf13/cobra"
)
func TestRunSessionList_TextIncludesSessionID(t *testing.T) {
tmpHome := t.TempDir()
setTestHome(t, tmpHome)
repoDir := t.TempDir()
sh := session.New(repoDir, "main", "test-model", session.SessionOptions{
ReviewMode: session.ReviewModeCommit,
DiffCommit: "abc123",
})
sh.RecordReviewItemDone("a.go", "a.go", "a.go", "fp-a", []model.LlmComment{{Path: "a.go", Content: "note"}})
sh.Finalize()
got := captureStdout(t, func() {
if err := runSessionListCompat([]string{"--repo", repoDir}); err != nil {
t.Fatalf("runSessionList: %v", err)
}
})
if !strings.Contains(got, sh.SessionID) {
t.Errorf("expected list output to contain session id %s, got %q", sh.SessionID, got)
}
if !strings.Contains(got, "abc123") {
t.Errorf("expected list output to contain commit range, got %q", got)
}
if !strings.Contains(got, "SESSION ID") {
t.Errorf("expected header, got %q", got)
}
}
func TestRunSessionList_JSON(t *testing.T) {
tmpHome := t.TempDir()
setTestHome(t, tmpHome)
repoDir := t.TempDir()
sh := session.New(repoDir, "main", "test-model", session.SessionOptions{
ReviewMode: session.ReviewModeCommit,
DiffCommit: "abc123",
})
sh.RecordReviewItemDone("a.go", "a.go", "a.go", "fp-a", nil)
sh.Finalize()
got := captureStdout(t, func() {
if err := runSessionListCompat([]string{"--repo", repoDir, "--json"}); err != nil {
t.Fatalf("runSessionList: %v", err)
}
})
var decoded []session.Summary
if err := json.Unmarshal([]byte(got), &decoded); err != nil {
t.Fatalf("unmarshal: %v (out=%q)", err, got)
}
if len(decoded) != 1 || decoded[0].SessionID != sh.SessionID {
t.Fatalf("decoded = %+v", decoded)
}
}
func TestRunSessionList_EmptyRepo(t *testing.T) {
tmpHome := t.TempDir()
setTestHome(t, tmpHome)
repoDir := t.TempDir()
got := captureStdout(t, func() {
if err := runSessionListCompat([]string{"--repo", repoDir}); err != nil {
t.Fatalf("runSessionList: %v", err)
}
})
if !strings.Contains(got, "No sessions found") {
t.Errorf("expected empty message, got %q", got)
}
}
func TestRunSessionShow_Text(t *testing.T) {
tmpHome := t.TempDir()
setTestHome(t, tmpHome)
repoDir := t.TempDir()
sh := session.New(repoDir, "main", "test-model", session.SessionOptions{
ReviewMode: session.ReviewModeCommit,
DiffCommit: "abc123",
})
sh.RecordReviewItemDone("a.go", "a.go", "a.go", "fp-a", []model.LlmComment{{Path: "a.go", Content: "note"}})
sh.RecordReviewItemFailed("bad.go", "bad.go", "bad.go", "fp-bad", "boom")
sh.Finalize()
got := captureStdout(t, func() {
if err := runSessionShowCompat([]string{"--repo", repoDir, sh.SessionID}); err != nil {
t.Fatalf("runSessionShow: %v", err)
}
})
for _, want := range []string{sh.SessionID, "abc123", "a.go", "bad.go", "boom", "Files:"} {
if !strings.Contains(got, want) {
t.Errorf("expected output to contain %q, got %q", want, got)
}
}
}
func TestRunSessionShow_JSON(t *testing.T) {
tmpHome := t.TempDir()
setTestHome(t, tmpHome)
repoDir := t.TempDir()
sh := session.New(repoDir, "main", "test-model", session.SessionOptions{
ReviewMode: session.ReviewModeCommit,
DiffCommit: "abc123",
})
sh.RecordReviewItemDone("a.go", "a.go", "a.go", "fp-a", nil)
sh.Finalize()
got := captureStdout(t, func() {
if err := runSessionShowCompat([]string{"--repo", repoDir, "--json", sh.SessionID}); err != nil {
t.Fatalf("runSessionShow: %v", err)
}
})
var payload struct {
Summary *session.Summary `json:"summary"`
Items []session.ItemDetail `json:"items"`
}
if err := json.Unmarshal([]byte(got), &payload); err != nil {
t.Fatalf("unmarshal: %v (out=%q)", err, got)
}
if payload.Summary == nil || payload.Summary.SessionID != sh.SessionID {
t.Fatalf("summary mismatch: %+v", payload.Summary)
}
if len(payload.Items) != 1 || payload.Items[0].FilePath != "a.go" {
t.Fatalf("items = %+v", payload.Items)
}
}
func TestRunSessionComments_TextRendersLikeReview(t *testing.T) {
tmpHome := t.TempDir()
setTestHome(t, tmpHome)
repoDir := t.TempDir()
sh := session.New(repoDir, "main", "test-model", session.SessionOptions{
ReviewMode: session.ReviewModeCommit,
DiffCommit: "abc123",
})
sh.RecordReviewItemDone("a.go", "a.go", "a.go", "fp-a", []model.LlmComment{
{Path: "a.go", Content: "possible nil deref", StartLine: 3, EndLine: 5, Severity: "high", Category: "bug"},
})
sh.Finalize()
got := captureStdout(t, func() {
if err := runSessionCommentsCompat([]string{"--repo", repoDir, sh.SessionID}); err != nil {
t.Fatalf("runSessionComments: %v", err)
}
})
for _, want := range []string{"a.go:3-5", "[bug · high]", "possible nil deref"} {
if !strings.Contains(got, want) {
t.Errorf("expected output to contain %q, got %q", want, got)
}
}
}
func TestRunSessionComments_SeverityFilter(t *testing.T) {
tmpHome := t.TempDir()
setTestHome(t, tmpHome)
repoDir := t.TempDir()
sh := session.New(repoDir, "main", "test-model", session.SessionOptions{
ReviewMode: session.ReviewModeCommit,
DiffCommit: "abc123",
})
sh.RecordReviewItemDone("a.go", "a.go", "a.go", "fp-a", []model.LlmComment{
{Path: "a.go", Content: "keep me", Severity: "high"},
{Path: "a.go", Content: "drop me", Severity: "low"},
})
sh.Finalize()
got := captureStdout(t, func() {
if err := runSessionCommentsCompat([]string{"--repo", repoDir, "--severity", "HIGH", sh.SessionID}); err != nil {
t.Fatalf("runSessionComments: %v", err)
}
})
if !strings.Contains(got, "keep me") || strings.Contains(got, "drop me") {
t.Errorf("severity filter not applied, got %q", got)
}
got = captureStdout(t, func() {
if err := runSessionCommentsCompat([]string{"--repo", repoDir, "--severity", "critical", sh.SessionID}); err != nil {
t.Fatalf("runSessionComments: %v", err)
}
})
if !strings.Contains(got, "No comments match the given filters") {
t.Errorf("expected filter-miss message, got %q", got)
}
}
func TestRunSessionComments_JSON(t *testing.T) {
tmpHome := t.TempDir()
setTestHome(t, tmpHome)
repoDir := t.TempDir()
sh := session.New(repoDir, "main", "test-model", session.SessionOptions{
ReviewMode: session.ReviewModeCommit,
DiffCommit: "abc123",
})
sh.RecordReviewItemDone("a.go", "a.go", "a.go", "fp-a", []model.LlmComment{
{Path: "a.go", Content: "note", Severity: "medium", Category: "style"},
})
sh.Finalize()
got := captureStdout(t, func() {
if err := runSessionCommentsCompat([]string{"--repo", repoDir, "--json", sh.SessionID}); err != nil {
t.Fatalf("runSessionComments: %v", err)
}
})
var decoded []model.LlmComment
if err := json.Unmarshal([]byte(got), &decoded); err != nil {
t.Fatalf("unmarshal: %v (out=%q)", err, got)
}
if len(decoded) != 1 || decoded[0].Content != "note" || decoded[0].Severity != "medium" {
t.Fatalf("decoded = %+v", decoded)
}
}
func TestRunSessionShow_JSONNoItemsIsArray(t *testing.T) {
tmpHome := t.TempDir()
setTestHome(t, tmpHome)
repoDir := t.TempDir()
// A run that ends before any file is reviewed records no items.
sh := session.New(repoDir, "main", "test-model", session.SessionOptions{
ReviewMode: session.ReviewModeCommit,
DiffCommit: "abc123",
})
sh.Finalize()
got := captureStdout(t, func() {
if err := runSessionShowCompat([]string{"--repo", repoDir, "--json", sh.SessionID}); err != nil {
t.Fatalf("runSessionShow: %v", err)
}
})
// json.Unmarshal accepts null into a slice, so only the raw text pins the wire shape.
var raw map[string]json.RawMessage
if err := json.Unmarshal([]byte(got), &raw); err != nil {
t.Fatalf("unmarshal: %v (out=%q)", err, got)
}
if string(raw["items"]) != "[]" {
t.Errorf("items = %s, want []", raw["items"])
}
}
func TestRunSessionComments_JSONEmptyIsArray(t *testing.T) {
tmpHome := t.TempDir()
setTestHome(t, tmpHome)
repoDir := t.TempDir()
sh := session.New(repoDir, "main", "test-model", session.SessionOptions{
ReviewMode: session.ReviewModeCommit,
DiffCommit: "abc123",
})
sh.RecordReviewItemDone("a.go", "a.go", "a.go", "fp-a", nil)
sh.Finalize()
got := captureStdout(t, func() {
if err := runSessionCommentsCompat([]string{"--repo", repoDir, "--json", sh.SessionID}); err != nil {
t.Fatalf("runSessionComments: %v", err)
}
})
if strings.TrimSpace(got) != "[]" {
t.Errorf("expected empty JSON array, got %q", got)
}
}
func TestRunSessionComments_NoCommentsMessage(t *testing.T) {
tmpHome := t.TempDir()
setTestHome(t, tmpHome)
repoDir := t.TempDir()
sh := session.New(repoDir, "main", "test-model", session.SessionOptions{
ReviewMode: session.ReviewModeCommit,
DiffCommit: "abc123",
})
sh.RecordReviewItemDone("a.go", "a.go", "a.go", "fp-a", nil)
sh.Finalize()
got := captureStdout(t, func() {
if err := runSessionCommentsCompat([]string{"--repo", repoDir, sh.SessionID}); err != nil {
t.Fatalf("runSessionComments: %v", err)
}
})
if !strings.Contains(got, "No comments recorded in session") {
t.Errorf("expected no-comments message, got %q", got)
}
}
func TestRunSessionShow_MissingID(t *testing.T) {
tmpHome := t.TempDir()
setTestHome(t, tmpHome)
err := runSessionShowCompat([]string{})
if err == nil {
t.Fatal("expected error for missing session id")
}
}
func TestTruncateUnicode(t *testing.T) {
got := truncate("错误原因:超过限制", 6) // allow-non-english: fixture exercises rune-boundary truncation
if !strings.HasSuffix(got, "…") {
t.Fatalf("expected ellipsis suffix, got %q", got)
}
if !strings.Contains(got, "错误") { // allow-non-english: fixture exercises rune-boundary truncation
t.Fatalf("expected valid truncated unicode text, got %q", got)
}
}
// TestTruncate covers the remaining branches of truncate: newline/tab
// normalization, the short-enough pass-through, and the n<=1 ellipsis-only case.
func TestTruncate(t *testing.T) {
cases := []struct {
name string
s string
n int
want string
}{
{"shorter than limit is unchanged", "abc", 10, "abc"},
{"newlines and tabs become spaces", "a\nb\tc", 10, "a b c"},
{"n of one collapses to ellipsis", "abcdef", 1, "…"},
{"n of zero collapses to ellipsis", "abcdef", 0, "…"},
{"exact length is unchanged", "abcd", 4, "abcd"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := truncate(tc.s, tc.n); got != tc.want {
t.Errorf("truncate(%q, %d) = %q, want %q", tc.s, tc.n, got, tc.want)
}
})
}
}
func TestRunSession_UnknownSubcommand(t *testing.T) {
err := runSession([]string{"bogus"})
if err == nil {
t.Fatal("expected error for unknown sub-command")
}
}
func TestSessionDisplayUsesManifestStatusAndCoverage(t *testing.T) {
summary := session.Summary{
SessionID: "run-1",
SelectedFiles: 4,
CompletedFiles: 1,
ReusedFiles: 1,
FailedFiles: 1,
WaivedFiles: 1,
RunManifest: &session.RunManifest{
TerminalState: session.StatePartial,
},
}
if got := describeStatus(summary); got != "partial" {
t.Fatalf("status = %q", got)
}
if got := describeFiles(summary); !strings.Contains(got, "4") || !strings.Contains(got, "failed 1") || !strings.Contains(got, "waived 1") {
t.Fatalf("files = %q", got)
}
got := captureStdout(t, func() { printSessionDetail(os.Stdout, &summary, nil) })
if !strings.Contains(got, "4 selected = 1 completed + 1 reused + 1 failed + 1 waived") {
t.Fatalf("detail = %q", got)
}
}
func TestSessionDisplayUsesUnknownForInvalidManifestStatus(t *testing.T) {
for _, state := range []session.TerminalState{"", "bogus"} {
summary := session.Summary{
RunManifest: &session.RunManifest{TerminalState: state},
}
if got := describeStatus(summary); got != "unknown" {
t.Errorf("terminal state %q displayed as %q, want unknown", state, got)
}
}
}
func TestSessionDisplayDoesNotInferLegacyComplete(t *testing.T) {
summary := session.Summary{CompletedFiles: 2, Legacy: true}
if got := describeStatus(summary); got != "legacy" {
t.Fatalf("status = %q", got)
}
summary.Aborted = true
if got := describeStatus(summary); got != "aborted" {
t.Fatalf("status = %q", got)
}
}
// newCompareSession records one session with the given findings and returns it.
func newCompareSession(t *testing.T, repoDir string, opts session.SessionOptions, comments []model.LlmComment) *session.SessionHistory {
t.Helper()
sh := session.New(repoDir, "main", "test-model", opts)
byFile := map[string][]model.LlmComment{}
var order []string
for _, c := range comments {
if _, seen := byFile[c.Path]; !seen {
order = append(order, c.Path)
}
byFile[c.Path] = append(byFile[c.Path], c)
}
for _, path := range order {
sh.RecordReviewItemDone(path, path, path, "fp-"+path, byFile[path])
}
return sh
}
// useCompareRepo points the compare command at repoDir and restores the shared
// command flags afterwards.
func useCompareRepo(t *testing.T, repoDir string, asJSON bool) {
t.Helper()
sessionCompareRepoDir = repoDir
sessionCompareJSON = asJSON
t.Cleanup(func() {
sessionCompareRepoDir = ""
sessionCompareJSON = false
})
}
func TestRunSessionCompare_TextShowsCounts(t *testing.T) {
t.Setenv("HOME", t.TempDir())
repoDir := t.TempDir()
opts := session.SessionOptions{ReviewMode: session.ReviewModeCommit, DiffCommit: "abc123"}
before := newCompareSession(t, repoDir, opts, []model.LlmComment{
{Path: "a.go", StartLine: 40, EndLine: 40, Category: "bug", ExistingCode: "x := 1", Content: "still here"},
{Path: "a.go", StartLine: 70, EndLine: 70, Category: "bug", ExistingCode: "y := 2", Content: "went away"},
})
before.Finalize()
after := newCompareSession(t, repoDir, opts, []model.LlmComment{
{Path: "a.go", StartLine: 52, EndLine: 52, Category: "bug", ExistingCode: "\tx := 1", Content: "still here"},
{Path: "b.go", StartLine: 3, EndLine: 3, Category: "style", ExistingCode: "z := 3", Content: "brand new"},
})
after.Finalize()
useCompareRepo(t, repoDir, false)
got := captureStdout(t, func() {
if err := runSessionCompare(before.SessionID, after.SessionID); err != nil {
t.Fatalf("runSessionCompare: %v", err)
}
})
for _, want := range []string{
"1 new, 1 persisting, 1 resolved",
"=== New (1) ===",
"=== Persisting (1) ===",
"=== Resolved (1) ===",
"abc123",
"brand new",
"went away",
} {
if !strings.Contains(got, want) {
t.Errorf("expected output to contain %q, got %q", want, got)
}
}
if strings.Contains(got, "did not review") {
t.Errorf("unexpected not-reviewed line for a legacy session pair: %q", got)
}
}
func TestRunSessionCompare_JSONShape(t *testing.T) {
t.Setenv("HOME", t.TempDir())
repoDir := t.TempDir()
opts := session.SessionOptions{ReviewMode: session.ReviewModeRange, DiffFrom: "main", DiffTo: "HEAD"}
before := newCompareSession(t, repoDir, opts, []model.LlmComment{
{Path: "a.go", StartLine: 40, EndLine: 40, Category: "bug", ExistingCode: "x := 1", Content: "still here"},
{Path: "a.go", StartLine: 70, EndLine: 70, Category: "bug", ExistingCode: "y := 2", Content: "went away"},
})
before.Finalize()
after := newCompareSession(t, repoDir, opts, []model.LlmComment{
{Path: "a.go", StartLine: 52, EndLine: 52, Category: "bug", ExistingCode: "x := 1", Content: "still here"},
{Path: "b.go", StartLine: 3, EndLine: 3, Category: "style", ExistingCode: "z := 3", Content: "brand new"},
})
after.Finalize()
useCompareRepo(t, repoDir, true)
out := captureStdout(t, func() {
if err := runSessionCompare(before.SessionID, after.SessionID); err != nil {
t.Fatalf("runSessionCompare: %v", err)
}
})
var decoded struct {
Before sessionCompareSide `json:"before"`
After sessionCompareSide `json:"after"`
New []model.LlmComment `json:"new"`
Persisting []model.LlmComment `json:"persisting"`
Resolved []model.LlmComment `json:"resolved"`
NotReviewed []model.LlmComment `json:"not_reviewed"`
}
if err := json.Unmarshal([]byte(out), &decoded); err != nil {
t.Fatalf("unmarshal: %v (out=%q)", err, out)
}
if decoded.Before.SessionID != before.SessionID || decoded.After.SessionID != after.SessionID {
t.Fatalf("sides = %+v / %+v", decoded.Before, decoded.After)
}
if decoded.Before.Range != "main..HEAD" || decoded.After.ReviewMode != session.ReviewModeRange {
t.Fatalf("range/mode = %+v / %+v", decoded.Before, decoded.After)
}
if len(decoded.New) != 1 || decoded.New[0].Path != "b.go" {
t.Fatalf("new = %+v", decoded.New)
}
if len(decoded.Persisting) != 1 || decoded.Persisting[0].StartLine != 52 {
t.Fatalf("persisting = %+v (want the after copy at line 52)", decoded.Persisting)
}
if len(decoded.Resolved) != 1 || decoded.Resolved[0].StartLine != 70 {
t.Fatalf("resolved = %+v", decoded.Resolved)
}
if len(decoded.NotReviewed) != 0 {
t.Fatalf("not_reviewed = %+v", decoded.NotReviewed)
}
}
func TestRunSessionCompare_RenamedFileFindingPersists(t *testing.T) {
t.Setenv("HOME", t.TempDir())
repoDir := t.TempDir()
opts := session.SessionOptions{ReviewMode: session.ReviewModeCommit, DiffCommit: "abc123"}
before := newCompareSession(t, repoDir, opts, []model.LlmComment{
{Path: "old/name.go", StartLine: 7, EndLine: 7, Category: "bug", ExistingCode: "x := 1", Content: "still here"},
})
before.Finalize()
after := newCompareSession(t, repoDir, opts, []model.LlmComment{
{Path: "new/name.go", StartLine: 19, EndLine: 19, Category: "bug", ExistingCode: "x := 1", Content: "still here"},
})
after.SetFinalManifest(&session.RunManifest{
SchemaVersion: session.ManifestSchemaVersion,
RunID: after.SessionID,
Operation: session.OperationReview,
TerminalState: session.StateComplete,
Coverage: session.Coverage{
Selected: []session.CoverageItem{{ItemID: "renamed", Path: "new/name.go", OldPath: "old/name.go"}},
Completed: []session.CoverageItem{{ItemID: "renamed", Path: "new/name.go", OldPath: "old/name.go"}},
},
})
after.Finalize()
useCompareRepo(t, repoDir, true)
out := captureStdout(t, func() {
if err := runSessionCompare(before.SessionID, after.SessionID); err != nil {
t.Fatalf("runSessionCompare: %v", err)
}
})
var decoded session.CompareResult
if err := json.Unmarshal([]byte(out), &decoded); err != nil {
t.Fatalf("unmarshal: %v (out=%q)", err, out)
}
if len(decoded.New) != 0 || len(decoded.Resolved) != 0 || len(decoded.NotReviewed) != 0 {
t.Fatalf("unexpected buckets: %+v", decoded)
}
if len(decoded.Persisting) != 1 || decoded.Persisting[0].Path != "new/name.go" || decoded.Persisting[0].StartLine != 19 {
t.Fatalf("persisting = %+v, want the after copy at new/name.go:19", decoded.Persisting)
}
}
func TestRunSessionCompare_JSONEmptyBucketsAreArrays(t *testing.T) {
t.Setenv("HOME", t.TempDir())
repoDir := t.TempDir()
opts := session.SessionOptions{ReviewMode: session.ReviewModeCommit, DiffCommit: "abc123"}
before := newCompareSession(t, repoDir, opts, nil)
before.Finalize()
after := newCompareSession(t, repoDir, opts, nil)
after.Finalize()
useCompareRepo(t, repoDir, true)
out := captureStdout(t, func() {
if err := runSessionCompare(before.SessionID, after.SessionID); err != nil {
t.Fatalf("runSessionCompare: %v", err)
}
})
var raw map[string]json.RawMessage
if err := json.Unmarshal([]byte(out), &raw); err != nil {
t.Fatalf("unmarshal: %v (out=%q)", err, out)
}
for _, bucket := range []string{"new", "persisting", "resolved", "not_reviewed"} {
got, ok := raw[bucket]
if !ok {
t.Fatalf("missing bucket %q in %q", bucket, out)
}
if string(got) != "[]" {
t.Errorf("%s = %s, want []", bucket, got)
}
}
}
func TestRunSessionCompare_CrossRepoRefuses(t *testing.T) {
t.Setenv("HOME", t.TempDir())
repoA := t.TempDir()
repoB := t.TempDir()
opts := session.SessionOptions{ReviewMode: session.ReviewModeCommit, DiffCommit: "abc123"}
local := newCompareSession(t, repoA, opts, nil)
local.Finalize()
foreign := newCompareSession(t, repoB, opts, nil)
foreign.Finalize()
// Simulate a session file copied in from another checkout: it lands in this
// repo's session dir but still records the other repo as its cwd.
dirA, err := session.SessionsDir(repoA)
if err != nil {
t.Fatalf("SessionsDir: %v", err)
}
dirB, err := session.SessionsDir(repoB)
if err != nil {
t.Fatalf("SessionsDir: %v", err)
}
data, err := os.ReadFile(filepath.Join(dirB, foreign.SessionID+".jsonl"))
if err != nil {
t.Fatalf("read foreign session: %v", err)
}
if err := os.WriteFile(filepath.Join(dirA, foreign.SessionID+".jsonl"), data, 0o600); err != nil {
t.Fatalf("write foreign session: %v", err)
}
useCompareRepo(t, repoA, false)
err = runSessionCompare(local.SessionID, foreign.SessionID)
if err == nil {
t.Fatal("expected an error comparing sessions from different repositories")
}
for _, want := range []string{"different repositories", repoA, repoB} {
if !strings.Contains(err.Error(), want) {
t.Errorf("error %q should mention %q", err, want)
}
}
}
func TestRunSessionCompare_ModeMismatchWarnsNotFails(t *testing.T) {
t.Setenv("HOME", t.TempDir())
repoDir := t.TempDir()
before := newCompareSession(t, repoDir, session.SessionOptions{ReviewMode: session.ReviewModeCommit, DiffCommit: "abc123"}, nil)
before.Finalize()
after := newCompareSession(t, repoDir, session.SessionOptions{ReviewMode: session.ReviewModeRange, DiffFrom: "main", DiffTo: "HEAD"}, nil)
after.Finalize()
useCompareRepo(t, repoDir, true)
var stdout string
stderr := captureStderr(t, func() {
stdout = captureStdout(t, func() {
if err := runSessionCompare(before.SessionID, after.SessionID); err != nil {
t.Errorf("runSessionCompare: %v", err)
}
})
})
if !strings.Contains(stderr, "review modes differ") {
t.Errorf("expected a stderr warning, got %q", stderr)
}
if strings.Contains(stdout, "review modes differ") {
t.Errorf("the warning must not reach stdout, got %q", stdout)
}
if err := json.Unmarshal([]byte(stdout), &map[string]json.RawMessage{}); err != nil {
t.Errorf("stdout is not valid JSON: %v (out=%q)", err, stdout)
}
}
func TestRunSessionCompare_MalformedSessionID(t *testing.T) {
t.Setenv("HOME", t.TempDir())
repoDir := t.TempDir()
sh := newCompareSession(t, repoDir, session.SessionOptions{ReviewMode: session.ReviewModeCommit, DiffCommit: "abc123"}, nil)
sh.Finalize()
useCompareRepo(t, repoDir, false)
tests := []struct {
name string
before, after string
}{
{"empty before", "", sh.SessionID},
{"empty after", sh.SessionID, ""},
{"unknown before", "11111111-2222-3333-4444-555555555555", sh.SessionID},
{"unknown after", sh.SessionID, "11111111-2222-3333-4444-555555555555"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := runSessionCompare(tt.before, tt.after); err == nil {
t.Fatal("expected an error")
}
})
}
}
func TestRunSessionCompare_SameSessionTwice(t *testing.T) {
t.Setenv("HOME", t.TempDir())
repoDir := t.TempDir()
sh := newCompareSession(t, repoDir, session.SessionOptions{ReviewMode: session.ReviewModeCommit, DiffCommit: "abc123"}, []model.LlmComment{
{Path: "a.go", StartLine: 40, EndLine: 40, Category: "bug", ExistingCode: "x := 1", Content: "one"},
{Path: "b.go", StartLine: 5, EndLine: 5, Category: "style", ExistingCode: "y := 2", Content: "two"},
})
sh.Finalize()
useCompareRepo(t, repoDir, false)
got := captureStdout(t, func() {
if err := runSessionCompare(sh.SessionID, sh.SessionID); err != nil {
t.Fatalf("runSessionCompare: %v", err)
}
})
if !strings.Contains(got, "0 new, 2 persisting, 0 resolved") {
t.Errorf("comparing a session with itself should be all persisting, got %q", got)
}
}
func TestRunSessionCompare_UnreviewedFilesAreNotResolved(t *testing.T) {
t.Setenv("HOME", t.TempDir())
repoDir := t.TempDir()
opts := session.SessionOptions{ReviewMode: session.ReviewModeCommit, DiffCommit: "abc123"}
before := newCompareSession(t, repoDir, opts, []model.LlmComment{
{Path: "a.go", StartLine: 40, EndLine: 40, Category: "bug", ExistingCode: "x := 1", Content: "in the reviewed file"},
{Path: "untouched.go", StartLine: 9, EndLine: 9, Category: "bug", ExistingCode: "y := 2", Content: "in a file the rerun skipped"},
})
before.Finalize()
after := newCompareSession(t, repoDir, opts, nil)
after.SetFinalManifest(&session.RunManifest{
SchemaVersion: session.ManifestSchemaVersion,
RunID: after.SessionID,
Operation: "review",
TerminalState: session.StateComplete,
Coverage: session.Coverage{
Selected: []session.CoverageItem{{ItemID: "i1", Path: "a.go"}},
Completed: []session.CoverageItem{{ItemID: "i1", Path: "a.go"}},
},
})
after.Finalize()
useCompareRepo(t, repoDir, false)
got := captureStdout(t, func() {
if err := runSessionCompare(before.SessionID, after.SessionID); err != nil {
t.Fatalf("runSessionCompare: %v", err)
}
})
for _, want := range []string{
"0 new, 0 persisting, 1 resolved",
"1 finding(s) in files the after session did not review (not counted as resolved)",
"=== Not reviewed (1) ===",
"untouched.go",
} {
if !strings.Contains(got, want) {
t.Errorf("expected output to contain %q, got %q", want, got)
}
}
}
// TestSessionCompareCmd_HelpRoutes mirrors the parent-command routing checks:
// `session compare --help` must resolve through the session command tree.
func TestSessionCompareCmd_HelpRoutes(t *testing.T) {
for _, args := range [][]string{
{"session", "compare", "--help"},
{"session", "diff", "--help"},
} {
root := rootCmd
root.SetArgs(args)
t.Cleanup(func() { root.SetArgs(nil) })
if err := root.Execute(); err != nil {
t.Fatalf("help on %v: %v", args, err)
}
}
}
// TestSessionCompareCompletesBothPositionals pins that typing the first session
// id does not stop completion of the second.
func TestSessionCompareCompletesBothPositionals(t *testing.T) {
t.Setenv("HOME", t.TempDir())
repoDir := t.TempDir()
sh := newCompareSession(t, repoDir, session.SessionOptions{ReviewMode: session.ReviewModeCommit, DiffCommit: "abc123"}, nil)
sh.Finalize()
if err := sessionCompareCmd.Flags().Set("repo", repoDir); err != nil {
t.Fatalf("set --repo: %v", err)
}
t.Cleanup(func() {
_ = sessionCompareCmd.Flags().Set("repo", "")
sessionCompareRepoDir = ""
})
comps, directive := sessionCompareCmd.ValidArgsFunction(sessionCompareCmd, []string{"already-typed-id"}, "")
if directive != cobra.ShellCompDirectiveNoFileComp {
t.Errorf("directive = %v, want NoFileComp", directive)
}
found := false
for _, c := range comps {
if strings.HasPrefix(c, sh.SessionID+"\t") {
found = true
}
}
if !found {
t.Errorf("expected %s among second-positional completions, got %v", sh.SessionID, comps)
}
if comps, _ := sessionCompareCmd.ValidArgsFunction(sessionCompareCmd, []string{"a", "b"}, ""); comps != nil {
t.Errorf("expected no completions past the second positional, got %v", comps)
}
}
// TestRunSessionCompare_UnfinishedAfterRunIsNotResolved pins that only the
// coverage partitions that carry a current verdict (completed, reused) count as
// reviewed. A file the after run selected but failed on was never re-judged, so
// its earlier findings must not be reported as fixed.
func TestRunSessionCompare_UnfinishedAfterRunIsNotResolved(t *testing.T) {
t.Setenv("HOME", t.TempDir())
repoDir := t.TempDir()
opts := session.SessionOptions{ReviewMode: session.ReviewModeCommit, DiffCommit: "abc123"}
before := newCompareSession(t, repoDir, opts, []model.LlmComment{
{Path: "completed.go", StartLine: 40, EndLine: 40, Category: "bug", ExistingCode: "x := 1", Content: "re-checked and gone"},
{Path: "failed.go", StartLine: 9, EndLine: 9, Category: "bug", ExistingCode: "y := 2", Content: "the rerun never judged this"},
{Path: "reused.go", StartLine: 3, EndLine: 3, Category: "bug", ExistingCode: "z := 3", Content: "carried forward as clean"},
})
before.Finalize()
after := newCompareSession(t, repoDir, opts, nil)
after.SetFinalManifest(&session.RunManifest{
SchemaVersion: session.ManifestSchemaVersion,
RunID: after.SessionID,
Operation: session.OperationReview,
TerminalState: session.StateFailed,
Coverage: session.Coverage{
Selected: []session.CoverageItem{
{ItemID: "i1", Path: "completed.go"},
{ItemID: "i2", Path: "failed.go"},
{ItemID: "i3", Path: "reused.go"},
},
Completed: []session.CoverageItem{{ItemID: "i1", Path: "completed.go"}},
Failed: []session.CoverageItem{{ItemID: "i2", Path: "failed.go", Classification: session.FailureTimeout}},
Reused: []session.CoverageItem{{ItemID: "i3", Path: "reused.go"}},
},
})
after.Finalize()
useCompareRepo(t, repoDir, true)
out := captureStdout(t, func() {
if err := runSessionCompare(before.SessionID, after.SessionID); err != nil {
t.Fatalf("runSessionCompare: %v", err)
}
})
var decoded struct {
Resolved []model.LlmComment `json:"resolved"`
NotReviewed []model.LlmComment `json:"not_reviewed"`
}
if err := json.Unmarshal([]byte(out), &decoded); err != nil {
t.Fatalf("unmarshal: %v (out=%q)", err, out)
}
resolved := []string{}
for _, c := range decoded.Resolved {
resolved = append(resolved, c.Path)
}
if got, want := strings.Join(resolved, ","), "completed.go,reused.go"; got != want {
t.Errorf("resolved = %q, want %q", got, want)
}
if len(decoded.NotReviewed) != 1 || decoded.NotReviewed[0].Path != "failed.go" {
t.Errorf("not_reviewed = %+v, want only failed.go", decoded.NotReviewed)
}
}
// exportTo runs `session export` against repoDir, writing to a file under
// t.TempDir(), and returns the file's contents. The command reads its flags
// from package globals, so they are set and restored here rather than through
// a cobra round trip.
func exportTo(t *testing.T, repoDir, sessionID string) (string, error) {
t.Helper()
out := filepath.Join(t.TempDir(), "session.html")
prevRepo, prevOut := sessionExportRepoDir, sessionExportOutput
t.Cleanup(func() { sessionExportRepoDir, sessionExportOutput = prevRepo, prevOut })
sessionExportRepoDir, sessionExportOutput = repoDir, out
if err := runSessionExport(sessionID); err != nil {
return "", err
}
b, err := os.ReadFile(out)
if err != nil {
t.Fatalf("read exported file: %v", err)
}
return string(b), nil
}
func TestRunSessionExport_WritesFile(t *testing.T) {
setTestHome(t, t.TempDir())
repoDir := t.TempDir()
sh := session.New(repoDir, "main", "test-model", session.SessionOptions{
ReviewMode: session.ReviewModeCommit,
DiffCommit: "abc123",
})
sh.RecordReviewItemDone("a.go", "a.go", "a.go", "fp-a", []model.LlmComment{{Path: "a.go", Content: "note"}})
sh.Finalize()
body, err := exportTo(t, repoDir, sh.SessionID)
if err != nil {
t.Fatalf("runSessionExport: %v", err)
}
for _, want := range []string{
"<!DOCTYPE html>",
sh.SessionID,
"--font: -apple-system", // the stylesheet arrived inline
"inline-code", // so did the script
} {
if !strings.Contains(body, want) {
t.Errorf("exported file missing %q", want)
}
}
// The whole point of the artifact: nothing left to fetch.
for _, bad := range []string{"/static/", "ZgotmplZ", `<a href="/`} {
if strings.Contains(body, bad) {
t.Errorf("exported file is not self-contained, contains %q", bad)
}
}
}
// TestRunSessionExport_DefaultsToNewest pins the no-argument form. A successful
// `ocr review` never prints its session id, so requiring one would push every
// CI archive step through `--format json`.
func TestRunSessionExport_DefaultsToNewest(t *testing.T) {
setTestHome(t, t.TempDir())
repoDir := t.TempDir()
older := session.New(repoDir, "main", "test-model", session.SessionOptions{ReviewMode: session.ReviewModeCommit, DiffCommit: "old"})
older.RecordReviewItemDone("a.go", "a.go", "a.go", "fp-a", nil)
older.Finalize()
time.Sleep(1100 * time.Millisecond) // StartTime is persisted at second resolution
newer := session.New(repoDir, "main", "test-model", session.SessionOptions{ReviewMode: session.ReviewModeCommit, DiffCommit: "new"})
newer.RecordReviewItemDone("b.go", "b.go", "b.go", "fp-b", nil)
newer.Finalize()
body, err := exportTo(t, repoDir, "")
if err != nil {
t.Fatalf("runSessionExport: %v", err)
}
if !strings.Contains(body, newer.SessionID) {
t.Errorf("export defaulted to the wrong session; want %s", newer.SessionID)
}
if strings.Contains(body, older.SessionID) {
t.Errorf("export contains the older session id %s", older.SessionID)
}
}
func TestRunSessionExport_Errors(t *testing.T) {
tests := []struct {
name string
seed bool // create a session first
sessionID string
wantErr string
}{
{name: "unknown session id", seed: true, sessionID: "no-such-session", wantErr: "no-such-session"},
{name: "no sessions at all", seed: false, sessionID: "", wantErr: "no sessions found"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
setTestHome(t, t.TempDir())
repoDir := t.TempDir()
if tt.seed {
sh := session.New(repoDir, "main", "m", session.SessionOptions{ReviewMode: session.ReviewModeCommit, DiffCommit: "c"})
sh.RecordReviewItemDone("a.go", "a.go", "a.go", "fp", nil)
sh.Finalize()
}
out := filepath.Join(t.TempDir(), "session.html")
prevRepo, prevOut := sessionExportRepoDir, sessionExportOutput
t.Cleanup(func() { sessionExportRepoDir, sessionExportOutput = prevRepo, prevOut })
sessionExportRepoDir, sessionExportOutput = repoDir, out
err := runSessionExport(tt.sessionID)
if err == nil {
t.Fatal("expected an error")
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Errorf("error = %q, want it to mention %q", err, tt.wantErr)
}
// A failed export must not leave a truncated artifact behind.
if _, statErr := os.Stat(out); statErr == nil {
t.Error("failed export created the output file anyway")
}
})
}
}